Skip to content

fix: an empty seeded .credentials.json is a logged-out session that wins - #583

Open
JSmithRobotics wants to merge 1 commit into
mainfrom
fix/empty-credentials-stub-defeats-token-forwarding
Open

fix: an empty seeded .credentials.json is a logged-out session that wins#583
JSmithRobotics wants to merge 1 commit into
mainfrom
fix/empty-credentials-stub-defeats-token-forwarding

Conversation

@JSmithRobotics

@JSmithRobotics JSmithRobotics commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

dl --claude-profile bear <ws> resolved the profile, forwarded the token, and claude asked to log in anyway.

The bug

.devcontainer/claude-code/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 forwarded_claude had just resolved from the profile and sent over ssh.

Measured both ways in one container, changing nothing else:

$ dl --claude-profile bear <ws> -- 'claude ...'
   -> prompts for a login, while CLAUDE_CODE_OAUTH_TOKEN is set and valid

$ dl --claude-profile bear <ws> -- 'rm -f "$CLAUDE_CONFIG_DIR/.credentials.json"; claude -p "reply with exactly: authenticated"'
authenticated

The token was verified to arrive on both transports before suspecting the file, by fingerprint rather than by printing it: (default), bear and base forward three different sha256[0:10] values, over devpod ssh and over the OpenSSH hop alike.

Why it went unnoticed, which is the interesting half

Two independent reasons, and both of them are about the mounts.

The stub is invisible while the feature mounts the host's credentials file over it. So the bug could only ever appear where the forwarded token is the container's only login, and that is the one configuration this feature's mounts rule out: forwarded_claude declines to forward at all into a ClaudeConfig::Foreign config, which is what any mount at, under or above the config directory produces. It surfaced on a container whose Claude mounts had been removed, which is what a workspace that wants to use a profile has to be.

The reasoning was already written down, on the other side of the same feature. init-host.sh retired its half of this seeding and said why:

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.

The container half was left behind. The stub existed for the same reason the host one did: to give a bind mount a source to cover, from the layout where this feature mounted nine individual paths under ~/.claude.

The change

Both seeded files go. .claude.json was written by the same block for the same retired reason, and 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 the stub cannot return under another name. It fails against the old installer, verified by reverting .devcontainer/ with the test in place.

Cost

This is a change under .devcontainer/, so the prebuild tag moves and launches build locally until devcontainer-prebuild.yml republishes on main. Same caveat as #582, which is open and green.

Not in this PR

The mounts themselves. Removing them is what let the forwarded token be used at all, and it is not a fix worth shipping as written: a container with no ~/.claude mount loses the shared agents/, commands/, hooks/, skills/ and wf-skills/, which is the point of the feature. Worth noting that removing only the whole-directory mount would not help either, since a mount under the config directory convicts exactly as the directory mount does.

The two shapes that keep both, for a separate discussion:

  1. Copy instead of mount. Bind the host's ~/.claude read-only at a neutral path and copy the instruction directories in from postCreateCommand. Nothing is mounted at or under the config directory, so it reads Ours. Costs live propagation, which the directory mount was chosen to preserve.
  2. Refine the descendant rule so a mount strictly under the config directory convicts only when it covers .credentials.json. A read-only skills/ mount does not own the login. Costs care: the descendants scan exists because this feature once mounted .credentials.json individually.

Happy to open that as an issue instead if you would rather keep it out of a PR thread.

Summary by Sourcery

Remove seeded Claude configuration stubs so forwarded profiles can authenticate correctly in containers without mounted credentials.

Bug Fixes:

  • Stop the Claude Code installer from seeding empty credential and configuration files that override forwarded OAuth tokens and incorrectly trigger login prompts.

Tests:

  • Add coverage ensuring the installer does not write either Claude configuration file, preventing the faulty seeding from returning under another implementation.

`dl --claude-profile bear <ws>` 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.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @JSmithRobotics, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 6 days and 19 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Removes the installer’s empty Claude config-file stubs, which could override a valid forwarded CLAUDE_CODE_OAUTH_TOKEN as a logged-out session, and adds a whole-installer regression test covering both removed files.

Sequence diagram for forwarded Claude authentication without seeded credentials

sequenceDiagram
    participant DL
    participant Container
    participant Claude

    DL->>Container: Set CLAUDE_CODE_OAUTH_TOKEN
    Container->>Claude: Start claude
    Claude->>Claude: Read credentials configuration
    alt Empty .credentials.json exists
        Claude-->>Container: Prompt for login
    else No seeded credentials file
        Claude-->>Container: Authenticate with CLAUDE_CODE_OAUTH_TOKEN
    end
Loading

Flow diagram for removing empty Claude config stubs

flowchart LR
    Installer[create_claude_directories]
    Files[.credentials.json and .claude.json]
    Test[test_the_feature_seeds_no_empty_credential]
    Claude[Claude Code]

    Installer -->|creates directories only| Claude
    Installer -.->|does not seed| Files
    Test -->|asserts both files are absent| Installer
    Claude -->|creates files on first use| Files
Loading

File-Level Changes

Change Details Files
Stop creating empty Claude configuration files so forwarded OAuth credentials can be used when no credentials mount is present.
  • Remove seeding of .credentials.json and .claude.json from the directory-creation step.
  • Rely on Claude Code to create both files when needed, preserving the existing directory and ownership setup.
.devcontainer/claude-code/install.sh
Add a regression test preventing either empty configuration file from being written by the installer.
  • Scan the complete installer for non-comment write operations targeting both configuration files.
  • Cover alternate write forms so the removed seeding cannot return under another implementation.
test/unit/test_devcontainer_manifest.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant