From f3adbaa19e70ae8d60b7ff3dd9a6fc62e17c7712 Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Fri, 4 Sep 2026 16:01:14 +0100 Subject: [PATCH 1/4] fix: an empty seeded .credentials.json is a logged-out session that wins `dl --claude-profile bear ` forwarded a valid token and `claude` asked the operator to log in anyway. `install.sh` seeded `{}` into `.credentials.json` when the file was missing. Claude Code reads the credentials file before the environment, so an empty one is not a placeholder: it is a logged-out session, and it beats the `CLAUDE_CODE_OAUTH_TOKEN` that `dl` had just gone to the trouble of resolving from the profile and forwarding over ssh. Both halves of why this went unnoticed: The stub existed to give a bind mount a source to cover, from the layout where this feature mounted nine individual paths under `~/.claude`. The host-side hook already retired its half of that on exactly this reasoning -- "Seeding an empty {} over a credentials file was never anything but a way to satisfy a bind source, and on a host that has never run Claude it is indistinguishable from a logged-out session" (init-host.sh) -- and the container half was left behind. And while this feature mounts the host's real credentials file *over* the stub, the stub is invisible. So the bug could only appear where the forwarded token was the container's only login, which is the one configuration the mount rules out: `forwarded_claude` refuses to forward at all into a `Foreign` config. It surfaced on a container whose Claude mounts had been removed, which is what a workspace wanting a profile has to be. Measured both ways in one container, changing nothing else: with the stub, `claude` prompts for a login; with it removed, `claude -p` answers on the forwarded token. `.claude.json` goes with it, seeded by the same block for the same retired reason. Claude Code creates both itself on first use, so there is nothing to replace either with. `test_the_feature_seeds_no_empty_credential` asserts it over the whole installer rather than about one line, so it cannot return under another name. --- .devcontainer/claude-code/install.sh | 34 +++++++++++++++++-------- test/unit/test_devcontainer_manifest.py | 31 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/.devcontainer/claude-code/install.sh b/.devcontainer/claude-code/install.sh index d726f06e..071cf749 100755 --- a/.devcontainer/claude-code/install.sh +++ b/.devcontainer/claude-code/install.sh @@ -175,16 +175,30 @@ create_claude_directories() { mkdir -p "$TARGET_HOME/.claude/commands" mkdir -p "$TARGET_HOME/.claude/hooks" - # Create empty config files if they don't exist - if [ ! -f "$TARGET_HOME/.claude/.credentials.json" ]; then - echo "{}" > "$TARGET_HOME/.claude/.credentials.json" - chmod 600 "$TARGET_HOME/.claude/.credentials.json" - fi - - if [ ! -f "$TARGET_HOME/.claude/.claude.json" ]; then - echo "{}" > "$TARGET_HOME/.claude/.claude.json" - chmod 600 "$TARGET_HOME/.claude/.claude.json" - fi + # No empty config files are seeded, and `.credentials.json` is the reason. + # + # This used to write `{}` into `.credentials.json` and `.claude.json` when they were + # missing. Both existed only to give a bind mount a source to cover, from the layout + # where this feature mounted nine individual paths under `~/.claude` -- and the + # host-side hook already retired its half of that on the same reasoning: "Seeding an + # empty {} over a credentials file was never anything but a way to satisfy a bind + # source, and on a host that has never run Claude it is indistinguishable from a + # logged-out session" (init-host.sh). + # + # In the container that stub is worse than useless, because it wins. `dl` forwards a + # profile's login as CLAUDE_CODE_OAUTH_TOKEN, Claude Code reads the credentials file + # first, and an empty one is a logged-out session: the agent asks the operator to log + # in while a valid token sits in its environment. It stayed hidden for as long as this + # feature mounted the host's real credentials file *over* the stub -- so the bug was + # invisible in exactly the configuration that could not use the token anyway, and + # surfaced the moment a container was given the forwarded login as its only one. + # + # Measured both ways in one container: with the stub, `claude` prompts for a login; + # with it removed and nothing else changed, `claude -p` answers on the forwarded + # token. Claude Code creates both files itself on first use, so there is nothing to + # replace this with. + # + # Guarded by test_the_feature_seeds_no_empty_credential. # Set proper ownership if [ "$(id -u)" -eq 0 ]; then diff --git a/test/unit/test_devcontainer_manifest.py b/test/unit/test_devcontainer_manifest.py index a9501286..82f56389 100644 --- a/test/unit/test_devcontainer_manifest.py +++ b/test/unit/test_devcontainer_manifest.py @@ -681,3 +681,34 @@ def test_the_devcontainer_installs_the_committed_lock_rather_than_solving_its_ow "create is free to discard the committed lock, solve its own environment over the " "network, and rewrite pixi.lock while it does it" ) + + +def test_the_feature_seeds_no_empty_credential(): + """The feature must not write a `.credentials.json` into the container. + + An empty ``{}`` there is not a harmless placeholder, it is a logged-out + session that wins: ``dl`` forwards a profile's login as + ``CLAUDE_CODE_OAUTH_TOKEN``, Claude Code reads the credentials file first, + and the agent then asks the operator to log in while a valid token sits in + its environment. + + It stayed hidden for as long as the feature mounted the host's real + credentials file *over* the stub, so the bug was invisible in exactly the + configuration that could not use a forwarded token anyway. + + Asserted over the whole installer rather than about one line, so it cannot + come back under another name. ``.claude.json`` is included because it was + seeded by the same block for the same retired reason; Claude Code creates + both itself on first use. + """ + installer = FEATURE_INSTALLER.read_text() + for name in (".credentials.json", ".claude.json"): + seeding = [ + line + for line in installer.splitlines() + # A write of the file, rather than a comment naming it. + if name in line + and not line.lstrip().startswith("#") + and (">" in line or "tee" in line or "cp " in line) + ] + assert not seeding, f"{name} is written by install.sh: {seeding}" From b2c7f7a1e2b469a0f6428a32f4e3d3e57cef1f30 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 9 Sep 2026 06:34:19 +0000 Subject: [PATCH 2/4] fix: the seeding guard matched one idiom, not the spellings `test_the_feature_seeds_no_empty_credential` claimed to hold "over the whole installer ... so it cannot come back under another name" while matching only a line that spelled the filename *and* contained `>`, `tee` or `cp `. Three ordinary re-seedings walked straight through it: touch "$TARGET_HOME/.claude/.credentials.json" install -m 600 /dev/null "$TARGET_HOME/.claude/.credentials.json" cred="$TARGET_HOME/.claude/.credentials.json" # redirect on the next line The first is not contrived. `.devcontainer/claude-code/README.md` prescribes `touch ~/.claude/.credentials.json` for this exact file, so it is the likeliest way the stub comes back, and the guard written to stop that was blind to it. The third is what someone does *while* editing the function: factoring the path into a variable splits the name and the redirection across two lines, and neither line matched. The filter was also over-broad the other way, tripping on a `stat` or an `echo ... >&2` that only named the file. Both halves go: the predicate is now "named outside a comment", the installer mentions both files only in comments, and a future line that genuinely needs to name one is meant to be a conversation rather than a match the filter happens to allow. Extracted as `_seeding_lines` so the spellings are exercised directly. Those five parametrized cases fail against the old predicate (3 of 5) and pass now; the guard itself is still red against the pre-fix installer, and now names all three of its lines instead of one. --- test/unit/test_devcontainer_manifest.py | 77 ++++++++++++++++++++----- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/test/unit/test_devcontainer_manifest.py b/test/unit/test_devcontainer_manifest.py index c7327122..9cb9546e 100644 --- a/test/unit/test_devcontainer_manifest.py +++ b/test/unit/test_devcontainer_manifest.py @@ -683,6 +683,59 @@ def test_the_devcontainer_installs_the_committed_lock_rather_than_solving_its_ow ) +SEEDED_NAMES = (".credentials.json", ".claude.json") + + +def _seeding_lines(installer, name): + """Lines of `installer` that would put `name` on disk. + + A comment naming the file is the one exemption. Nothing else is: any other + mention counts, including one that only assigns the path to a variable. + + Keying on a list of write verbs was the first attempt and it is the reason + this is a function with tests of its own. `>`, `tee` and `cp` miss `touch` + -- which is what `.devcontainer/claude-code/README.md` prescribes for this + exact file, so it is the likeliest way the stub returns -- they miss + `install -m 600 /dev/null`, and they miss any spelling that names the path + on one line and redirects into the variable on the next. + """ + return [ + line + for line in installer.splitlines() + if name in line and not line.lstrip().startswith("#") + ] + + +@pytest.mark.parametrize( + "write", + [ + 'touch "$TARGET_HOME/.claude/.credentials.json"', + 'install -m 600 /dev/null "$TARGET_HOME/.claude/.credentials.json"', + # Names the path here and redirects into `$cred` on the next line, so + # neither line carries both the filename and a redirection. + 'cred="$TARGET_HOME/.claude/.credentials.json"', + ': > "$TARGET_HOME/.claude/.credentials.json"', + 'echo "{}" > "$TARGET_HOME/.claude/.credentials.json"', + ], +) +def test_the_seeding_guard_catches_a_write_that_is_not_a_redirection(write): + """The guard has to hold against the spellings, not against one idiom. + + Every line here re-seeds the credentials file and every one of them passed + the guard as first written, which claimed to hold "over the whole installer + ... so it cannot come back under another name" while matching only `>`, + `tee` and `cp`. + """ + installer = f' mkdir -p "$TARGET_HOME/.claude"\n {write}\n' + assert _seeding_lines(installer, ".credentials.json") == [f" {write}"] + + +def test_the_seeding_guard_reads_a_comment_as_a_comment(): + """The exemption the guard does grant, since the fix is 22 lines of comment + explaining why the file is not seeded, and every one of them names it.""" + assert _seeding_lines(" # writes .credentials.json\n", ".credentials.json") == [] + + def test_the_feature_seeds_no_empty_credential(): """The feature must not write a `.credentials.json` into the container. @@ -696,22 +749,18 @@ def test_the_feature_seeds_no_empty_credential(): credentials file *over* the stub, so the bug was invisible in exactly the configuration that could not use a forwarded token anyway. - Asserted over the whole installer rather than about one line, so it cannot - come back under another name. ``.claude.json`` is included because it was - seeded by the same block for the same retired reason; Claude Code creates - both itself on first use. + ``.claude.json`` is held to the same rule because it was seeded by the same + block for the same retired reason. + + The installer names both files only in comments today, so the guard forbids + every other mention rather than guessing at write syntax. A future line that + genuinely needs to name one is meant to be a conversation, not a match the + filter happens to let through. """ installer = FEATURE_INSTALLER.read_text() - for name in (".credentials.json", ".claude.json"): - seeding = [ - line - for line in installer.splitlines() - # A write of the file, rather than a comment naming it. - if name in line - and not line.lstrip().startswith("#") - and (">" in line or "tee" in line or "cp " in line) - ] - assert not seeding, f"{name} is written by install.sh: {seeding}" + for name in SEEDED_NAMES: + seeding = _seeding_lines(installer, name) + assert not seeding, f"{name} is named outside a comment in install.sh: {seeding}" def test_the_agent_socket_is_bound_from_the_variable_that_names_it(devcontainer, mounts): From 6daa21073c40caaf1ac09b1852f837a6c3eb3d49 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 9 Sep 2026 06:35:42 +0000 Subject: [PATCH 3/4] fix: the comment credited Claude Code with a file devlaunch writes "Claude Code creates both files itself on first use" was true of `.credentials.json` and wrong about `.claude.json`, which hid the more interesting half of removing that stub. `provision.rs` seeds {"hasCompletedOnboarding":true} into $CLAUDE_CONFIG_DIR/.claude.json and exits early when the file is already there. This feature sets CLAUDE_CONFIG_DIR to /home/vscode/.claude -- the directory `create_claude_directories` is populating -- so the `{}` stub satisfied that guard before the provisioner ever looked. Every container built from this feature skipped the onboarding seed and met its operator with the trust prompt, which is precisely what the seed exists to prevent. Removing the stub un-blocks it, so this change fixes two things and the comment described one. `test_the_installer_may_not_create_the_file_that_marks_claude_onboarded` holds the coupling rather than restating it: the filename the provisioner exits on is read out of provision.rs and required to be one the installer is forbidden to write. Confirmed to have teeth by dropping `.claude.json` from the forbidden set, which fails it. The comment itself is not test-provable, so it goes in beside the guard. --- .devcontainer/claude-code/install.sh | 14 ++++++++--- test/unit/test_devcontainer_manifest.py | 33 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/.devcontainer/claude-code/install.sh b/.devcontainer/claude-code/install.sh index 071cf749..6c66e725 100755 --- a/.devcontainer/claude-code/install.sh +++ b/.devcontainer/claude-code/install.sh @@ -195,10 +195,18 @@ create_claude_directories() { # # Measured both ways in one container: with the stub, `claude` prompts for a login; # with it removed and nothing else changed, `claude -p` answers on the forwarded - # token. Claude Code creates both files itself on first use, so there is nothing to - # replace this with. + # token. Claude Code creates `.credentials.json` itself on first use, so there is + # nothing to replace it with. # - # Guarded by test_the_feature_seeds_no_empty_credential. + # `.claude.json` had a second victim, and it was devlaunch. The provisioner seeds + # {"hasCompletedOnboarding":true} into $CLAUDE_CONFIG_DIR/.claude.json and exits + # early when that file already exists -- and this feature points CLAUDE_CONFIG_DIR + # at the directory being created right here, so the stub satisfied that guard. + # Every container built from this feature skipped the onboarding seed and met its + # operator with the trust prompt, which is the opposite of what seeding it was for. + # + # Guarded by test_the_feature_seeds_no_empty_credential and + # test_the_installer_may_not_create_the_file_that_marks_claude_onboarded. # Set proper ownership if [ "$(id -u)" -eq 0 ]; then diff --git a/test/unit/test_devcontainer_manifest.py b/test/unit/test_devcontainer_manifest.py index 9cb9546e..1bba9b73 100644 --- a/test/unit/test_devcontainer_manifest.py +++ b/test/unit/test_devcontainer_manifest.py @@ -763,6 +763,39 @@ def test_the_feature_seeds_no_empty_credential(): assert not seeding, f"{name} is named outside a comment in install.sh: {seeding}" +def test_the_installer_may_not_create_the_file_that_marks_claude_onboarded(devcontainer): + """`.claude.json` is forbidden for a second reason, and it is devlaunch's own. + + The provisioner seeds ``{"hasCompletedOnboarding":true}`` into + ``$CLAUDE_CONFIG_DIR/.claude.json`` and exits early if that file is already + there. This feature points ``CLAUDE_CONFIG_DIR`` at the very directory the + installer sets up, so the ``{}`` stub was satisfying that guard: every + container built from this feature skipped the onboarding seed, which is the + opposite of what seeding it was for. + + Two files hold that one fact, so this diffs them rather than restating it: + the name the provisioner exits on has to be a name the installer is + forbidden to write. + """ + guarded = re.findall(r'\[ -e \\"\$dir/([^\\]+)\\" \]', SHIPPING_PROVISIONER.read_text()) + assert guarded, ( + f"{SHIPPING_PROVISIONER.name} no longer guards the onboarding seed on an " + "existing file; this test diffs that name against the installer and has " + "nothing left to diff" + ) + config_dir = devcontainer["containerEnv"]["CLAUDE_CONFIG_DIR"] + assert config_dir.endswith("/.claude"), ( + f"CLAUDE_CONFIG_DIR is {config_dir}, which is no longer the directory " + "install.sh populates, so the two no longer collide and this test is moot" + ) + for name in guarded: + assert name in SEEDED_NAMES, ( + f"the provisioner skips the onboarding seed when {name} exists, but " + f"install.sh is only forbidden to create {SEEDED_NAMES}, so the feature " + f"is free to suppress it again" + ) + + def test_the_agent_socket_is_bound_from_the_variable_that_names_it(devcontainer, mounts): """The agent socket mount reads $SSH_AUTH_SOCK, not a guess at where it is. From f0f43b195f63e6a8f978b0704c63cf9e54f21a8e Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 9 Sep 2026 06:36:53 +0000 Subject: [PATCH 4/4] docs: file the changelog entries 0.34.0 will ship Two user-visible fixes have landed since 0.33.0 and `## [Unreleased]` was empty for both. "All notable changes to this project will be documented in this file" is the file's own preamble, and a release cut off this section would have shipped notes for neither. The credentials one is this branch's. The ssh agent socket one is #582, which merged into main without an entry -- filed here rather than left for the release commit, so the section a cut promotes is already complete. Nothing enforces this: `test_changelog_frozen` only forbids writing into an already-shipped section, so both omissions were silent. --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf8202d..f4edf9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **A forwarded Claude login no longer loses to an empty credentials file.** + `dl --claude-profile bear ` resolved the profile, forwarded the token as + `CLAUDE_CODE_OAUTH_TOKEN`, and `claude` asked the operator to log in anyway. + The devcontainer feature seeded `{}` into `.credentials.json` when the file was + missing, and Claude Code reads that file before it reads the environment: an + empty one is not a placeholder, it is a logged-out session, and it wins. + + The stub was invisible for as long as the feature mounted the host's real + credentials file *over* it, so the bug could only ever appear where the + forwarded token was the container's only login -- which is the one + configuration those mounts rule out, since `forwarded_claude` declines to + forward into a config directory it does not own. It surfaced the moment a + workspace had its Claude mounts removed, which is what wanting a profile + requires. + + `.claude.json` went with it, and that half fixed something else. The + provisioner seeds `{"hasCompletedOnboarding":true}` there and exits early when + the file exists, so the stub had been suppressing devlaunch's own onboarding + seed: every container built from this feature met its operator with the trust + prompt. Claude Code creates the credentials file itself on first use, so + neither stub had anything to replace it. + +- **The ssh agent socket is bound from `$SSH_AUTH_SOCK` rather than from a guess + at where it lives.** The mount source was `${localEnv:HOME}/.ssh/agent.sock`, + which is not a path any agent picks by itself: gpg-agent listens on + `$XDG_RUNTIME_DIR/gnupg/S.gpg-agent.ssh` and `ssh-agent` on a `/tmp/ssh-XXXX` + mktemp path. On such a host `devpod up` refused the create outright with + `bind mount source path does not exist`, before the container existed, which + reads as a broken tool rather than as a manifest naming a path the host never + had. `init-host.sh` cannot paper over this the way it does for `known_hosts`: + that one is touched into existence, and there is no touching a socket into + being an agent. + ## [0.33.0] - 2026-09-07 ### Fixed