From be45664522a3434acf9169ca41b956044b576ef2 Mon Sep 17 00:00:00 2001 From: nozaq Date: Tue, 11 Aug 2026 06:58:38 +0000 Subject: [PATCH 1/5] feature(claude-code): add claude-code feature --- src/claude-code/README.md | 53 +++++++++++++ src/claude-code/claude-code.asc | 29 +++++++ src/claude-code/devcontainer-feature.json | 32 ++++++++ src/claude-code/install.sh | 96 +++++++++++++++++++++++ test/claude-code/scenarios.json | 11 +++ test/claude-code/test.sh | 16 ++++ test/claude-code/without_apt_source.sh | 14 ++++ 7 files changed, 251 insertions(+) create mode 100644 src/claude-code/README.md create mode 100644 src/claude-code/claude-code.asc create mode 100644 src/claude-code/devcontainer-feature.json create mode 100755 src/claude-code/install.sh create mode 100644 test/claude-code/scenarios.json create mode 100755 test/claude-code/test.sh create mode 100755 test/claude-code/without_apt_source.sh diff --git a/src/claude-code/README.md b/src/claude-code/README.md new file mode 100644 index 0000000..400d44d --- /dev/null +++ b/src/claude-code/README.md @@ -0,0 +1,53 @@ +# Claude Code (claude-code) + +Installs Claude Code, Anthropic's agentic coding CLI, from the official APT repository. + +## Example Usage + +```json +"features": { + "ghcr.io/bare-devcontainer/features/claude-code:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | APT package version of claude-code to install. Use `latest` for the newest available version, or an exact version string as reported by `apt-cache policy claude-code`. | string | latest | +| keepAptSource | Keep the Claude Code APT source and signing key in the image so the package can be upgraded later. When false, both are removed after installation. | boolean | true | + +## Details + +- Requires a Debian or Ubuntu based image. +- The repository signing key is vendored with the feature and installed to + `/usr/share/keyrings/claude-code.asc`, so no key is downloaded at build time. +- The APT source is written to `/etc/apt/sources.list.d/claude-code.list`. Set + `keepAptSource` to `false` to drop the source and key once installation + finishes. +### Persisting configuration and credentials + +Claude Code keeps its credentials, settings and history under `~/.claude`. The +feature declares a volume mount so those survive container rebuilds: + +```json +"mounts": [ + { + "source": "${devcontainerId}-claude-code-config", + "target": "/var/lib/claude-code", + "type": "volume" + } +] +``` + +A feature's mount target is static and cannot reference the remote user's home +directory, so the volume is mounted at `/var/lib/claude-code` and the install +script links `~/.claude` to it. That keeps the mount working whatever the image +uses as its remote user. The directory is created and owned by the remote user +at build time, which is also the ownership the named volume is seeded with on +first use. + +The volume is per dev container (`${devcontainerId}`) and is not shared between +projects. Mounts declared by a feature cannot be disabled from `devcontainer.json`; +if you need different storage, point `~/.claude` elsewhere via the +`CLAUDE_CONFIG_DIR` environment variable. diff --git a/src/claude-code/claude-code.asc b/src/claude-code/claude-code.asc new file mode 100644 index 0000000..69a4aa5 --- /dev/null +++ b/src/claude-code/claude-code.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGnK73ABEACnbytJXkjweYrwIr0aLEFRlH+C0nF44KxFc7gQmJ6PjSPMGZAD +dxZcaixU7zZl8WxEpVO0wLmIH8cf2zGOdyuZg1Yaugk1vHb2b8WBhAGCQJdPgB8W +XquedepEYtk56uP/gCoTjJDUZluEGBHnlnuujSJ4orxEdhSykEoAUfJZGEILPpMd +bphFt/Sn+Eb/TxM5jpKPdwnv8AShNF/1mZU1fWTQq9tRKJUakZj04gdaDFElQXak +CtTij+GT6yoYCARSHwGO+PC/Pr6q4tc+D7LRjxSBvUWDoFSmlqb/PJ1hj9D/7I2O +e4XXniAPWMR56KvxHlzOzrNQdJujbJdSkCwh1ZijkSd3y8ayW5WYUTGdRab99NUw +agzlabe/VVF6kzJ0Scn5q3PihB2Y9Bwo0CKnkYk7a7KT77EWv0Kkq+VHmOtqX3a2 +hhX+b6a6ve9rzJ1qZYGj+obv/C3Sx1LzUjAfqVy7RJDf2uAoP5t2g8u/TkSpUxhM +VEjZBkSxYZhMyzQM6t8IgkUfnSrIPTHixbDWARZ4beMOBjxyPZK1nP7OOrNR3TkK +JtwLMQAabURCDnL0PjS0iwBTU4jtumBD1XSULyWuoTvMljrpQr1nV1oDyOt0OLqa +KA2McWtd9PdXhC8y2EIg7TmrTlJLfHYbdmkiCYj4J49Q8HWkN/6WE+RTUwARAQAB +tD5BbnRocm9waWMgQ2xhdWRlIENvZGUgUmVsZWFzZSBTaWduaW5nIDxzZWN1cml0 +eUBhbnRocm9waWMuY29tPokCUQQTAQoAOxYhBDHd3iTd+rZ59C170rqpKf8afsrO +BQJpyu9wAhsPBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJELqpKf8afsrO +l5IP/2I8X1dFy5xYczWB/coIxGjuzS/V6ByZGZZEJsbr04pmuHiFUykJqPGWGQ6q +U0YF5iEwvEkaagS5m7DzhSEf3FM3Cgafax/6d70tar9Vr1D+w6uPfxetu7u/WYJp +aolIsdh5fTrBh9zSM1Njl8FM8wG8CwZQjS33Oa7d8cwRkgdUWbt6LXgz+cTQNuBn +BgW6Ks7oZFI25dfu0ojDR+aDFJg4+4wZoyDLPvJz1SIrJ5WFGs67zsx9SfS3yZnf +XKmBe+f0dUy+GJ2nFZrXFf99+c0dPEHYO8DCeAHZizjkFrdYtUHdDU0YDYEGkLJa +bE+pgcpkHf5EvsZzHsyDbl95W/eh7pcXMbwkN+W4CBYUE9X4uHhqzWaC5yAVRWUA +1BJ9V4LjZfHPLEJt0I3TxzXiEg9/BVeaTYq9RjaxIFo9Nfk158HqJY6SA5jslBlx +Gv/No8u+xVcze2UJyGVfEIUfm92+0UAIkny3+5cuVV0ICzJxXlXj0CnLM9Lt50wE +p3suVwuBEviCbZ08eAH1Ht8gbBdSsiOkIU8CX3v/scwHHx5q0+NBL6xLrQObg13a +tRXBlKObfElkPN3lTUbUnJOW4U8uSjH8VRP+AujKWMDFe7x0zCs+iYY1mTOvbrTS +9n3CmZUmbynZ+E/QWNENpW/pDNZdWFy43PASmML5FHu4m9Sn +=oqMI +-----END PGP PUBLIC KEY BLOCK----- diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json new file mode 100644 index 0000000..b4e35be --- /dev/null +++ b/src/claude-code/devcontainer-feature.json @@ -0,0 +1,32 @@ +{ + "id": "claude-code", + "version": "1.0.0", + "name": "Claude Code", + "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/claude-code", + "description": "Installs Claude Code, Anthropic's agentic coding CLI, from the official APT repository.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest" + ], + "default": "latest", + "description": "APT package version of claude-code to install. Use \"latest\" for the newest available version, or an exact version string as reported by `apt-cache policy claude-code`." + }, + "keepAptSource": { + "type": "boolean", + "default": true, + "description": "Keep the Claude Code APT source and signing key in the image so the package can be upgraded later. When false, both are removed after installation." + } + }, + "mounts": [ + { + "source": "${devcontainerId}-claude-code-config", + "target": "/var/lib/claude-code", + "type": "volume" + } + ], + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh new file mode 100755 index 0000000..6573bf3 --- /dev/null +++ b/src/claude-code/install.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# Installs the Claude Code CLI from Anthropic's official APT repository and +# prepares the remote user's configuration directory (~/.claude). +# +# Expected environment variables, from this feature's own options: +# +# VERSION APT package version to install, or "latest". +# KEEPAPTSOURCE "true" to leave the APT source and signing key in the +# image. +# +# and from the Dev Container specification, injected by the CLI: +# +# _REMOTE_USER The account the container is attached as, and therefore +# the one Claude Code reads its configuration as. +# _REMOTE_USER_HOME Home directory of _REMOTE_USER. + +set -euo pipefail + +VERSION="${VERSION:-latest}" +KEEPAPTSOURCE="${KEEPAPTSOURCE:-true}" + +FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APT_KEYRING="/usr/share/keyrings/claude-code.asc" +APT_SOURCE="/etc/apt/sources.list.d/claude-code.list" +APT_REPOSITORY="https://downloads.claude.ai/claude-code/apt/stable stable main" + +if [ "$(id -u)" -ne 0 ]; then + echo "(!) This feature must be installed as root." >&2 + exit 1 +fi + +if ! command -v apt-get >/dev/null 2>&1; then + echo "(!) This feature requires a Debian/Ubuntu based image (apt-get not found)." >&2 + exit 1 +fi + +export DEBIAN_FRONTEND=noninteractive + +# The APT repository is served over HTTPS, which fails on minimal images that +# ship without a CA bundle. +if [ ! -e /etc/ssl/certs/ca-certificates.crt ]; then + apt-get update -y + apt-get install -y --no-install-recommends ca-certificates +fi + +# The signing key is vendored with the feature so installation does not depend +# on fetching a key over the network at build time. +install -m 0644 "${FEATURE_DIR}/claude-code.asc" "${APT_KEYRING}" +echo "deb [signed-by=${APT_KEYRING}] ${APT_REPOSITORY}" > "${APT_SOURCE}" + +# Index the source added above. +apt-get update -y + +if [ "${VERSION}" = "latest" ] || [ -z "${VERSION}" ]; then + package="claude-code" +else + package="claude-code=${VERSION}" +fi +apt-get install -y --no-install-recommends "${package}" + +if [ "${KEEPAPTSOURCE}" != "true" ]; then + rm -f "${APT_SOURCE}" "${APT_KEYRING}" +fi + +rm -rf /var/lib/apt/lists/* + +# Claude Code stores credentials, settings and history under ~/.claude. The +# feature mounts a volume at CONFIG_DIR and ~/.claude is linked to it, so the +# configuration survives rebuilds. The volume target cannot depend on the remote +# user's home directory (feature mounts are static), hence the indirection. +USERNAME="${_REMOTE_USER:-root}" +USER_HOME="${_REMOTE_USER_HOME:-$(getent passwd "${USERNAME}" | cut -d: -f6)}" +USER_GROUP="$(id -gn "${USERNAME}")" +CONFIG_DIR="/var/lib/claude-code" + +# A named volume is seeded from the image on first use, so creating the +# directory with the right ownership here also makes the volume itself owned by +# the remote user. +mkdir -p "${CONFIG_DIR}" +chown "${USERNAME}:${USER_GROUP}" "${CONFIG_DIR}" + +if [ -n "${USER_HOME}" ]; then + config_link="${USER_HOME}/.claude" + if [ -d "${config_link}" ] && [ ! -L "${config_link}" ]; then + # Preserve whatever the base image or an earlier feature put there. + cp -a "${config_link}/." "${CONFIG_DIR}/" + rm -rf "${config_link}" + else + rm -f "${config_link}" + fi + ln -s "${CONFIG_DIR}" "${config_link}" + chown -h "${USERNAME}:${USER_GROUP}" "${config_link}" +fi + +echo "Installed $(claude --version 2>/dev/null || echo 'claude-code')." diff --git a/test/claude-code/scenarios.json b/test/claude-code/scenarios.json new file mode 100644 index 0000000..81337e1 --- /dev/null +++ b/test/claude-code/scenarios.json @@ -0,0 +1,11 @@ +{ + "without_apt_source": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "remoteUser": "vscode", + "features": { + "claude-code": { + "keepAptSource": false + } + } + } +} diff --git a/test/claude-code/test.sh b/test/claude-code/test.sh new file mode 100755 index 0000000..d295a05 --- /dev/null +++ b/test/claude-code/test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# +# Default test for the claude-code feature: the CLI is on PATH and the remote +# user owns its configuration directory. + +set -e + +source dev-container-features-test-lib + +check "claude is on PATH" bash -c "command -v claude" +check "claude reports a version" bash -c "claude --version" +check "config directory links to the mounted volume" bash -c "test \"\$(readlink \"${HOME}/.claude\")\" = /var/lib/claude-code" +check "config directory is writable by the remote user" bash -c "touch \"${HOME}/.claude/.write-test\" && rm \"${HOME}/.claude/.write-test\"" +check "apt source is present" bash -c "test -f /etc/apt/sources.list.d/claude-code.list" + +reportResults diff --git a/test/claude-code/without_apt_source.sh b/test/claude-code/without_apt_source.sh new file mode 100755 index 0000000..7a7005c --- /dev/null +++ b/test/claude-code/without_apt_source.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# Scenario test: with keepAptSource disabled, the CLI is still installed but the +# APT source and signing key are gone from the image. + +set -e + +source dev-container-features-test-lib + +check "claude is on PATH" bash -c "command -v claude" +check "apt source is removed" bash -c "! test -e /etc/apt/sources.list.d/claude-code.list" +check "signing key is removed" bash -c "! test -e /usr/share/keyrings/claude-code.asc" + +reportResults From 90a8c028fd139b38e04201c7614400ca9344a08c Mon Sep 17 00:00:00 2001 From: nozaq Date: Tue, 11 Aug 2026 07:52:47 +0000 Subject: [PATCH 2/5] ci: add smoke test --- .github/workflows/smoke-test.yml | 121 +++++++++++++++++++++++++ AGENTS.md | 12 +++ renovate.jsonc | 16 ++++ src/claude-code/smoke_test.sh | 41 +++++++++ test/claude-code/scenarios.json | 11 --- test/claude-code/test.sh | 16 ---- test/claude-code/without_apt_source.sh | 14 --- 7 files changed, 190 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/smoke-test.yml create mode 100755 src/claude-code/smoke_test.sh delete mode 100644 test/claude-code/scenarios.json delete mode 100755 test/claude-code/test.sh delete mode 100755 test/claude-code/without_apt_source.sh diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 0000000..d35f9f1 --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -0,0 +1,121 @@ +name: Smoke Test + +# Starts a real Dev Container per feature and base image combination, with the +# feature added at its default options, and runs the feature's smoke_test.sh +# inside it as the remote user. + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # renovate: datasource=npm depName=@devcontainers/cli + DEVCONTAINER_CLI_VERSION: "0.88.0" + +jobs: + discover: + name: Discover features + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + features: ${{ steps.discover.outputs.features }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: List features under src/ + id: discover + run: | + set -euo pipefail + features="$(find src -mindepth 2 -maxdepth 2 -name devcontainer-feature.json -printf '%h\n' \ + | xargs -r -n1 basename \ + | sort \ + | jq -R -s -c 'split("\n") | map(select(length > 0))')" + + if [ "${features}" = "[]" ]; then + echo "::error::No features found under src/" + exit 1 + fi + + echo "features=${features}" >> "${GITHUB_OUTPUT}" + echo "Discovered features: ${features}" + + smoke-test: + name: ${{ matrix.feature }} on ${{ matrix.image }} + needs: discover + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + feature: ${{ fromJSON(needs.discover.outputs.features) }} + # Deliberately floating tags: features have to keep working against the + # base images users actually pull today, not a digest frozen at merge + # time. Renovate therefore does not pin these. + image: + - mcr.microsoft.com/devcontainers/base:debian + - ghcr.io/bare-devcontainer/debian:trixie + env: + FEATURE: ${{ matrix.feature }} + IMAGE: ${{ matrix.image }} + WORKSPACE: ${{ runner.temp }}/smoke + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the Dev Containers CLI + run: npm install --global "@devcontainers/cli@${DEVCONTAINER_CLI_VERSION}" + + - name: Assemble the test workspace + run: | + set -euo pipefail + + if [ ! -f "src/${FEATURE}/smoke_test.sh" ]; then + echo "::error::src/${FEATURE}/smoke_test.sh is missing; every feature must ship one." + exit 1 + fi + + # The feature is copied next to the generated devcontainer.json so it + # can be referenced as a local path, which installs the working tree + # copy rather than a published release. + rm -rf "${WORKSPACE}" + mkdir -p "${WORKSPACE}/.devcontainer" + cp -R "src/${FEATURE}" "${WORKSPACE}/.devcontainer/${FEATURE}" + + # The mount is spelled out rather than left to the CLI default so the + # path the smoke test runs from is known here. + jq -n \ + --arg image "${IMAGE}" \ + --arg feature "./${FEATURE}" \ + --arg mount "source=${WORKSPACE},target=/workspaces/smoke,type=bind" \ + '{ + name: "smoke-test", + image: $image, + workspaceMount: $mount, + workspaceFolder: "/workspaces/smoke", + features: {($feature): {}} + }' \ + > "${WORKSPACE}/.devcontainer/devcontainer.json" + + jq . "${WORKSPACE}/.devcontainer/devcontainer.json" + + - name: Start the Dev Container + run: devcontainer up --workspace-folder "${WORKSPACE}" --remove-existing-container + + - name: Run smoke_test.sh + run: | + devcontainer exec --workspace-folder "${WORKSPACE}" \ + bash "/workspaces/smoke/.devcontainer/${FEATURE}/smoke_test.sh" diff --git a/AGENTS.md b/AGENTS.md index a639d8d..2c00fe1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,18 @@ This repository publishes dev container features for use with the [Dev Container - `ci: pin action SHAs` - `chore: update renovate config` +## Testing + +- Every feature under `src/` ships a `smoke_test.sh` next to its + `devcontainer-feature.json`. The `Smoke Test` workflow starts a Dev Container + per feature and base image, with the feature installed at its default + options, and runs that script inside it as the remote user. +- Smoke tests are self-contained: they run in a plain container without the + `devcontainer features test` helper library, so they must not source + `dev-container-features-test-lib`, and they exit non-zero when a check fails. +- Assert against `${HOME}` rather than a hard-coded home directory: the base + images under test use different remote users. + ## GitHub Actions - Pin every action to a full commit SHA with a `# vX.Y.Z` comment, diff --git a/renovate.jsonc b/renovate.jsonc index d87ba62..f34a5ee 100644 --- a/renovate.jsonc +++ b/renovate.jsonc @@ -46,6 +46,22 @@ "// renovate: datasource=(?\\S+)( registryUrl=(?\\S+))? depName=(?\\S+)( versioning=(?\\S+))?( extractVersion=(?\\S+))?\\s+\"[^\"]+\":\\s*\"(?[^@\"\\s]+)\"" ], "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" + }, + { + // Tool versions pinned as workflow `env:` entries, which no built-in + // manager inspects. Actions themselves are covered by the + // github-actions manager and need no comment. + "customType": "regex", + "managerFilePatterns": [ + "/^\\.github/workflows/[^/]+\\.ya?ml$/" + ], + "matchStrings": [ + // A `# renovate:` comment bound to the quoted value of the YAML + // key on the following line, + // e.g. DEVCONTAINER_CLI_VERSION: "0.88.0" + "# renovate: datasource=(?\\S+)( registryUrl=(?\\S+))? depName=(?\\S+)( versioning=(?\\S+))?\\s+[A-Za-z0-9_]+:\\s*\"(?[^\"\\s]+)\"" + ], + "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" } ] } \ No newline at end of file diff --git a/src/claude-code/smoke_test.sh b/src/claude-code/smoke_test.sh new file mode 100755 index 0000000..1d7b8a3 --- /dev/null +++ b/src/claude-code/smoke_test.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# Smoke test for the claude-code feature. +# +# Runs inside a started Dev Container that has this feature installed with its +# default options, as the container's remote user. See +# .github/workflows/smoke-test.yml for how it is invoked. + +set -euo pipefail + +failures=0 + +# Runs a command and records the outcome instead of aborting, so a single run +# reports every broken check rather than only the first one. +check() { + local label="$1" + shift + + if "$@" >/dev/null 2>&1; then + echo "ok - ${label}" + else + echo "FAIL - ${label}" + failures=$((failures + 1)) + fi +} + +check "claude is on PATH" command -v claude +check "claude reports a version" claude --version +check "config directory links to the mounted volume" \ + bash -c 'test "$(readlink "${HOME}/.claude")" = /var/lib/claude-code' +check "config directory is writable by the remote user" \ + bash -c 'touch "${HOME}/.claude/.write-test" && rm "${HOME}/.claude/.write-test"' +check "apt source is kept by default" test -f /etc/apt/sources.list.d/claude-code.list +check "signing key is kept by default" test -f /usr/share/keyrings/claude-code.asc + +if [ "${failures}" -ne 0 ]; then + echo "${failures} check(s) failed." + exit 1 +fi + +echo "All checks passed." diff --git a/test/claude-code/scenarios.json b/test/claude-code/scenarios.json deleted file mode 100644 index 81337e1..0000000 --- a/test/claude-code/scenarios.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "without_apt_source": { - "image": "mcr.microsoft.com/devcontainers/base:debian", - "remoteUser": "vscode", - "features": { - "claude-code": { - "keepAptSource": false - } - } - } -} diff --git a/test/claude-code/test.sh b/test/claude-code/test.sh deleted file mode 100755 index d295a05..0000000 --- a/test/claude-code/test.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# -# Default test for the claude-code feature: the CLI is on PATH and the remote -# user owns its configuration directory. - -set -e - -source dev-container-features-test-lib - -check "claude is on PATH" bash -c "command -v claude" -check "claude reports a version" bash -c "claude --version" -check "config directory links to the mounted volume" bash -c "test \"\$(readlink \"${HOME}/.claude\")\" = /var/lib/claude-code" -check "config directory is writable by the remote user" bash -c "touch \"${HOME}/.claude/.write-test\" && rm \"${HOME}/.claude/.write-test\"" -check "apt source is present" bash -c "test -f /etc/apt/sources.list.d/claude-code.list" - -reportResults diff --git a/test/claude-code/without_apt_source.sh b/test/claude-code/without_apt_source.sh deleted file mode 100755 index 7a7005c..0000000 --- a/test/claude-code/without_apt_source.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# -# Scenario test: with keepAptSource disabled, the CLI is still installed but the -# APT source and signing key are gone from the image. - -set -e - -source dev-container-features-test-lib - -check "claude is on PATH" bash -c "command -v claude" -check "apt source is removed" bash -c "! test -e /etc/apt/sources.list.d/claude-code.list" -check "signing key is removed" bash -c "! test -e /usr/share/keyrings/claude-code.asc" - -reportResults From d043b8c557dfc0c776bbababc7d6bcc704ce1c8a Mon Sep 17 00:00:00 2001 From: nozaq Date: Tue, 11 Aug 2026 07:58:33 +0000 Subject: [PATCH 3/5] feature(claude-code): report a missing remote user explicitly Under `set -euo pipefail` the `getent passwd` fallback and the `id -gn` lookup aborted install.sh with a bare non-zero status when the remote user did not exist, which also made the later `[ -n "${USER_HOME}" ]` guard unreachable. Resolve the account once inside an `if !` condition, where `set -e` is suspended, and fail with a diagnostic instead. The guard now covers its remaining real case: an account whose passwd entry has no home directory. Co-Authored-By: Claude Opus 5 (1M context) --- src/claude-code/install.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 6573bf3..4537f39 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -70,10 +70,22 @@ rm -rf /var/lib/apt/lists/* # configuration survives rebuilds. The volume target cannot depend on the remote # user's home directory (feature mounts are static), hence the indirection. USERNAME="${_REMOTE_USER:-root}" -USER_HOME="${_REMOTE_USER_HOME:-$(getent passwd "${USERNAME}" | cut -d: -f6)}" -USER_GROUP="$(id -gn "${USERNAME}")" CONFIG_DIR="/var/lib/claude-code" +# Both the ownership applied below and the home directory lookup need the +# account to exist. Resolve it once and report a missing account here, since +# under `set -e` a failing getent/id would otherwise abort the script with a +# bare non-zero status. +if ! passwd_entry="$(getent passwd "${USERNAME}")"; then + echo "(!) Remote user '${USERNAME}' was not found in the password database." >&2 + exit 1 +fi + +# The password database is only consulted as a fallback: the CLI-provided home +# directory wins when it is set. +USER_HOME="${_REMOTE_USER_HOME:-$(printf '%s' "${passwd_entry}" | cut -d: -f6)}" +USER_GROUP="$(id -gn "${USERNAME}")" + # A named volume is seeded from the image on first use, so creating the # directory with the right ownership here also makes the volume itself owned by # the remote user. From 0e0cd19a50cebaf9a67dab84dd1161d9d2f80372 Mon Sep 17 00:00:00 2001 From: nozaq Date: Tue, 11 Aug 2026 08:57:13 +0000 Subject: [PATCH 4/5] address review feedback --- .github/workflows/{smoke-test.yml => ci.yml} | 26 +++--- .github/workflows/release.yml | 91 ++++++++++++++++++++ .github/workflows/update-material.yml | 90 +++++++++++++++++++ AGENTS.md | 13 ++- renovate.jsonc | 2 +- scripts/update-material.sh | 40 +++++++++ src/claude-code/NOTES.md | 62 +++++++++++++ src/claude-code/README.md | 56 +++++++++--- src/claude-code/devcontainer-feature.json | 3 + src/claude-code/install.sh | 60 +++++-------- src/claude-code/smoke_test.sh | 34 +++++--- 11 files changed, 401 insertions(+), 76 deletions(-) rename .github/workflows/{smoke-test.yml => ci.yml} (83%) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/update-material.yml create mode 100755 scripts/update-material.sh create mode 100644 src/claude-code/NOTES.md diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/ci.yml similarity index 83% rename from .github/workflows/smoke-test.yml rename to .github/workflows/ci.yml index d35f9f1..4e19e68 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,4 @@ -name: Smoke Test - -# Starts a real Dev Container per feature and base image combination, with the -# feature added at its default options, and runs the feature's smoke_test.sh -# inside it as the remote user. +name: CI on: push: @@ -52,7 +48,7 @@ jobs: echo "Discovered features: ${features}" smoke-test: - name: ${{ matrix.feature }} on ${{ matrix.image }} + name: ${{ matrix.feature }} on ${{ matrix.image.label }} needs: discover runs-on: ubuntu-latest permissions: @@ -61,16 +57,14 @@ jobs: fail-fast: false matrix: feature: ${{ fromJSON(needs.discover.outputs.features) }} - # Deliberately floating tags: features have to keep working against the - # base images users actually pull today, not a digest frozen at merge - # time. Renovate therefore does not pin these. image: - - mcr.microsoft.com/devcontainers/base:debian - - ghcr.io/bare-devcontainer/debian:trixie + - ref: mcr.microsoft.com/devcontainers/base:debian + label: mcr/base:debian + - ref: ghcr.io/bare-devcontainer/debian:trixie + label: bare/debian:trixie env: FEATURE: ${{ matrix.feature }} - IMAGE: ${{ matrix.image }} - WORKSPACE: ${{ runner.temp }}/smoke + IMAGE: ${{ matrix.image.ref }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -80,6 +74,8 @@ jobs: run: npm install --global "@devcontainers/cli@${DEVCONTAINER_CLI_VERSION}" - name: Assemble the test workspace + env: + WORKSPACE: ${{ runner.temp }}/smoke run: | set -euo pipefail @@ -113,9 +109,13 @@ jobs: jq . "${WORKSPACE}/.devcontainer/devcontainer.json" - name: Start the Dev Container + env: + WORKSPACE: ${{ runner.temp }}/smoke run: devcontainer up --workspace-folder "${WORKSPACE}" --remove-existing-container - name: Run smoke_test.sh + env: + WORKSPACE: ${{ runner.temp }}/smoke run: | devcontainer exec --workspace-folder "${WORKSPACE}" \ bash "/workspaces/smoke/.devcontainer/${FEATURE}/smoke_test.sh" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c569191 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + pull_request: + paths: + - "src/**/devcontainer-feature.json" + - "src/**/NOTES.md" + - ".github/workflows/release.yml" + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && 'release' || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + release: + name: Publish features + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Publish features + uses: devcontainers/action@1082abd5d2bf3a11abccba70eef98df068277772 # v1.4.3 + with: + publish-features: "${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }}" + base-path-to-features: "./src" + generate-docs: "true" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate GitHub App Token + id: app-token + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.HOUSEKEEPER_CLIENT_ID }} + private-key: ${{ secrets.HOUSEKEEPER_PRIVATE_KEY }} + + - name: Create PR for Documentation + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + changed=$(git status --porcelain -- 'src/*/README.md' | awk '{print $2}') + if [ -z "$changed" ]; then + echo "No documentation changes." + exit 0 + fi + + OWNER="${GITHUB_REPOSITORY_OWNER}" + REPO="${GITHUB_REPOSITORY#*/}" + BRANCH="housekeeper/doc-update-${GITHUB_RUN_ID}" + MESSAGE="docs: update feature documentation" + + gh api "repos/${OWNER}/${REPO}/git/refs" \ + --method POST \ + -F ref="refs/heads/${BRANCH}" \ + -F sha="${GITHUB_SHA}" + + # The contents API commits as the app, so the checkout needs no + # credentials and the commits are signed by GitHub. + while IFS= read -r file; do + existing_sha=$(gh api "repos/${OWNER}/${REPO}/contents/${file}?ref=${BRANCH}" \ + --jq '.sha' 2>/dev/null || true) + args=( + --method PUT + -F message="${MESSAGE}" + -F content="$(base64 -w0 "$file")" + -F branch="${BRANCH}" + ) + [ -n "$existing_sha" ] && args+=(-F sha="$existing_sha") + gh api "repos/${OWNER}/${REPO}/contents/${file}" "${args[@]}" + done <<< "$changed" + + features=$(echo "$changed" | sed 's|src/\([^/]*\)/README\.md|\1|' | sort -u | sed 's/^/- /') + + gh pr create \ + --title "${MESSAGE}" \ + --body "$(printf '## Updated Features\n\n%s' "${features}")" \ + --head "${BRANCH}" \ + --base main diff --git a/.github/workflows/update-material.yml b/.github/workflows/update-material.yml new file mode 100644 index 0000000..d2c952d --- /dev/null +++ b/.github/workflows/update-material.yml @@ -0,0 +1,90 @@ +name: Update Trusted Material + +on: + pull_request: + paths: + - .github/workflows/update-material.yml + - scripts/update-material.sh + schedule: + - cron: "0 6 * * 3" + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + update: + name: Refresh trusted material + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Refresh trusted material + id: update + run: | + set -euo pipefail + + changed="$(bash scripts/update-material.sh)" + echo "changed=${changed}" >> "${GITHUB_OUTPUT}" + + - name: Generate GitHub App Token + id: app-token + if: steps.update.outputs.changed == 'true' && github.event_name != 'pull_request' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.HOUSEKEEPER_CLIENT_ID }} + private-key: ${{ secrets.HOUSEKEEPER_PRIVATE_KEY }} + + - name: Create PR for refreshed material + if: steps.update.outputs.changed == 'true' && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + OWNER="${GITHUB_REPOSITORY_OWNER}" + REPO="${GITHUB_REPOSITORY#*/}" + BRANCH="housekeeper/update-material" + MESSAGE="chore: update trusted material" + + # A single long-lived branch, so a run that finds the same update + # again amends the open pull request instead of opening another one. + if ! gh api "repos/${OWNER}/${REPO}/git/ref/heads/${BRANCH}" > /dev/null 2>&1; then + gh api "repos/${OWNER}/${REPO}/git/refs" \ + --method POST \ + -f ref="refs/heads/${BRANCH}" \ + -f sha="${GITHUB_SHA}" + fi + + # The contents API commits as the app, so the checkout needs no + # credentials and the commits are signed by GitHub. + while IFS= read -r file; do + existing_sha="$(gh api "repos/${OWNER}/${REPO}/contents/${file}?ref=${BRANCH}" \ + --jq '.sha' 2>/dev/null || true)" + args=( + --method PUT + -F message="${MESSAGE}" + -F content="$(base64 -w0 "${file}")" + -F branch="${BRANCH}" + ) + if [ -n "${existing_sha}" ]; then + args+=(-F sha="${existing_sha}") + fi + gh api "repos/${OWNER}/${REPO}/contents/${file}" "${args[@]}" + done < <(git diff --name-only) + + if [ -z "$(gh pr list --state open --head "${BRANCH}" --json number --jq '.[0]')" ]; then + gh pr create \ + --title "${MESSAGE}" \ + --body "Automated sync of trusted material." \ + --head "${BRANCH}" \ + --base main + fi diff --git a/AGENTS.md b/AGENTS.md index 2c00fe1..8cfecaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,12 +13,19 @@ This repository publishes dev container features for use with the [Dev Container - `ci: pin action SHAs` - `chore: update renovate config` +## Documentation + +- A feature's `README.md` is generated: `devcontainer features generate-docs` + deletes it and writes it back from `devcontainer-feature.json` plus the + feature's `NOTES.md`. Never edit `README.md` by hand — put prose in + `NOTES.md`, which is inserted after the options table. + ## Testing - Every feature under `src/` ships a `smoke_test.sh` next to its - `devcontainer-feature.json`. The `Smoke Test` workflow starts a Dev Container - per feature and base image, with the feature installed at its default - options, and runs that script inside it as the remote user. + `devcontainer-feature.json`. The `CI` workflow's `smoke-test` job starts a Dev + Container per feature and base image, with the feature installed at its + default options, and runs that script inside it as the remote user. - Smoke tests are self-contained: they run in a plain container without the `devcontainer features test` helper library, so they must not source `dev-container-features-test-lib`, and they exit non-zero when a check fails. diff --git a/renovate.jsonc b/renovate.jsonc index f34a5ee..d854c0c 100644 --- a/renovate.jsonc +++ b/renovate.jsonc @@ -59,7 +59,7 @@ // A `# renovate:` comment bound to the quoted value of the YAML // key on the following line, // e.g. DEVCONTAINER_CLI_VERSION: "0.88.0" - "# renovate: datasource=(?\\S+)( registryUrl=(?\\S+))? depName=(?\\S+)( versioning=(?\\S+))?\\s+[A-Za-z0-9_]+:\\s*\"(?[^\"\\s]+)\"" + "# renovate: datasource=(?\\S+)( registryUrl=(?\\S+))? depName=(?\\S+)( versioning=(?\\S+))?( extractVersion=(?\\S+))?\\s+[A-Za-z0-9_]+:\\s*\"(?[^\"\\s]+)\"" ], "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" } diff --git a/scripts/update-material.sh b/scripts/update-material.sh new file mode 100755 index 0000000..993d219 --- /dev/null +++ b/scripts/update-material.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# update-material.sh — refresh the trust material vendored in this repository, +# writing changed files in place in the working tree. Prints "true" or "false" +# to stdout depending on whether anything changed; per-file download and +# comparison progress goes to stderr. Performs no git or GitHub operations. +# +# Usage: +# update-material.sh +set -euo pipefail + +# path: the file tracked in this repository, url: where upstream publishes it. +MATERIALS='[ + { + "path": "src/claude-code/claude-code.asc", + "url": "https://downloads.claude.ai/keys/claude-code.asc" + } +]' + +changed=false +while IFS=$'\t' read -r path url; do + echo "Downloading ${url} -> ${path}" >&2 + + # Downloaded to a temporary file first so a failed request cannot leave a + # truncated key behind in the working tree. + tmp=$(mktemp) + wget -q -T 30 -t 3 -O "$tmp" "$url" + + if cmp -s "$tmp" "$path"; then + echo " unchanged" >&2 + else + echo " changed" >&2 + changed=true + fi + + chmod 644 "$tmp" + mv "$tmp" "$path" +done < <(jq -r '.[] | [.path, .url] | @tsv' <<< "$MATERIALS") + +echo "$changed" diff --git a/src/claude-code/NOTES.md b/src/claude-code/NOTES.md new file mode 100644 index 0000000..9bd0f6e --- /dev/null +++ b/src/claude-code/NOTES.md @@ -0,0 +1,62 @@ +## Details + +- Requires a Debian or Ubuntu based image. +- The repository signing key is vendored with the feature and installed to + `/usr/share/keyrings/claude-code.asc`, so no key is downloaded at build time. +- The APT source is written to `/etc/apt/sources.list.d/claude-code.list`. Set + `keepAptSource` to `false` to drop the source and key once installation + finishes. + +### Persisting configuration and credentials + +Claude Code keeps its credentials, settings and history in its configuration +directory. The feature declares a volume mount so those survive container +rebuilds, and points Claude Code at it: + +```json +"containerEnv": { + "CLAUDE_CONFIG_DIR": "/var/lib/claude-code" +}, +"mounts": [ + { + "source": "${devcontainerId}-claude-code-config", + "target": "/var/lib/claude-code", + "type": "volume" + } +] +``` + +A feature's mount target is static and cannot reference the remote user's home +directory, so the volume is mounted at `/var/lib/claude-code` and +`CLAUDE_CONFIG_DIR` moves the configuration directory there. That keeps the +mount working whatever the image uses as its remote user, and covers +`.claude.json` (onboarding state, the logged-in account, project history and +user-scoped MCP servers) as well as `~/.claude` would have. + +Anything already present in `~/.claude` when the feature runs — from the base +image or an earlier feature — is copied into `/var/lib/claude-code`, but only +when the volume has not been seeded yet. The original is left in place, since a +pre-existing volume masks the copy at run time. + +`containerEnv` from `devcontainer.json` takes precedence over a feature's, so +setting `CLAUDE_CONFIG_DIR` there points Claude Code somewhere else and leaves +the volume unused. + +The named volume is seeded from the image on first use, ownership included. The +Dev Containers CLI renumbers the remote user to the host user's UID and GID on +Linux, but only chowns the home directory, so a directory owned by a build-time +UID would end up unreachable. The install script therefore creates a +`claude-code` system group, adds the remote user to it, and makes +`/var/lib/claude-code` group-writable and setgid: group membership is recorded by +name and is unaffected by the renumbering. + +One case is not covered. If an existing volume is reused after the host user's +UID changed, files written under the old UID keep their owner-only modes and +Claude Code cannot rewrite them; clear them with `rm -rf /var/lib/claude-code/*` +(this drops stored credentials and history) or remove the volume. Files created +afterwards are unaffected. + +The volume is per dev container (`${devcontainerId}`) and is not shared between +projects. Mounts declared by a feature cannot be disabled from +`devcontainer.json`, but overriding `CLAUDE_CONFIG_DIR` as shown above leaves the +volume mounted and unused. diff --git a/src/claude-code/README.md b/src/claude-code/README.md index 400d44d..76ebb61 100644 --- a/src/claude-code/README.md +++ b/src/claude-code/README.md @@ -1,3 +1,4 @@ + # Claude Code (claude-code) Installs Claude Code, Anthropic's agentic coding CLI, from the official APT repository. @@ -14,7 +15,7 @@ Installs Claude Code, Anthropic's agentic coding CLI, from the official APT repo | Options Id | Description | Type | Default Value | |-----|-----|-----|-----| -| version | APT package version of claude-code to install. Use `latest` for the newest available version, or an exact version string as reported by `apt-cache policy claude-code`. | string | latest | +| version | APT package version of claude-code to install. Use "latest" for the newest available version, or an exact version string as reported by `apt-cache policy claude-code`. | string | latest | | keepAptSource | Keep the Claude Code APT source and signing key in the image so the package can be upgraded later. When false, both are removed after installation. | boolean | true | ## Details @@ -25,12 +26,17 @@ Installs Claude Code, Anthropic's agentic coding CLI, from the official APT repo - The APT source is written to `/etc/apt/sources.list.d/claude-code.list`. Set `keepAptSource` to `false` to drop the source and key once installation finishes. + ### Persisting configuration and credentials -Claude Code keeps its credentials, settings and history under `~/.claude`. The -feature declares a volume mount so those survive container rebuilds: +Claude Code keeps its credentials, settings and history in its configuration +directory. The feature declares a volume mount so those survive container +rebuilds, and points Claude Code at it: ```json +"containerEnv": { + "CLAUDE_CONFIG_DIR": "/var/lib/claude-code" +}, "mounts": [ { "source": "${devcontainerId}-claude-code-config", @@ -41,13 +47,41 @@ feature declares a volume mount so those survive container rebuilds: ``` A feature's mount target is static and cannot reference the remote user's home -directory, so the volume is mounted at `/var/lib/claude-code` and the install -script links `~/.claude` to it. That keeps the mount working whatever the image -uses as its remote user. The directory is created and owned by the remote user -at build time, which is also the ownership the named volume is seeded with on -first use. +directory, so the volume is mounted at `/var/lib/claude-code` and +`CLAUDE_CONFIG_DIR` moves the configuration directory there. That keeps the +mount working whatever the image uses as its remote user, and covers +`.claude.json` (onboarding state, the logged-in account, project history and +user-scoped MCP servers) as well as `~/.claude` would have. + +Anything already present in `~/.claude` when the feature runs — from the base +image or an earlier feature — is copied into `/var/lib/claude-code`, but only +when the volume has not been seeded yet. The original is left in place, since a +pre-existing volume masks the copy at run time. + +`containerEnv` from `devcontainer.json` takes precedence over a feature's, so +setting `CLAUDE_CONFIG_DIR` there points Claude Code somewhere else and leaves +the volume unused. + +The named volume is seeded from the image on first use, ownership included. The +Dev Containers CLI renumbers the remote user to the host user's UID and GID on +Linux, but only chowns the home directory, so a directory owned by a build-time +UID would end up unreachable. The install script therefore creates a +`claude-code` system group, adds the remote user to it, and makes +`/var/lib/claude-code` group-writable and setgid: group membership is recorded by +name and is unaffected by the renumbering. + +One case is not covered. If an existing volume is reused after the host user's +UID changed, files written under the old UID keep their owner-only modes and +Claude Code cannot rewrite them; clear them with `rm -rf /var/lib/claude-code/*` +(this drops stored credentials and history) or remove the volume. Files created +afterwards are unaffected. The volume is per dev container (`${devcontainerId}`) and is not shared between -projects. Mounts declared by a feature cannot be disabled from `devcontainer.json`; -if you need different storage, point `~/.claude` elsewhere via the -`CLAUDE_CONFIG_DIR` environment variable. +projects. Mounts declared by a feature cannot be disabled from +`devcontainer.json`, but overriding `CLAUDE_CONFIG_DIR` as shown above leaves the +volume mounted and unused. + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/bare-devcontainer/features/blob/main/src/claude-code/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json index b4e35be..b7fa042 100644 --- a/src/claude-code/devcontainer-feature.json +++ b/src/claude-code/devcontainer-feature.json @@ -19,6 +19,9 @@ "description": "Keep the Claude Code APT source and signing key in the image so the package can be upgraded later. When false, both are removed after installation." } }, + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/var/lib/claude-code" + }, "mounts": [ { "source": "${devcontainerId}-claude-code-config", diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 4537f39..e9b900b 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Installs the Claude Code CLI from Anthropic's official APT repository and -# prepares the remote user's configuration directory (~/.claude). +# prepares the configuration directory the feature points CLAUDE_CONFIG_DIR at. # # Expected environment variables, from this feature's own options: # @@ -37,21 +37,18 @@ fi export DEBIAN_FRONTEND=noninteractive -# The APT repository is served over HTTPS, which fails on minimal images that -# ship without a CA bundle. +# Ensure the system has a CA bundle for HTTPS access to the APT repository. if [ ! -e /etc/ssl/certs/ca-certificates.crt ]; then apt-get update -y apt-get install -y --no-install-recommends ca-certificates fi -# The signing key is vendored with the feature so installation does not depend -# on fetching a key over the network at build time. -install -m 0644 "${FEATURE_DIR}/claude-code.asc" "${APT_KEYRING}" +# Add the official Claude Code apt repository. +install -D -m 0644 "${FEATURE_DIR}/claude-code.asc" "${APT_KEYRING}" echo "deb [signed-by=${APT_KEYRING}] ${APT_REPOSITORY}" > "${APT_SOURCE}" - -# Index the source added above. apt-get update -y +# Install the requested version of the package, or the latest if none was specified. if [ "${VERSION}" = "latest" ] || [ -z "${VERSION}" ]; then package="claude-code" else @@ -65,44 +62,33 @@ fi rm -rf /var/lib/apt/lists/* -# Claude Code stores credentials, settings and history under ~/.claude. The -# feature mounts a volume at CONFIG_DIR and ~/.claude is linked to it, so the -# configuration survives rebuilds. The volume target cannot depend on the remote -# user's home directory (feature mounts are static), hence the indirection. USERNAME="${_REMOTE_USER:-root}" CONFIG_DIR="/var/lib/claude-code" +CONFIG_GROUP="claude-code" -# Both the ownership applied below and the home directory lookup need the -# account to exist. Resolve it once and report a missing account here, since -# under `set -e` a failing getent/id would otherwise abort the script with a -# bare non-zero status. if ! passwd_entry="$(getent passwd "${USERNAME}")"; then echo "(!) Remote user '${USERNAME}' was not found in the password database." >&2 exit 1 fi -# The password database is only consulted as a fallback: the CLI-provided home -# directory wins when it is set. -USER_HOME="${_REMOTE_USER_HOME:-$(printf '%s' "${passwd_entry}" | cut -d: -f6)}" -USER_GROUP="$(id -gn "${USERNAME}")" - -# A named volume is seeded from the image on first use, so creating the -# directory with the right ownership here also makes the volume itself owned by -# the remote user. +# Create a dedicated group for the configuration directory, and add the remote user to it. +# This allows the remote user to access the configuration directory even if the remote user's UID is renumbered by `updateRemoteUserUID` option. mkdir -p "${CONFIG_DIR}" -chown "${USERNAME}:${USER_GROUP}" "${CONFIG_DIR}" - -if [ -n "${USER_HOME}" ]; then - config_link="${USER_HOME}/.claude" - if [ -d "${config_link}" ] && [ ! -L "${config_link}" ]; then - # Preserve whatever the base image or an earlier feature put there. - cp -a "${config_link}/." "${CONFIG_DIR}/" - rm -rf "${config_link}" - else - rm -f "${config_link}" - fi - ln -s "${CONFIG_DIR}" "${config_link}" - chown -h "${USERNAME}:${USER_GROUP}" "${config_link}" +if ! getent group "${CONFIG_GROUP}" >/dev/null; then + groupadd --system "${CONFIG_GROUP}" fi +usermod -aG "${CONFIG_GROUP}" "${USERNAME}" + +# Transfer any existing configuration from the remote user's home directory to the mounted volume if it exists. +USER_HOME="${_REMOTE_USER_HOME:-$(printf '%s' "${passwd_entry}" | cut -d: -f6)}" +if [ -n "${USER_HOME}" ] && [ -d "${USER_HOME}/.claude" ] && [ -z "$(ls -A "${CONFIG_DIR}")" ]; then + cp -a "${USER_HOME}/.claude/." "${CONFIG_DIR}/" +fi + +# Set the ownership and permissions of the configuration directory. +# The setgid bit keeps entries created later in the shared group. +chown -R "${USERNAME}:${CONFIG_GROUP}" "${CONFIG_DIR}" +chmod -R g+rwX "${CONFIG_DIR}" +chmod 2775 "${CONFIG_DIR}" echo "Installed $(claude --version 2>/dev/null || echo 'claude-code')." diff --git a/src/claude-code/smoke_test.sh b/src/claude-code/smoke_test.sh index 1d7b8a3..c9c4389 100755 --- a/src/claude-code/smoke_test.sh +++ b/src/claude-code/smoke_test.sh @@ -1,10 +1,4 @@ #!/usr/bin/env bash -# -# Smoke test for the claude-code feature. -# -# Runs inside a started Dev Container that has this feature installed with its -# default options, as the container's remote user. See -# .github/workflows/smoke-test.yml for how it is invoked. set -euo pipefail @@ -16,20 +10,38 @@ check() { local label="$1" shift - if "$@" >/dev/null 2>&1; then + local output + local status=0 + # The `||` keeps the assignment out of `set -e`'s reach, which would + # otherwise abort the whole run on the first failing check. + output="$("$@" 2>&1)" || status=$? + + if [ "${status}" -eq 0 ]; then echo "ok - ${label}" else - echo "FAIL - ${label}" + echo "FAIL - ${label} (exit ${status})" + if [ -n "${output}" ]; then + echo "${output}" | sed 's/^/ /' + fi failures=$((failures + 1)) fi } check "claude is on PATH" command -v claude check "claude reports a version" claude --version -check "config directory links to the mounted volume" \ - bash -c 'test "$(readlink "${HOME}/.claude")" = /var/lib/claude-code' +check "CLAUDE_CONFIG_DIR points at the mounted volume" \ + bash -c 'test "${CLAUDE_CONFIG_DIR:-}" = /var/lib/claude-code' +check "remote user belongs to the config group" \ + bash -c 'id -nG | tr " " "\n" | grep -qx claude-code' +check "config directory is group-writable and setgid" \ + bash -c 'test "$(stat -c "%A %G" /var/lib/claude-code)" = "drwxrwsr-x claude-code"' check "config directory is writable by the remote user" \ - bash -c 'touch "${HOME}/.claude/.write-test" && rm "${HOME}/.claude/.write-test"' + bash -c 'touch /var/lib/claude-code/.write-test && rm /var/lib/claude-code/.write-test' +check "files created there are owned by the remote user" \ + bash -c 'touch /var/lib/claude-code/.owner-test + owner="$(stat -c %u /var/lib/claude-code/.owner-test)" + rm /var/lib/claude-code/.owner-test + test "${owner}" = "$(id -u)"' check "apt source is kept by default" test -f /etc/apt/sources.list.d/claude-code.list check "signing key is kept by default" test -f /usr/share/keyrings/claude-code.asc From 60e83612e16e9b56bf56c0d40ddad2905ed6a853 Mon Sep 17 00:00:00 2001 From: nozaq Date: Tue, 11 Aug 2026 13:39:02 +0000 Subject: [PATCH 5/5] add vscode extension --- src/claude-code/NOTES.md | 23 +++++++++++++++++++++++ src/claude-code/devcontainer-feature.json | 11 +++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/claude-code/NOTES.md b/src/claude-code/NOTES.md index 9bd0f6e..558a343 100644 --- a/src/claude-code/NOTES.md +++ b/src/claude-code/NOTES.md @@ -7,6 +7,29 @@ `keepAptSource` to `false` to drop the source and key once installation finishes. +### VS Code extension + +The feature also requests the Claude Code VS Code extension, so an editor that +attaches to the container gets the sidebar, inline diffs and the IDE +integration alongside the CLI: + +```json +"customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code" + ] + } +} +``` + +Extension installation is done by the attaching client, not by the install +script, so this applies to VS Code, Cursor and GitHub Codespaces, and is +ignored by clients that do not consume `customizations.vscode` (the +`devcontainer` CLI, or attaching from another editor). The CLI is installed +either way. Nothing in `devcontainer.json` can remove an extension a feature +asks for, so uninstall it in the container if it is not wanted. + ### Persisting configuration and credentials Claude Code keeps its credentials, settings and history in its configuration diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json index b7fa042..82836c5 100644 --- a/src/claude-code/devcontainer-feature.json +++ b/src/claude-code/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "claude-code", - "version": "1.0.0", + "version": "1.1.0", "name": "Claude Code", "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/claude-code", "description": "Installs Claude Code, Anthropic's agentic coding CLI, from the official APT repository.", @@ -31,5 +31,12 @@ ], "installsAfter": [ "ghcr.io/devcontainers/features/common-utils" - ] + ], + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code" + ] + } + } }