From ae1670abbf2ec0f9a6ded4d9a19635a79fc5cbae Mon Sep 17 00:00:00 2001 From: Giles Knap Date: Wed, 29 Apr 2026 12:37:54 +0000 Subject: [PATCH 1/2] feat: Add optional Claude Code sandbox to devcontainer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new copier questions gate a sandboxed Claude Code setup: add_claude (top-level — env var blockers, ~/.claude bind mount, Claude Code CLI install, postCreate/postStart hooks, node, justfile); install_gh and install_glab (each gated on add_claude — adds the CLI install plus per-repo PAT volume mount and matching just recipe). Breaks the template/.devcontainer symlink so devcontainer.json can be Jinja-conditional. The meta repo's own .devcontainer/devcontainer.json and Dockerfile become the add_claude=no baseline. A new test_meta_matches_no_claude_template drift test renders the template with all Claude opts off and byte-diffs the result against the meta repo to catch divergence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .devcontainer/devcontainer.json | 2 + Dockerfile | 4 +- copier.yml | 24 ++++ example-answers.yml | 3 + template/.devcontainer | 1 - .../.devcontainer/devcontainer.json.jinja | 115 ++++++++++++++++++ ...dd_claude %}postCreate.sh{% endif %}.jinja | 13 ++ ...add_claude %}postStart.sh{% endif %}.jinja | 25 ++++ template/Dockerfile.jinja | 24 +++- ... if add_claude %}justfile{% endif %}.jinja | 23 ++++ tests/test_example.py | 22 ++++ 11 files changed, 252 insertions(+), 4 deletions(-) delete mode 120000 template/.devcontainer create mode 100644 template/.devcontainer/devcontainer.json.jinja create mode 100755 template/.devcontainer/{% if add_claude %}postCreate.sh{% endif %}.jinja create mode 100755 template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja create mode 100644 template/{% if add_claude %}justfile{% endif %}.jinja diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c12cce59..cbbd4468 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,6 +8,8 @@ "remoteEnv": { // Allow X11 apps to run inside the container "DISPLAY": "${localEnv:DISPLAY}", + // Mark this shell as running inside the devcontainer + "IN_DEVCONTAINER": "1", // Put things that allow it in the persistent cache "PRE_COMMIT_HOME": "/cache/pre-commit", "UV_CACHE_DIR": "/cache/uv", diff --git a/Dockerfile b/Dockerfile index d4dfcaaf..e8462413 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# The developer stage is used as a devcontainer including dev versions -# of the build dependencies +# The devcontainer should use the developer target and run as root with podman +# or docker with user namespaces. FROM ghcr.io/diamondlightsource/ubuntu-devcontainer:noble AS developer # Add any system dependencies for the developer/build environment here diff --git a/copier.yml b/copier.yml index 96ee9a36..23459245 100644 --- a/copier.yml +++ b/copier.yml @@ -113,6 +113,30 @@ docker_debug: be useful if debugging the service inside of the cluster infrastructure is required. +add_claude: + type: bool + help: | + Add a Claude Code sandbox to the devcontainer? + Disables host SSH agent / VS Code git credential injection inside + the container, mounts ~/.claude from the host, installs Claude Code + CLI, and enables `--dangerously-skip-permissions` autopilot mode. + +install_gh: + type: bool + when: "{{ add_claude }}" + help: | + Install the GitHub CLI (gh) so Claude can push/pull via PAT auth? + Only useful inside the Claude sandbox — ordinary users typically + rely on SSH keys or VS Code git credentials. + +install_glab: + type: bool + when: "{{ add_claude }}" + help: | + Install the GitLab CLI (glab) for projects that talk to a GitLab + instance (e.g. gitlab.diamond.ac.uk submodules)? + Only useful inside the Claude sandbox. + docs_type: type: str help: | diff --git a/example-answers.yml b/example-answers.yml index 17869585..91ea509a 100644 --- a/example-answers.yml +++ b/example-answers.yml @@ -7,6 +7,9 @@ description: An expanded https://github.com/DiamondLightSource/python-copier-tem distribution_name: dls-python-copier-template-example docker: true docker_debug: true +add_claude: true +install_gh: true +install_glab: true docs_type: sphinx git_platform: github.com github_org: DiamondLightSource diff --git a/template/.devcontainer b/template/.devcontainer deleted file mode 120000 index 485dcb28..00000000 --- a/template/.devcontainer +++ /dev/null @@ -1 +0,0 @@ -../.devcontainer \ No newline at end of file diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja new file mode 100644 index 00000000..ff0b38d8 --- /dev/null +++ b/template/.devcontainer/devcontainer.json.jinja @@ -0,0 +1,115 @@ +// For format details, see https://containers.dev/implementors/json_reference/ +{ + "name": "Python 3 Developer Container", + "build": { + "dockerfile": "../Dockerfile", + "target": "developer" + },{% if add_claude %} + // Tell VS Code the remote user is root so copyGitConfig writes to + // /root/.gitconfig (matching $HOME in the shell); otherwise it falls back + // to the base image's USER and the copy lands in the wrong home. + "remoteUser": "root",{% endif %} + "remoteEnv": { + // Allow X11 apps to run inside the container + "DISPLAY": "${localEnv:DISPLAY}",{% if add_claude %} + // Disable SSH agent forwarding — prevents Claude from using host SSH keys + "SSH_AUTH_SOCK": "", + // Disable VS Code git credential injection — prevents askpass from + // relaying host GitHub credentials into the container over the IPC socket + "GIT_ASKPASS": "", + "VSCODE_GIT_IPC_HANDLE": "", + "VSCODE_GIT_ASKPASS_MAIN": "", + "VSCODE_GIT_ASKPASS_NODE": "", + "VSCODE_GIT_ASKPASS_EXTRA_ARGS": "",{% endif %} + // Mark this shell as running inside the devcontainer + "IN_DEVCONTAINER": "1", + // Put things that allow it in the persistent cache + "PRE_COMMIT_HOME": "/cache/pre-commit", + "UV_CACHE_DIR": "/cache/uv", + "UV_PYTHON_CACHE_DIR": "/cache/uv-python", + // Make a venv that is specific for this workspace path as the cache is shared + "UV_PROJECT_ENVIRONMENT": "/cache/venv-for${localWorkspaceFolder}", + // Do the equivalent of "activate" the venv so we don't have to "uv run" everything + "VIRTUAL_ENV": "/cache/venv-for${localWorkspaceFolder}", + "PATH": "/cache/venv-for${localWorkspaceFolder}/bin:${containerEnv:PATH}" + }, + "customizations": { + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + // Use the container's python by default + "python.defaultInterpreterPath": "/cache/venv-for${localWorkspaceFolder}/bin/python", + // Don't activate the venv as it is already in the PATH + "python.terminal.activateEnvInCurrentTerminal": false, + "python.terminal.activateEnvironment": false, + // Workaround to prevent garbled python REPL in the terminal + // https://github.com/microsoft/vscode-python/issues/25505 + "python.terminal.shellIntegration.enabled": false{% if sphinx %}, + // Only forward explicitly listed ports — auto-detection races with + // sphinx-autobuild and steals the port on restart + "remote.autoForwardPorts": false{% endif %} + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "github.vscode-github-actions", + "tamasfe.even-better-toml", + "redhat.vscode-yaml", + "ryanluker.vscode-coverage-gutters", + "charliermarsh.ruff", + "ms-azuretools.vscode-docker"{% if add_claude %}, + "anthropic.claude-code"{% endif %} + ] + } + },{% if sphinx %} + // Explicitly forward sphinx-autobuild port (auto-detection disabled above) + "forwardPorts": [ + 8000 + ],{% endif %} + // Create host-side dirs needed for bind mounts before the container starts + "initializeCommand": "mkdir -p ${localEnv:HOME}/.config/terminal-config{% if add_claude %} ${localEnv:HOME}/.claude{% endif %}", + "runArgs": [ + // Allow the container to access the host X11 display and EPICS CA + "--net=host", + // Make sure SELinux does not disable with access to host filesystems like tmp + "--security-opt=label=disable" + ], + "mounts": [ + // Mount in the user terminal config folder so it can be edited + { + "source": "${localEnv:HOME}/.config/terminal-config", + "target": "/user-terminal-config", + "type": "bind" + }, + // Keep a persistent cross container cache for uv, pre-commit, and the venvs + { + "source": "devcontainer-shared-cache", + "target": "/cache", + "type": "volume" + }{% if install_gh %}, + // Persist gh auth across container rebuilds with per-repo scoped PAT + { + "source": "gh-auth-${localWorkspaceFolderBasename}", + "target": "/root/.config/gh", + "type": "volume" + }{% endif %}{% if install_glab %}, + // Persist glab auth across container rebuilds (GitLab CLI) + { + "source": "glab-auth-${localWorkspaceFolderBasename}", + "target": "/root/.config/glab-cli", + "type": "volume" + }{% endif %}{% if add_claude %}, + // Mount Claude config from host (settings, memory, skills) + { + "source": "${localEnv:HOME}/.claude", + "target": "/root/.claude", + "type": "bind" + }{% endif %} + ], + // Mount the parent as /workspaces so we can pip install peers as editable + "workspaceMount": "source=${localWorkspaceFolder}/..,target=/workspaces,type=bind",{% if add_claude %} + "postCreateCommand": ".devcontainer/postCreate.sh", + "postStartCommand": ".devcontainer/postStart.sh"{% else %} + // After the container is created, recreate the venv then make pre-commit first run faster + "postCreateCommand": "uv venv --clear && uv sync && pre-commit install --install-hooks"{% endif %} +} diff --git a/template/.devcontainer/{% if add_claude %}postCreate.sh{% endif %}.jinja b/template/.devcontainer/{% if add_claude %}postCreate.sh{% endif %}.jinja new file mode 100755 index 00000000..9c1c62a0 --- /dev/null +++ b/template/.devcontainer/{% if add_claude %}postCreate.sh{% endif %}.jinja @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +# Install Claude Code CLI +curl -fsSL https://claude.ai/install.sh | bash + +# Install Python dependencies and pre-commit hooks +uv venv --clear +uv sync +pre-commit install --install-hooks + +# Initialise git submodules if any are declared +[ -f .gitmodules ] && git submodule update --init || true diff --git a/template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja b/template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja new file mode 100755 index 00000000..78275e2a --- /dev/null +++ b/template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja @@ -0,0 +1,25 @@ +#!/bin/bash +set -euo pipefail + +# Wipe any credential helpers and SSH URL rewrites injected by VS Code's +# Dev Containers extension when it copies the host gitconfig. An empty-string +# value resets the helper list so only an explicit PAT via `just gh-auth` +# can authenticate to remotes. +git config --global credential.helper '' +git config --global --unset-all url.ssh://git@github.com/.insteadOf 2>/dev/null || true + +# Force all SSH-style remotes to use HTTPS so the gh/glab credential helpers +# handle auth. This keeps the container SSH-key-free (Claude stays sandboxed) +# while still allowing push/pull on repos whose remotes are set to git@...:. +git config --global url."https://github.com/".insteadOf "git@github.com:" +{%- if install_glab %} +git config --global url."https://gitlab.diamond.ac.uk/".insteadOf "git@gitlab.diamond.ac.uk:" +{%- endif %} + +{% if install_gh -%} +# If gh CLI has cached credentials (survive container rebuild), re-register +# its git credential helper so HTTPS remotes authenticate automatically. +if gh auth status &>/dev/null; then + gh auth setup-git +fi +{%- endif %} diff --git a/template/Dockerfile.jinja b/template/Dockerfile.jinja index 342d04c1..4909f07f 100644 --- a/template/Dockerfile.jinja +++ b/template/Dockerfile.jinja @@ -5,7 +5,29 @@ FROM ghcr.io/diamondlightsource/ubuntu-devcontainer:noble AS developer # Add any system dependencies for the developer/build environment here RUN apt-get update -y && apt-get install -y --no-install-recommends \ graphviz \ - && apt-get dist-clean{% if docker %} + && apt-get dist-clean{% if add_claude %} + +# Node is required by Claude Code's hook runtime +RUN apt-get update -y && apt-get install -y --no-install-recommends \ + nodejs \ + && apt-get dist-clean{% endif %}{% if install_gh %} + +# GitHub CLI — used by Claude to authenticate to github.com via PAT +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | \ + dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ + chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + apt-get dist-clean{% endif %}{% if install_glab %} + +# GitLab CLI — used by Claude to authenticate to gitlab instances via PAT. +# No apt repo, so install from the upstream release tarball. +ARG GLAB_VERSION=1.92.1 +RUN curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C /tmp bin/glab && \ + install -m 0755 /tmp/bin/glab /usr/local/bin/glab && \ + rm -rf /tmp/bin{% endif %}{% if docker %} # The build stage installs the context into the venv FROM developer AS build diff --git a/template/{% if add_claude %}justfile{% endif %}.jinja b/template/{% if add_claude %}justfile{% endif %}.jinja new file mode 100644 index 00000000..c7fe2f97 --- /dev/null +++ b/template/{% if add_claude %}justfile{% endif %}.jinja @@ -0,0 +1,23 @@ +# Start Claude Code in sandbox mode (no SSH agent, skip permission prompts) +claude: + SSH_AUTH_SOCK= IS_SANDBOX=1 claude --dangerously-skip-permissions{% if install_gh %} + + +# Authenticate gh CLI with a GitHub PAT (token not stored in shell history) +gh-auth: + #!/bin/bash + read -sp "GitHub PAT: " t && echo + echo "$t" | gh auth login --with-token + unset t + gh auth setup-git + gh auth status{% endif %}{% if install_glab %} + + +# Authenticate glab CLI with a GitLab PAT (token not stored in shell history). +# --git-protocol https prevents glab's SSH insteadOf rewrite. +glab-auth hostname="gitlab.com": + #!/bin/bash + read -sp "GitLab PAT for {{ '{{' }} hostname {{ '}}' }}: " t && echo + echo "$t" | glab auth login --stdin --hostname {{ '{{' }} hostname {{ '}}' }} --git-protocol https + unset t + glab auth status{% endif %} diff --git a/tests/test_example.py b/tests/test_example.py index ea94347b..42459c11 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -206,6 +206,28 @@ def test_gitignore_same(): assert top_gi.read() == template_gi.read() +def test_meta_matches_no_claude_template(tmp_path: Path): + """The meta repo's .devcontainer/devcontainer.json and Dockerfile must + match what the template renders with all Claude options off (and docker + off, since the meta repo isn't a deployable service). Catches drift + between the meta repo's own dev experience and what we ship to projects + that opt out of the Claude sandbox.""" + copy_project( + tmp_path, + add_claude=False, + install_gh=False, + install_glab=False, + docker=False, + docker_debug=False, + ) + for relpath in [".devcontainer/devcontainer.json", "Dockerfile"]: + rendered = (tmp_path / relpath).read_text() + meta = (TOP / relpath).read_text() + assert rendered == meta, ( + f"{relpath} drift between meta repo and template (add_claude=no)" + ) + + def test_private_member_access(tmp_path: Path): code = """ class MyClass: From 5b874f969235edced14f4956491d60febb7662db Mon Sep 17 00:00:00 2001 From: Giles Knap Date: Wed, 29 Apr 2026 12:38:12 +0000 Subject: [PATCH 2/2] fix: Disable port auto-detection, forward 8000 explicitly VS Code's auto-detection races with sphinx-autobuild on container restart and steals the port, breaking the live-reload docs preview. Disable it and forward 8000 explicitly so the docs URL is stable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .devcontainer/devcontainer.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cbbd4468..e326fb05 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -31,7 +31,10 @@ "python.terminal.activateEnvironment": false, // Workaround to prevent garbled python REPL in the terminal // https://github.com/microsoft/vscode-python/issues/25505 - "python.terminal.shellIntegration.enabled": false + "python.terminal.shellIntegration.enabled": false, + // Only forward explicitly listed ports — auto-detection races with + // sphinx-autobuild and steals the port on restart + "remote.autoForwardPorts": false }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ @@ -45,7 +48,11 @@ ] } }, - // Create the config folder for the bash-config feature and uv cache + // Explicitly forward sphinx-autobuild port (auto-detection disabled above) + "forwardPorts": [ + 8000 + ], + // Create host-side dirs needed for bind mounts before the container starts "initializeCommand": "mkdir -p ${localEnv:HOME}/.config/terminal-config", "runArgs": [ // Allow the container to access the host X11 display and EPICS CA