From f8852f70757ea062b0a58f44ad7378b6a24d95b6 Mon Sep 17 00:00:00 2001 From: Kai Schwarz Date: Wed, 9 Sep 2026 08:46:01 +0200 Subject: [PATCH 1/2] feat(devbase): stop installing the Claude Code CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devbase declared ghcr.io/anthropics/devcontainer-features/claude-code:1 in dependsOn, so every consuming container installed the Claude Code CLI globally via npm. That copy was redundant, and it was broken in a way that needed a manual fix. Redundant, because the VS Code extension ships and runs its own runtime. Verified by process list in a running container rather than inferred: /home/vscode/.vscode-server/extensions/anthropic.claude-code-2.1.263-linux-x64/ resources/native-binary/claude --output-format stream... The extension never invokes the npm global. It is what we actually use, so the npm install was a second copy of the same thing. Broken, because the claude-code feature installs as root into a global npm tree that the node feature makes vscode:nvm. The package directory therefore lands root-owned at mode 755 — no group write — and `claude update` fails for the remote user until someone runs, by hand: sudo chown -R vscode:nvm "$(npm root -g)/@anthropic-ai" Fixing the ownership in install.sh was the alternative, and it would have shipped as a patch and so reached consumers immediately. Rejected: it keeps a redundant second runtime in every image purely to make it self-updating. Removing it is the smaller end state, and this Feature has exactly one consumer group, so a major costs less here than it would for a public Feature. Checked before removing: no repository's scripts, workflows or package manifests call the claude CLI — zero hits across the fleet. Three things deliberately stay. RTK stays. Its PreToolUse hook lives in the bind-mounted, host-shared ~/.claude/settings.json and fires under the extension's runtime exactly as it did under the CLI's — confirmed by RTK-filtered command output in a container running only the extension. Without the binary that hook still exits 127 on every Bash call, which is the whole reason RTK is in this Feature. node stays in dependsOn. One of its two documented reasons retires here — claude-code installed EOL Node 18 from nodesource when it found no Node — but the other stands unchanged: devbase's own pnpm, commitizen and npm-floor steps need npm. The anthropic.claude-code and anthropic.claude-vscode VS Code extensions stay. They are now the only Claude Code in a container. Tests move with the behaviour. The "dependsOn installed the claude CLI" check inverts to assert the CLI is absent: a removal with no assertion cannot distinguish "removed" from "quietly came back through some other feature's own dependsOn", which is exactly what dependsOn does. node_pinned's nodesource guard is dropped rather than kept — its subject is gone, so it could no longer fail, and a check that cannot fail is worse than no check. BREAKING CHANGE: devbase no longer installs the Claude Code CLI. A container built from 2.x has no `claude` on PATH unless the repository lists ghcr.io/anthropics/devcontainer-features/claude-code:1 itself. The Claude Code VS Code extension is unaffected — it runs its own bundled runtime. Consumers pin devbase:1, so this reaches nobody until they change that reference to devbase:2 and re-resolve any committed devcontainer-lock.json; see "Migrating to 2.0.0" in README.md. Refs RSRMID-3053. --- CLAUDE.md | 22 +++-- README.md | 82 ++++++++++++++++--- .../src/devbase/devcontainer-feature.json | 1 - features/test/devbase/node_pinned.sh | 12 +-- features/test/devbase/test.sh | 23 ++++-- 5 files changed, 105 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 496cb17..73a0819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,13 +96,17 @@ environment. sequences a feature the consumer already listed and does nothing when they did not, so it suits the language runtimes — devbase must not install those, only run after them. A new language runtime that post-create depends on goes there. `dependsOn` actually pulls - the feature in, and carries `github-cli` (the credential helper is useless without - `gh`) and `claude-code`. Adding to `dependsOn` puts software in every consumer's image - with no option to decline, so it is a `feat(devbase)` and needs a test asserting the - binary — never list the same feature in both. Check what a dependency drags in before - adding one: `claude-code` installs its own Node when it finds none, and that fallback is - **EOL 18 from nodesource** — which is why `node` is a dependency too, and why removing it - would silently put every repository that does not list its own on a dead runtime. + the feature in, and carries `github-cli` (the credential helper is useless without `gh`) + and `node` (devbase's own pnpm, commitizen and npm-floor steps need npm). Adding to + `dependsOn` puts software in every consumer's image with no option to decline, so it is a + `feat(devbase)` and needs a test asserting the binary — never list the same feature in + both. **Removing** one is a major, and needs a test asserting the binary is _absent_, or + nothing distinguishes a removal from one that quietly came back through another feature's + own `dependsOn`. Check what a dependency drags in before adding one, and what its removal + takes with it: `claude-code` was dropped in 2.0.0 (RSRMID-3053) because the VS Code + extension runs its own bundled runtime, and that also retired one of the two reasons + `node` is here — `claude-code` installed **EOL Node 18 from nodesource** when it found no + Node. The other reason stands, so `node` stayed. - **`node` is in `dependsOn` at `lts`, and that makes devbase the owner of the Node version.** It rests on one CLI behaviour: two instances of a feature with _identical_ options deduplicate, differing ones do not — both install and the dependency-expanded @@ -345,8 +349,8 @@ Opus decides, Sonnet implements. Definitions live in `.claude/agents/`. ## Do NOT - Add a language runtime to `devbase` — runtimes come from the devcontainers language - features, and this Feature installs none directly (a Node does arrive transitively via - the `claude-code` dependency; that is a known side effect, not licence to add more) + features, and `node` is the sole exception, declared in `dependsOn` because devbase's own + npm-dependent steps need it. That is one deliberate exception, not licence to add more - Hand-edit `version` in `devcontainer-feature.json` — semantic-release owns it - File a behaviour change under a non-releasing commit type, which silently ships nothing - Repoint this repository's devcontainer **away from** the published coordinate — the diff --git a/README.md b/README.md index 00c5393..fde7815 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,12 @@ Add one entry to your repository's `.devcontainer/devcontainer.json`: } ``` -Only the second line is strictly required. `devbase` declares `node` (at `lts`), -`github-cli` and `claude-code` in `dependsOn`, so it installs all three itself, before -itself. The `node` entry above is kept because it deduplicates with `devbase`'s own — same -feature, same options, one install — and it documents the runtime at the point people look -for it. Other runtimes (php, go, python, java) are genuinely yours: `devbase` installs -none of those and only orders itself after them. +Only the second line is strictly required. `devbase` declares `node` (at `lts`) and +`github-cli` in `dependsOn`, so it installs both itself, before itself. The `node` entry +above is kept because it deduplicates with `devbase`'s own — same feature, same options, +one install — and it documents the runtime at the point people look for it. Other runtimes +(php, go, python, java) are genuinely yours: `devbase` installs none of those and only +orders itself after them. Rebuild the container. That is the whole integration for a repository whose defaults are fine. @@ -45,6 +45,7 @@ instead — the frames come with this entry already in place. - [Migrating a repository that already has a devcontainer](#migrating-a-repository-that-already-has-a-devcontainer) - [Developing a Feature](#developing-a-feature) - [Publishing](#publishing) +- [Migrating to 2.0.0](#migrating-to-200) - [Keeping consumers up to date](#keeping-consumers-up-to-date) - [Troubleshooting](#troubleshooting) @@ -55,14 +56,26 @@ language runtime of its own** — php, go, python and java stay in each reposito list, and `devbase` declares `installsAfter` for them so its setup steps run once those runtimes exist. Node is the exception, and a deliberate one: see below. -Three features it does _not_ leave to the consumer, declared in `dependsOn` rather than +Two features it does _not_ leave to the consumer, declared in `dependsOn` rather than `installsAfter`: **`github-cli`**, because the `gh` credential helper below is useless -without `gh`; **`claude-code`**, because it is on every one of our machines anyway; and -**`node` at `lts`**, because `devbase`'s own pnpm and commitizen steps need npm, and -because `claude-code` installs **Node 18 from nodesource** — EOL since April 2025 — when it -cannot find a Node of its own. `installsAfter` is only a hint: it orders a feature the -consumer already listed and does nothing when they did not, so it could never have carried -these three. +without `gh`; and **`node` at `lts`**, because `devbase`'s own pnpm, commitizen and +npm-floor steps need npm. `installsAfter` is only a hint: it orders a feature the consumer +already listed and does nothing when they did not, so it could never have carried either. + +> **`claude-code` was a third, until 2.0.0 removed it.** It was there "because it is on +> every one of our machines anyway", and that turned out to be the whole of the reason — +> nothing in this Feature, and nothing in any consuming repository's scripts or workflows, +> ever called the `claude` CLI. Meanwhile the VS Code extension ships and runs its own +> runtime (`resources/native-binary/claude`, inside the extension directory), so the +> npm-installed global was a second copy of the same thing — and, being installed as root +> into a global npm tree the node feature makes user-owned, a copy that could not update +> itself: `claude update` failed until someone chowned it by hand. See +> [Migrating to 2.0.0](#migrating-to-200). +> +> It also used to be half the reason `node` is a dependency: `claude-code` installs +> **Node 18 from nodesource** — EOL since April 2025 — when it cannot find a Node of its +> own. That reason retires with it. The other half stands unchanged, which is why `node` +> stays. > **On the Node pin.** Keep `"ghcr.io/devcontainers/features/node:2": { "version": "lts" }` > in your `devcontainer.json` if it is already there — identical options deduplicate, so it @@ -512,6 +525,49 @@ private, and a private Feature fails every consumer's build with a `401`. Set it this repository's _Packages_ → the `devbase` package → _Package settings_ → _Change visibility_. +## Migrating to 2.0.0 + +**What changed:** `devbase` no longer declares +`ghcr.io/anthropics/devcontainer-features/claude-code:1` in `dependsOn`, so a container +built from `2.x` has no `claude` on `PATH` unless the repository asks for it. (RSRMID-3053) + +**Why:** the Claude Code VS Code extension ships and runs its own runtime, at +`resources/native-binary/claude` inside the extension directory — verified by process +list in a running container, not inferred. The npm-installed global was therefore a second +copy of the same thing, and one that could not update itself: the `claude-code` feature +installs as root into a global npm tree the node feature makes `vscode:nvm`, so the package +directory lands root-owned at mode 755 and `claude update` fails for the remote user until +someone runs `sudo chown -R vscode:nvm "$(npm root -g)/@anthropic-ai"` by hand. + +**What you have to do.** Because consumers pin `devbase:1`, `2.0.0` reaches nobody on a +rebuild — the upgrade is deliberate, in two steps: + +1. Change the reference in `.devcontainer/devcontainer.json` from `devbase:1` to + `devbase:2`. +2. If the repository commits a `devcontainer-lock.json`, re-resolve it. Neither + `devcontainer upgrade` nor Dependabot will do this for you — see + [Keeping consumers up to date](#keeping-consumers-up-to-date) for why, and for the + commands. + +**What you do _not_ lose.** `gh`, `node` at `lts`, `pnpm` and the RTK binary are all +unchanged, as are the `anthropic.claude-code` and `anthropic.claude-vscode` VS Code +extensions — which are now the only Claude Code in the container. RTK in particular still +matters: its `PreToolUse` hook lives in the bind-mounted, host-shared +`~/.claude/settings.json` and fires under the extension's runtime exactly as it did under +the CLI's, so a container without the binary still exits 127 on every Bash call. + +**If you actually want the CLI in a container**, list the feature yourself: + +```jsonc +"features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1": {}, + "ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:2": {} +} +``` + +That is a deliberate, per-repository choice rather than something every image carries. Note +it brings the ownership problem above back with it. + ## Keeping consumers up to date **A consumer with no `devcontainer-lock.json` needs nothing.** Every rebuild re-resolves diff --git a/features/src/devbase/devcontainer-feature.json b/features/src/devbase/devcontainer-feature.json index 25682d7..f6adbaa 100644 --- a/features/src/devbase/devcontainer-feature.json +++ b/features/src/devbase/devcontainer-feature.json @@ -104,7 +104,6 @@ "ghcr.io/devcontainers/features/node:2": { "version": "lts" }, - "ghcr.io/anthropics/devcontainer-features/claude-code:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, "installsAfter": [ diff --git a/features/test/devbase/node_pinned.sh b/features/test/devbase/node_pinned.sh index 3963244..493d83d 100755 --- a/features/test/devbase/node_pinned.sh +++ b/features/test/devbase/node_pinned.sh @@ -29,11 +29,13 @@ check "devbase's lts wins over a repository's own Node pin" bash -lc ' node -v ! node -v | grep -q "^v22\."' -# claude-code installs its own Node only when it cannot find one, and that fallback is -# Node 18 from nodesource — EOL. With node in dependsOn it can never run, and this is what -# would catch it starting to: the apt source exists if and only if that installer ran. -check "claude-code added no Node of its own" bash -c \ - '! test -e /etc/apt/sources.list.d/nodesource.list' +# The nodesource guard that used to live here is gone with its subject. It existed because +# claude-code installed Node 18 from nodesource — EOL — when it could not find a Node, and +# it caught that installer starting to run. 2.0.0 dropped claude-code from dependsOn, so +# nothing in the Feature can reach nodesource any more and the check could no longer fail. +# A check that cannot fail is worse than no check: it reports SUCCESS and sends the next +# reader somewhere else. `node` stays in dependsOn on its own merits — devbase's pnpm, +# commitizen and npm-floor steps need npm — which the check above is what actually proves. # devbase's npm-dependent steps are the reason the dependency exists, so assert they # completed rather than reporting a missing toolchain. diff --git a/features/test/devbase/test.sh b/features/test/devbase/test.sh index 7420bae..615af4e 100644 --- a/features/test/devbase/test.sh +++ b/features/test/devbase/test.sh @@ -252,14 +252,23 @@ check "banner reports the OS" bash -c 'cd /tmp && devbase-env-info | grep -qi "u check "config.env parses as shell" sh -n /usr/local/share/devbase/config.env # --- dependsOn ----------------------------------------------------------------- -# Neither of these is listed by this test's feature set, so their presence is entirely -# the manifest's `dependsOn` doing its job. Asserting the binaries rather than the -# manifest is the point: `installsAfter` would parse identically and install neither, -# and the resulting container looks fine until the credential helper needs `gh`. +# gh is not listed by this test's feature set, so its presence is entirely the manifest's +# `dependsOn` doing its job. Asserting the binary rather than the manifest is the point: +# `installsAfter` would parse identically and install nothing, and the resulting container +# looks fine until the credential helper needs `gh`. check "dependsOn installed the gh CLI" command -v gh -# -l so a PATH addition made in the login profile is picked up the way a real shell -# would pick it up. -check "dependsOn installed the claude CLI" bash -lc 'command -v claude' + +# The mirror image, and the reason it is asserted rather than simply dropped: devbase used +# to pull the Claude Code CLI in through `dependsOn`, and 2.0.0 stopped. The VS Code +# extension ships and runs its own runtime — `resources/native-binary/claude` inside the +# extension directory — so the npm-installed global CLI was a second, redundant copy, and +# being installed as root inside a user-owned global npm tree it could not even update +# itself (`claude update` failed until someone chowned it by hand). +# +# A removal with no assertion is indistinguishable from a removal that quietly came back +# through a transitive dependency, which is exactly what `dependsOn` does. -l so a PATH +# addition made in the login profile is picked up the way a real shell would pick it up. +check "devbase installs no Claude CLI of its own" bash -lc '! command -v claude' # --- setup.sh steps, asserted as effects ------------------------------------- # These steps are what the Feature is for, and until now only the *recording* of their From 56d3bc9684fc581fc7ad45cfd4b62b79985c3999 Mon Sep 17 00:00:00 2001 From: Kai Schwarz Date: Wed, 9 Sep 2026 08:46:16 +0200 Subject: [PATCH 2/2] ci(devcontainer): correct what dependsOn provides in both frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both frames said node, github-cli and claude-code all come from devbase's dependsOn. The preceding commit removed claude-code, so that is now wrong in three places. The default frame keeps referencing devbase:1, per the dogfooding rule — the frame only stops expecting something after the release that stops providing it — so its comment says so explicitly: the CLI is still there until that reference moves to :2, and deliberately gone afterwards. The local frame has no published :1 to fall back on: it builds the working-tree copy, so the removal takes effect there immediately. Noted, because that asymmetry is the whole point of having the alternate config and is the first place anyone will notice `claude` missing. Also corrects the ~/.claude mkdir rationale, which named claude-code as the thing that could not write its config into a root-owned directory. The directory is still needed for exactly that reason — the VS Code extension writes there, and the RTK hook is read from the settings.json inside it. Refs RSRMID-3053. --- .devcontainer/devcontainer.json | 20 ++++++++++++++------ .devcontainer/local/devcontainer.json | 9 ++++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b53d5ab..688c88b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,10 +26,12 @@ // looks right — a symptom that points at git rather than at this line. // // ~/.claude is a directory either way, so its `mkdir` is about ownership rather than - // shape: the one Docker creates is owned by root, and claude-code — which devbase - // pulls in via `dependsOn` — then cannot write its own config in the container. It has - // to stay in step with the .claude mount below; dropping it while the mount remained - // is exactly what the .ssh cleanup did. + // shape: the one Docker creates is owned by root, and Claude Code then cannot write its + // own config or session state in the container. Still needed after 2.0.0 dropped the + // claude-code CLI from devbase's `dependsOn` — the VS Code extension writes to the same + // directory, and the RTK hook is read from the settings.json inside it. It has to stay + // in step with the .claude mount below; dropping it while the mount remained is exactly + // what the .ssh cleanup did. // // ~/.zsh_history needs the same `touch` as ~/.gitconfig and for the same reason: it is // bound as a single file since RSRMID-3052, and a missing source becomes a root-owned @@ -42,12 +44,18 @@ // and you are back to pushing a branch to find out whether a change works. "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - // node, github-cli and claude-code are deliberately NOT listed: devbase declares all - // three in `dependsOn` and pulls them in itself — node at lts, which is the Node the + // node and github-cli are deliberately NOT listed: devbase declares both in + // `dependsOn` and pulls them in itself — node at lts, which is the Node the // devcontainer CLI and prettier run on. Leaving them out is what makes this container // a check of that guarantee rather than a config that would still have `gh` and a Node // of its own if the dependency silently stopped resolving. // + // claude-code used to be a third. 2.0.0 dropped it (RSRMID-3053): the VS Code + // extension runs its own bundled runtime, so the npm global was a redundant second + // copy that could not even update itself. This frame still references `devbase:1` + // below, so it keeps the CLI until that reference moves to `:2` — after which there is + // deliberately no `claude` on PATH here. + // // Every removal from this list is a bet that the *published* Feature already provides // it, because this list resolves devbase:1 from the registry. Dropping github-cli once // on the strength of a dependsOn that existed only in the working tree is how every diff --git a/.devcontainer/local/devcontainer.json b/.devcontainer/local/devcontainer.json index 2a0b3c4..79730f8 100644 --- a/.devcontainer/local/devcontainer.json +++ b/.devcontainer/local/devcontainer.json @@ -27,9 +27,12 @@ "features": { // Needed to run `pnpm features:test` from inside this container. "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - // node, github-cli and claude-code come from devbase's `dependsOn` — see the default - // config. Here they exercise it straight from the working-tree copy, which is why this - // list can shrink without waiting for a release the way the default one must. + // node and github-cli come from devbase's `dependsOn` — see the default config. Here + // they exercise it straight from the working-tree copy, which is why this list can + // shrink without waiting for a release the way the default one must. That also means + // this config is where the claude-code removal takes effect first: unlike the default + // frame, it has no published `:1` to keep the CLI alive, so a container built here has + // no `claude` on PATH as soon as the working tree drops it. "./devbase": {} },