Skip to content

fix: the host ignored $CLAUDE_CONFIG_DIR and read ~/.claude regardless - #570

Merged
blooop merged 2 commits into
mainfrom
fix/claude-config-dir
Sep 4, 2026
Merged

fix: the host ignored $CLAUDE_CONFIG_DIR and read ~/.claude regardless#570
blooop merged 2 commits into
mainfrom
fix/claude-config-dir

Conversation

@JSmithRobotics

@JSmithRobotics JSmithRobotics commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

A host that has moved its Claude configuration now forwards its login, instead of
reporting itself as not logged in.

Claude Code reads $CLAUDE_CONFIG_DIR before ~/.claude, and the probe dl
runs inside a container has always read it too
, because a devcontainer feature
may set it. Only the host side did not: it joined a hardcoded
.claude/.credentials.json onto $HOME, so a host that had moved its
configuration returned NoToken::NotLoggedIn while holding a perfectly good
login, and claude in every workspace asked for a login.

The symptom was silent, which is why it survived. "No credential file at
~/.claude" is also the ordinary macOS state, where the login lives in the
keychain, so it is deliberately not warned about. A host that had moved its config
got that same quiet answer, with nothing to tell the two apart.

The variable replaces the default rather than being tried ahead of it

Claude Code does not fall back from $CLAUDE_CONFIG_DIR to ~/.claude, and a
fallback here would forward a credential out of a directory Claude Code is not
reading. That is the same defect one level down: invisible until the host holds two
logins, at which point it forwards the wrong one. An empty value counts as unset,
matching domain::xdg's rule and what a shell exporting a bare variable means.

Order is unchanged above the new arm: DEVLAUNCH_NO_CLAUDE_TOKEN first, then any
exported CLAUDE_CODE_OAUTH_TOKEN, and only then the file. The exported token
stays above the variable because both are ambient, and that hatch is what lets a
dl running inside a workspace forward the token it was handed.

One split worth naming

CREDENTIALS_RELPATH becomes CONFIG_RELPATH plus CREDENTIALS_FILENAME, so the
directory half is a decision and the filename half is a constant.

CONFIG_RELPATH is deliberately not shared with flows::provision's
CLAUDE_CONFIG_RELPATH: it is the same string about two different machines, and a
comment in each names the other. Sharing it would couple the host's layout to the
container's, which is exactly the coupling this bug came from.

Scope

clients/claude.rs plus the README, docs/workspace-tools.md and the changelog.
Public-API snapshots are untouched and verified so, because every item involved is
pub(crate).

Independent of #564. feat: --claude-profile forwards a named Claude login builds
on the config_dir() this introduces and carries this commit until this merges;
once it does, that PR's diff collapses to its own two commits.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28

Summary by Sourcery

Honor CLAUDE_CONFIG_DIR when forwarding host Claude logins so credential lookup matches Claude Code across relocated and non-UTF-8 configuration directories.

Bug Fixes:

  • Honor CLAUDE_CONFIG_DIR when locating host Claude credentials, preventing authenticated hosts with relocated configurations from being reported as logged out.

Enhancements:

  • Match Claude Code’s credential-directory precedence by treating a non-empty CLAUDE_CONFIG_DIR as a replacement for ~/.claude, while preserving opt-out and exported-token precedence.
  • Preserve non-UTF-8 configuration paths when resolving host credentials.

Documentation:

  • Document host credential lookup, precedence, empty-value behavior, and the new CLAUDE_CONFIG_DIR environment variable in the README and workspace-tools guide.
  • Record the host credential lookup fix and its behavior in the changelog.

Tests:

  • Add coverage for relocated configurations, replacement rather than fallback behavior, precedence rules, credential isolation, and non-UTF-8 paths.

Claude Code reads $CLAUDE_CONFIG_DIR before ~/.claude, and the probe dl runs
inside a container has always read it too, because a devcontainer feature may
set it. Only the host side did not: it joined a hardcoded
.claude/.credentials.json onto $HOME, so a host that had moved its
configuration reported NoToken::NotLoggedIn while holding a perfectly good
login.

The symptom was silent, which is why it survived. "No credential file at
~/.claude" is also the ordinary macOS state, where the login lives in the
keychain, so it is deliberately not warned about; a host that had moved its
config got that same quiet answer and no way to tell the two apart.

The variable replaces the default rather than being tried ahead of it. Claude
Code does not fall back from $CLAUDE_CONFIG_DIR to ~/.claude, and a fallback
here would forward a credential out of a directory Claude Code is not reading,
which is the same defect one level down: invisible until the host holds two
logins, at which point it forwards the wrong one. An empty value counts as
unset, matching domain::xdg's rule and what a shell exporting a bare variable
means.

Order is unchanged above the new arm: DEVLAUNCH_NO_CLAUDE_TOKEN first, then any
exported CLAUDE_CODE_OAUTH_TOKEN, and only then the file. The exported token
stays above the variable because both are ambient and that hatch is what lets a
dl running inside a workspace forward the token it was handed.

CREDENTIALS_RELPATH splits into CONFIG_RELPATH and CREDENTIALS_FILENAME, so the
directory half is a decision and the filename half is a constant.
CONFIG_RELPATH is deliberately not shared with flows::provision's
CLAUDE_CONFIG_RELPATH: the same string about two different machines, with a
comment in each naming the other.

Public-API snapshots are untouched, since every item involved is pub(crate).

@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 7 days 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

The PR fixes host-side Claude authentication by selecting credentials from CLAUDE_CONFIG_DIR when it is non-empty and otherwise using $HOME/.claude, without falling back from an explicitly configured directory. It preserves existing token precedence and forwarding safeguards, adds focused regression tests, and documents the behavior.

Sequence diagram for forwarding a host Claude login

sequenceDiagram
    participant User
    participant DL
    participant Resolver
    participant ConfigDir
    participant WorkspaceClaude
    User->>DL: Open workspace
    DL->>Resolver: resolve_token(home, host)
    alt DEVLAUNCH_NO_CLAUDE_TOKEN
        Resolver-->>DL: NoToken::OptedOut
    else Exported CLAUDE_CODE_OAUTH_TOKEN
        Resolver-->>DL: Exported access token
    else Non-empty CLAUDE_CONFIG_DIR
        Resolver->>ConfigDir: Read .credentials.json
        ConfigDir-->>Resolver: accessToken
        Resolver-->>DL: Access token
        DL->>WorkspaceClaude: Forward CLAUDE_CODE_OAUTH_TOKEN
    else Empty or unset CLAUDE_CONFIG_DIR
        Resolver->>Resolver: Read HOME/.claude/.credentials.json
        Resolver-->>DL: Access token or NoToken::NotLoggedIn
        DL->>WorkspaceClaude: Forward CLAUDE_CODE_OAUTH_TOKEN
    end
Loading

Flow diagram for host Claude credential resolution

flowchart TD
    Start["Resolve Claude token"] --> OptOut{DEVLAUNCH_NO_CLAUDE_TOKEN set}
    OptOut -->|Yes| NoToken["Missing: opted out"]
    OptOut -->|No| Exported{CLAUDE_CODE_OAUTH_TOKEN valid}
    Exported -->|Yes| Forward["Forward exported token"]
    Exported -->|No| ConfigDir{CLAUDE_CONFIG_DIR non-empty}
    ConfigDir -->|Yes| Moved["Read CONFIG_DIR/.credentials.json"]
    ConfigDir -->|No| Home["Read HOME/.claude/.credentials.json"]
    Moved --> Result["Forward access token or report not logged in"]
    Home --> Result
Loading

File-Level Changes

Change Details Files
Honor the host's Claude configuration directory when resolving credentials.
  • Capture CLAUDE_CONFIG_DIR in HostEnv and resolve it before the home-directory default.
  • Read .credentials.json from the selected directory while preserving opt-out and exported-token precedence.
  • Treat empty or unset configuration paths as the default $HOME/.claude location, and do not fall back when a non-empty custom directory lacks credentials.
rust/devlaunch-core/src/clients/claude.rs
Add regression coverage for credential selection and security invariants.
  • Test custom-directory reads, replacement semantics, empty-value handling, precedence, opt-out behavior, and missing credentials.
  • Verify only the access token is extracted from credentials in the custom directory.
  • Update existing default-path tests for the split directory and filename constants.
rust/devlaunch-core/src/clients/claude.rs
Document the host configuration behavior and its precedence rules.
  • Explain custom-directory selection, replacement rather than fallback, and empty-value semantics.
  • Expose CLAUDE_CONFIG_DIR in the environment-variable reference and describe its host-side use.
  • Record the fix and clarify that exported tokens and opt-out behavior retain their existing precedence.
README.md
docs/workspace-tools.md
CHANGELOG.md

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

@blooop blooop left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Closes a real asymmetry, and the direction is right: the container probe has always read $CLAUDE_CONFIG_DIR and the host side did not. Replacing rather than falling back matches Claude Code, and the reasoning for keeping the exported token above it is sound.

Two things below. The first is the same silent-NotLoggedIn symptom this PR exists to remove, reachable by a different route.

Checked and clean: empty value, trailing slash, nonexistent directory, the container-side ${CLAUDE_CONFIG_DIR:-$HOME/.claude} agreeing on empty-is-unset, and no host env leaking past --send-env. I looked for a multi-path (colon-separated) assumption and found no evidence Claude Code accepts one, so I am not raising it.

One non-finding worth knowing: the e2e suite scopes HOME to a scratch dir (test/fixtures/e2e_helpers.py:410) but does not scrub CLAUDE_CONFIG_DIR, so on a developer machine that sets it, e2e runs now read the real credential. Nothing asserts token absence, so nothing breaks.

§4: the numbered 4-item order in the module note, HostEnv.config_dir's field doc, and docs/workspace-tools.md:83-87 all read as a fallback chain, which is exactly what the code refuses to be — each is corrected two lines later, so it is a clarity cost rather than a wrong claim.

Spec axis: no spec available (no Closes #n, no linked issue), so that axis was skipped.

Self {
disable: crate::osext::env_str(DISABLE_VAR),
token: crate::osext::env_str(TOKEN_VAR),
config_dir: crate::osext::env_str(CONFIG_DIR_VAR),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

osext::env_str is var_os(..).to_string_lossy().into_owned() (osext.rs:52-61), so a non-UTF-8 path is mangled rather than preserved. CLAUDE_CONFIG_DIR=/home/u/cfg-caf\xe9 (legal bytes on Linux) becomes /home/u/cfg-caf\u{FFFD}, read_to_string fails, and the result is Missing(NoToken::NotLoggedIn) — unwarned. That is precisely the "authenticated host reports itself as not logged in" symptom this PR removes, reintroduced for a path the variable can legally hold.

The docstring at :236 cites crate::domain::xdg as the precedent, and xdg::resolve deliberately takes Option<OsString> and does a byte-preserving PathBuf::from(value). This arm matches xdg on empty-is-unset but not on this.

Suggest HostEnv.config_dir: Option<OsString> filled from std::env::var_os; OsString satisfies the derives already on HostEnv.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Fixed. `HostEnv.config_dir` is an `OsString` from `std::env::var_os`, matching `domain::xdg::resolve`. The new test asserts both halves: the directory is opened as named, and the lossy spelling of the same path really would have returned `NotLoggedIn`.

Comment thread docs/workspace-tools.md Outdated
reading, which is the same defect as ignoring the variable and harder to notice,
because it only shows itself on a host with two logins. So a `$CLAUDE_CONFIG_DIR`
that names a directory holding no credential is a host that is not logged in, and
the launch says so once rather than quietly forwarding the other account.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

"the launch says so once" — nothing says so. forwarded_claude maps every TokenLookup::Missing(_) to None with no notice (flows/launch.rs:1938-1941), and enum LaunchNotice has NoGitHubToken but no Claude arm. So a $CLAUDE_CONFIG_DIR naming a directory with no credential forwards nothing and is silent, not announced.

This PR's own test comment says the opposite and is the accurate one: "its absence is the quiet arm and not a warning" (clients/claude.rs:441). Suggest deleting the clause, or saying the launch is deliberately silent here — the sentence as written promises a message a reader will go looking for.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Fixed. The clause is gone; the page now says the launch is deliberately silent here and why (the macOS keychain case), which is what the branch's own test comment already said. The four-item list is two items now, for the same reason it was flagged.

… not there

`osext::env_str` decodes lossily on purpose, which is right for the two switches
either side of this field and wrong for this one: a path is bytes, not text.
`CLAUDE_CONFIG_DIR=/home/u/cfg-caf<0xE9>` -- a legal Linux path -- arrived with
U+FFFD where the byte was, failed to open, and reported `NotLoggedIn` on a host
holding a good login. That is the exact symptom this branch exists to remove, so
reading the variable back in lossily was the same defect wearing the fix's
clothes.

`HostEnv.config_dir` is an `OsString` filled from `std::env::var_os`, which is
what `domain::xdg`'s `resolve` already does and the rule this arm already cited.
The new test asserts both halves: the directory is opened as named, and the lossy
spelling of the same path really would have missed it.

Two documentation corrections in the same change, both about claims this branch
added:

- `docs/workspace-tools.md` said "the launch says so once rather than quietly
  forwarding the other account". Nothing says so: `forwarded_claude` maps every
  `TokenLookup::Missing(_)` to `None` and `LaunchNotice` has no Claude arm. The
  silence is deliberate -- on macOS the credential is in the login keychain and
  no file is the ordinary state -- so the page now says that instead of promising
  a message a reader would go looking for. The branch's own test comment already
  said the accurate thing.

- The four-item list in the module note and on the page read as a fallback chain,
  which is precisely what the code refuses to be. The two file reads are now one
  item, since a reader who stops at the list should not come away with the model
  the next paragraph has to argue against.
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.13793% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 94.74%. Comparing base (5b7d12f) to head (6307b89).

Files with missing lines Patch % Lines
rust/devlaunch-core/src/clients/claude.rs 99.13% 1 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.02% <99.13%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.02% <99.13%> (+0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop
blooop merged commit 1ca1019 into main Sep 4, 2026
15 checks passed
@blooop
blooop deleted the fix/claude-config-dir branch September 4, 2026 11:48
@blooop blooop mentioned this pull request Sep 4, 2026
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.

2 participants