Skip to content

feat: --claude-profile forwards a named Claude login - #566

Closed
JSmithRobotics wants to merge 6 commits into
blooop:fix/claude-config-dirfrom
JSmithRobotics:feat/claude-profile
Closed

feat: --claude-profile forwards a named Claude login#566
JSmithRobotics wants to merge 6 commits into
blooop:fix/claude-config-dirfrom
JSmithRobotics:feat/claude-profile

Conversation

@JSmithRobotics

@JSmithRobotics JSmithRobotics commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #565. Its commit is in this diff because a cross-fork PR's base
must be a branch in this repository, so there is nowhere to point the base but
main. Merge #565 first and this diff collapses to its own two commits.
Review order: #565, then here.

dl --claude-profile <name> forwards a named Claude login instead of the default
one, for the case one credential cannot serve: two accounts on one machine, and a
workspace that wants the one your host is not signed in to.

The layout belongs to the tool that manages the profiles

Profiles are directories under ~/.claude-profiles/, or wherever
CLAUDE_PROFILES_DIR points, each holding the .credentials.json a claude
login writes and each a CLAUDE_CONFIG_DIR of its own, which is what makes the
logins independent.

The first version of this invented a devlaunch-shaped root under the config
directory, and the second commit here takes it back out.
That was a third
location for one concept, and it would have asked anyone with working profiles to
log every account in again somewhere new. There is no writer here and none is
coming: creating a profile, seeding the configuration it shares with the main
login, and deleting it stay with whatever made the directory. This reads.

DEVLAUNCH_CLAUDE_PROFILES_DIR still wins over CLAUDE_PROFILES_DIR, so a
scratch run reads its own profiles rather than the real credentials.

The refusal is the feature

A named profile that holds no credential stops the launch. It does not fall
back to the default login. Two accounts on one machine is what profiles are for, so
a typo that silently forwarded the other one would be worse than a launch that
fails: the launch you see, and the wrong account you find out about later and
somewhere else.

The name is validated at the boundary as a single directory component, so
--claude-profile ../../etc is refused by the rule rather than becoming a
traversal that fails later on a read. Empty, leading -, leading . (which
subsumes . and ..) and anything outside [A-Za-z0-9_.-] are all refused.

Where it sits in the resolution order

Above an exported CLAUDE_CODE_OAUTH_TOKEN, unlike $CLAUDE_CONFIG_DIR, because a
profile was typed on this command line for this launch and nothing ambient should
beat an explicit argument. DEVLAUNCH_NO_CLAUDE_TOKEN still comes first: a machine
that has opted out has no account to choose.

--claude-profile default resolves the login you would get anyway and never
consults a default/ directory, so a picker has something to select and a recalled
line has a way to say "not the profile I used last time".

What it deliberately does not reach

  • Not stored with the workspace, unlike --devcontainer, so no workspace can
    quietly forward an account chosen weeks ago. It resolves per session.
  • Not in reach of cleanup. Profiles live outside everything devlaunch owns, and
    --purge and --prune walk devlaunch's cache, so a login was never in reach of
    either. There is a test asserting the profiles root is unreachable from both.
  • Not the claude.ai account a container's claude is paired to for Remote
    Control. Two credentials, and this moves one of them.
  • Does not weaken the mounted-config check: Foreign forwards nothing, profile
    or no profile.

aid passes the flag through. A verb that forwards no login says it is ignoring it,
as --devcontainer does; a global command refuses it. Completion offers the
profiles that exist plus default, read off the disk rather than the completion
cache, because a profile made a minute ago has to complete now.

Public surface

flows::launch::Host::with_claude_profile is a promised row: it is a builder
method on a type api re-exports, so its signature is a contract.
domain::xdg::claude_profiles_root is binary surface. The third commit carries the
regenerated snapshots, with the toolchain and the reasoning in its message.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28

Summary by Sourcery

Add per-launch selection of independently managed Claude login profiles with strict validation and failure behavior.

New Features:

  • Add --claude-profile <name> to forward credentials from named host Claude profiles for workspace launches and aid sessions.
  • Provide shell completion for profiles discovered from the configured profiles directory, including the special default selection.

Bug Fixes:

  • Prevent launches from silently falling back to the default Claude credential when a requested profile is invalid or lacks a usable credential.
  • Keep named-profile credentials outside devlaunch-owned cleanup paths and preserve mounted Claude-config protections.

Enhancements:

  • Define profile-directory resolution and precedence through DEVLAUNCH_CLAUDE_PROFILES_DIR and CLAUDE_PROFILES_DIR, with command-line profiles taking precedence over ambient tokens.
  • Expose the profile builder and profiles-root API as public interfaces and document profile behavior, precedence, errors, and account boundaries.

Documentation:

  • Document named Claude profiles, supported environment variables, usage through aid, and their interaction with workspace storage and cleanup.

Tests:

  • Add coverage for profile resolution, precedence, validation, refusal behavior, credential forwarding, completion, CLI grammar, and secret-safe error messages.

One credential cannot serve a host with two accounts. `--claude-profile <name>`
names a directory under `<config>/devlaunch/claude-profiles/`, each holding the
`.credentials.json` a `claude` login writes, and forwards that one instead of the
default.

A named profile that holds no credential STOPS the launch. It does not fall back
to the default login, and that refusal is the feature rather than a rough edge:
two accounts on one machine is what profiles are for, so a typo that silently
forwarded the other one would be worse than a launch that fails. The launch you
see; the wrong account you find out about later and somewhere else. `NoToken`
gains `ProfileUnreadable` for it, apart from `Unreadable` because a profile
refusal has a name to quote and must not read as the quiet arm.

`ProfileName` checks at the boundary, beside `Token` and for its reason, so
`--claude-profile ../../etc` is refused by the rule rather than becoming a
traversal that fails later on a read. Empty, `.`, `..`, either separator, a NUL
and a leading `-` are all excluded; the grammar deliberately does not
double-check, so there is one boundary rather than two that can disagree.

Read above an exported CLAUDE_CODE_OAUTH_TOKEN, unlike $CLAUDE_CONFIG_DIR. Both
of those are ambient and a profile was typed on this command line for this
launch, so nothing ambient beats an explicit argument, and a nested `dl` naming
a profile is overriding exactly the token it inherited.
DEVLAUNCH_NO_CLAUDE_TOKEN still comes first: a machine that has opted out has no
account to choose. The ClaudeConfig::Ours gate is untouched, so a repo whose own
devcontainer owns its Claude config still forwards nothing, profile or no
profile.

Not stored with the workspace, unlike --devcontainer. A profile describes this
session rather than the container, and storing it would mean a workspace
quietly forwarding an account chosen weeks ago.

Profiles live under the config home and never the cache: --purge deletes the
cache entire and --prune walks the clones inside it, so a credential there would
be one flag away from deletion, and nothing regenerates a login.
`claude_profiles_root_in` is split out so that placement is a function of its
input and a test states it.

Refused on a global command, reported and ignored on a verb that forwards no
login, which is the line --devcontainer already draws. Completion offers the
profiles that exist, read off the disk rather than the completion cache, since
one made a minute ago has to complete now. VALUE_FLAGS gains the flag with a
test that diffs the list against clap's own parser, because a value-taking flag
missing from it leaves its value behind for wants_startup_cache_refresh to read
as a word.

Host::with_claude_profile is a new promised item and moves
public-api.api.txt; xdg::claude_profiles_root moves public-api.rest.txt. Neither
snapshot is regenerated here: the script needs a nightly toolchain this machine
does not have, and its own note warns that snapshots move with toolchain drift,
so regenerating against the wrong nightly would be noise.
The first version of --claude-profile put profiles under
`config_home()/devlaunch/claude-profiles`, which was wrong for a reason no test
could catch: `claude-as` on this machine already manages per-account
CLAUDE_CONFIG_DIRs under `~/.claude-profiles`, with two logged-in accounts in
it. A devlaunch-shaped root made a third location for one concept and would have
asked anyone with working profiles to log every account in again somewhere new.

So the layout and the variable are honoured rather than set, the arrangement dl
already has with devpod's DEVPOD_SSH_CONFIG and with Claude Code's own
CLAUDE_CONFIG_DIR:

  $DEVLAUNCH_CLAUDE_PROFILES_DIR   devlaunch's own, and it wins
  $CLAUDE_PROFILES_DIR             the managing tool's, honoured
  ~/.claude-profiles               its default

devlaunch's override stays on top because it is what scopes a scratch run away
from real credentials; without it a test would read the machine's actual logins.

There is still no writer, and now that is somebody else's job rather than a gap:
creating a profile, seeding the config it shares with the main login, and
deleting it belong to the tool that made the directory. dl reads one file out of
it.

The placement argument that put profiles under the config home is unaffected
rather than abandoned. --purge deletes devlaunch's cache entire and --prune walks
the clones inside it, and `~/.claude-profiles` was never in either path; the test
that held it now names the new location.

`--claude-profile default` resolves the unnamed credential and never consults a
`default/` directory, matching `claude-as default`, which runs claude with no
CLAUDE_CONFIG_DIR rather than looking one up. It earns a word rather than being
the absence of the flag because a picker needs something to select and a recalled
line needs a way to say "not the profile I used last time". It resolves the whole
unnamed chain, so it still honours CLAUDE_CONFIG_DIR and still loses to the
opt-out.

Verified against the real directory: completion offers `default base bear`, the
scratch override still scopes to its own, and both real credentials carry the
`claudeAiOauth.accessToken` this reads.
Two rows, and the first of them is a promise rather than a tripwire.

`public-api.api.txt` gains `flows::launch::Host::with_claude_profile`, twice,
because the generator renders a promised type's inherent methods under both the
`api` re-export section and the module that owns them. It is a builder method on
a type `api` re-exports, so an external consumer is entitled to depend on it and
a later change to its signature is a break.

`public-api.rest.txt` gains `domain::xdg::claude_profiles_root`, which is binary
surface: `dl` calls it and nothing promises it.

Regenerated with `scripts/public-api-snapshots.sh` on nightly 1.100.0
(2026-09-03) and cargo-public-api 0.52.0, the pin the script names. The script
reproduces main's three files byte for byte on that toolchain, which is what
makes these two rows the change rather than a rendering difference.
@sourcery-ai

sourcery-ai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds per-launch named Claude login forwarding backed by externally managed profile directories, with strict name validation, explicit failure instead of credential fallback, defined precedence and scope rules, live completion, aid passthrough, relocated-config support, API snapshot updates, tests, and documentation.

Sequence diagram for resolving a Claude credential

sequenceDiagram
    participant CLI as dl CLI
    participant Launch as Launch
    participant Resolver as Claude resolver
    participant Profiles as Profile directory
    participant Config as Claude config
    participant Session as Container session

    CLI->>Launch: with_claude_profile(profile)
    Launch->>Resolver: resolve_token(home, profiles_root, host)
    alt DEVLAUNCH_NO_CLAUDE_TOKEN
        Resolver-->>Launch: OptedOut
    else named profile other than default
        Resolver->>Resolver: ProfileName::parse(name)
        alt invalid name or missing credential
            Resolver-->>Launch: ProfileUnreadable or ProfileNotAName
        else credential found
            Resolver->>Profiles: read .credentials.json
            Profiles-->>Resolver: access token
            Resolver-->>Launch: Found(token)
        end
    else default or no profile
        alt exported CLAUDE_CODE_OAUTH_TOKEN
            Resolver-->>Launch: Found(token)
        else configured host directory
            Resolver->>Config: read CLAUDE_CONFIG_DIR/.credentials.json
            Config-->>Resolver: access token
            Resolver-->>Launch: Found(token)
        else default host directory
            Resolver->>Config: read ~/.claude/.credentials.json
            Config-->>Resolver: access token
            Resolver-->>Launch: Found(token)
        end
    end
    Launch->>Session: forward CLAUDE_CODE_OAUTH_TOKEN
Loading

Flow diagram for Claude profile resolution precedence

flowchart TD
    A[Launch with Claude authentication] --> B{DEVLAUNCH_NO_CLAUDE_TOKEN?}
    B -- yes --> C[Forward nothing]
    B -- no --> D{Named profile other than default?}
    D -- yes --> E{ProfileName::parse succeeds?}
    E -- no --> F[Refuse launch]
    E -- yes --> G[Read profile .credentials.json]
    G --> H{Credential found?}
    H -- no --> F
    H -- yes --> I[Forward profile token]
    D -- no --> J{CLAUDE_CODE_OAUTH_TOKEN exported?}
    J -- yes --> K[Forward exported token]
    J -- no --> L{CLAUDE_CONFIG_DIR set?}
    L -- yes --> M[Read configured .credentials.json]
    L -- no --> N[Read ~/.claude/.credentials.json]
    M --> O[Forward default token if found]
    N --> O
Loading

File-Level Changes

Change Details Files
Add per-launch selection and forwarding of named Claude credentials.
  • Introduce --claude-profile <name> through dl parsing, workspace/picker dispatch, launch construction, and the public Host builder API.
  • Resolve profiles from DEVLAUNCH_CLAUDE_PROFILES_DIR, CLAUDE_PROFILES_DIR, or ~/.claude-profiles, reading each profile’s .credentials.json without creating or deleting profile data.
  • Validate names as safe single directory components and fail explicitly for missing or invalid named profiles instead of falling back to the default credential.
  • Define precedence: opt-out first, explicit profile next, then exported token, configured Claude directory, and the default home directory.
  • Keep profile selection per launch, ignore it for verbs that forward no login, and reject it for global commands.
rust/dl/src/cli.rs
rust/dl/src/commands.rs
rust/dl/src/launch.rs
rust/devlaunch-core/src/flows/launch.rs
rust/devlaunch-core/src/clients/claude.rs
rust/aid/src/rewrite.rs
Honor relocated host Claude configuration and formalize credential resolution behavior.
  • Read $CLAUDE_CONFIG_DIR/.credentials.json as a replacement for the default ~/.claude location, while preserving exported-token and opt-out precedence.
  • Keep token extraction limited to the OAuth access token and add distinct diagnostics for profile validation and profile credential failures.
  • Add extensive unit coverage for precedence, fallback refusal, profile isolation, path validation, and secret handling.
rust/devlaunch-core/src/clients/claude.rs
CHANGELOG.md
docs/workspace-tools.md
Add external profile-root resolution and live shell completion.
  • Expose the binary-surface claude_profiles_root resolver with devlaunch override precedence and a default outside devlaunch’s cache.
  • Ensure purge and prune paths cannot reach the profiles root.
  • Complete profile names directly from the profiles directory and always include the sentinel default.
rust/devlaunch-core/src/domain/xdg.rs
rust/devlaunch-core/src/completions/dl.bash
rust/devlaunch-core/public-api.api.txt
rust/devlaunch-core/public-api.rest.txt
Document the new CLI, environment variables, account boundaries, and operational behavior.
  • Describe profile layout, setup, precedence, refusal semantics, workspace/session scope, cleanup isolation, and Remote Control distinction.
  • Document $CLAUDE_CONFIG_DIR, CLAUDE_PROFILES_DIR, and DEVLAUNCH_CLAUDE_PROFILES_DIR.
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

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

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="rust/devlaunch-core/src/flows/launch.rs" line_range="1969" />
<code_context>
+            &self.host.claude,
+        ) {
             claude::TokenLookup::Found(token) => Some(token),
             claude::TokenLookup::Missing(_) => None,
         }
</code_context>
<issue_to_address>
**issue (bug_risk):** A named profile that is missing, unreadable, or invalid is converted from `TokenLookup::Missing` to `None`, so the launch continues without forwarding the selected credential instead of stopping as the feature requires. A typo such as `--claude-profile work2` therefore silently starts a session with no token rather than refusing the launch.

**Triggers:** When the selected profile cannot yield a valid access token.

**Suggested fix:** Propagate profile-specific failures out of the launch path and abort the launch, while retaining the existing `None` behavior for ordinary unnamed-login misses and the opt-out.
</issue_to_address>

### Comment 2
<location path="rust/dl/src/cli.rs" line_range="612-615" />
<code_context>
     /// pass it once.
     #[arg(long, value_name = "VARIANT|PATH")]
     devcontainer: Option<String>,
+    /// Forward a named Claude login instead of the default one. Profiles live in
+    /// `<config>/devlaunch/claude-profiles/<name>/`. Per launch: unlike
+    /// `--devcontainer` it is not stored with the workspace, so a workspace never
+    /// forwards an account chosen weeks ago.
+    #[arg(long = "claude-profile", value_name = "NAME")]
+    claude_profile: Option<String>,
</code_context>
<issue_to_address>
**nitpick:** The `--help` description still tells users that profiles live under `<config>/devlaunch/claude-profiles/<name>/`, while the implementation now reads `~/.claude-profiles` or the configured profile-root variables. Following the built-in help sends users to a directory that this feature never reads.

**Triggers:** When a user relies on `dl --help` to create or locate a profile.

**Suggested fix:** Update the help text to describe `~/.claude-profiles/<name>` and the `CLAUDE_PROFILES_DIR`/`DEVLAUNCH_CLAUDE_PROFILES_DIR` overrides.

```suggestion
    /// Forward a named Claude login instead of the default one. Profiles live in
    /// `~/.claude-profiles/<name>` by default. Set `CLAUDE_PROFILES_DIR` or
    /// `DEVLAUNCH_CLAUDE_PROFILES_DIR` to override the profile root. Per launch:
    /// unlike `--devcontainer` it is not stored with the workspace, so a workspace never
    /// forwards an account chosen weeks ago.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread rust/devlaunch-core/src/flows/launch.rs Outdated
&self.host.claude,
) {
claude::TokenLookup::Found(token) => Some(token),
claude::TokenLookup::Missing(_) => None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): A named profile that is missing, unreadable, or invalid is converted from TokenLookup::Missing to None, so the launch continues without forwarding the selected credential instead of stopping as the feature requires. A typo such as --claude-profile work2 therefore silently starts a session with no token rather than refusing the launch.

Triggers: When the selected profile cannot yield a valid access token.

Suggested fix: Propagate profile-specific failures out of the launch path and abort the launch, while retaining the existing None behavior for ordinary unnamed-login misses and the opt-out.

Comment thread rust/dl/src/cli.rs Outdated
Comment on lines +612 to +615
/// Forward a named Claude login instead of the default one. Profiles live in
/// `<config>/devlaunch/claude-profiles/<name>/`. Per launch: unlike
/// `--devcontainer` it is not stored with the workspace, so a workspace never
/// forwards an account chosen weeks ago.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nitpick: The --help description still tells users that profiles live under <config>/devlaunch/claude-profiles/<name>/, while the implementation now reads ~/.claude-profiles or the configured profile-root variables. Following the built-in help sends users to a directory that this feature never reads.

Triggers: When a user relies on dl --help to create or locate a profile.

Suggested fix: Update the help text to describe ~/.claude-profiles/<name> and the CLAUDE_PROFILES_DIR/DEVLAUNCH_CLAUDE_PROFILES_DIR overrides.

Suggested change
/// Forward a named Claude login instead of the default one. Profiles live in
/// `<config>/devlaunch/claude-profiles/<name>/`. Per launch: unlike
/// `--devcontainer` it is not stored with the workspace, so a workspace never
/// forwards an account chosen weeks ago.
/// Forward a named Claude login instead of the default one. Profiles live in
/// `~/.claude-profiles/<name>` by default. Set `CLAUDE_PROFILES_DIR` or
/// `DEVLAUNCH_CLAUDE_PROFILES_DIR` to override the profile root. Per launch:
/// unlike `--devcontainer` it is not stored with the workspace, so a workspace never
/// forwards an account chosen weeks ago.

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.28253% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.79%. Comparing base (b2196b3) to head (da3ec2c).

Files with missing lines Patch % Lines
rust/dl/src/commands.rs 64.00% 9 Missing ⚠️
rust/devlaunch-core/src/domain/xdg.rs 85.71% 4 Missing ⚠️
rust/devlaunch-core/src/flows/launch.rs 96.85% 4 Missing ⚠️
rust/dl/src/lib.rs 33.33% 2 Missing ⚠️
rust/devlaunch-core/src/clients/claude.rs 99.54% 1 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.06% <96.28%> (+0.02%) ⬆️

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

Components Coverage Δ
shipped code (rust) 95.06% <96.28%> (+0.02%) ⬆️
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.

Review catch. `dl --help` sent readers to
`<config>/devlaunch/claude-profiles/<name>/`, the devlaunch-shaped root the
commit below it took back out in favour of the one claude-as already uses. The
README, docs/workspace-tools.md and the changelog all moved; this string did not,
so the one place a reader looks *while typing the flag* was the one place still
naming a directory nothing reads.

Following it produces a profile the resolver cannot find, which surfaces as a
refusal about a profile the user just created -- the least tractable shape this
mistake has.

Not caught by test_readme_cli_doc.py, and it is worth saying why rather than
adding a guard here: that test holds every flag `--help` offers to appearing in
the README, which is a check about flag *names*. Prose inside a help string
naming a path is a different claim and nothing checks it. A guard that diffed
this string against the README would be a third copy of the path.

`CLAUDE_PROFILES_DIR` is named too, since it is the override anyone with
existing profiles is already using, and "dl reads them and never creates one"
replaces the sentence a reader would otherwise have to infer.
@gitguardian

gitguardian Bot commented Sep 4, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@JSmithRobotics
JSmithRobotics changed the base branch from main to fix/claude-config-dir September 4, 2026 10:19
Review catch, and the one that matters: `--claude-profile typo` did not stop a
launch. It started a session forwarding no Claude token at all, and said nothing.

`clients::claude` builds `NoToken::ProfileUnreadable` and `ProfileNotAName` with
some care, and the doc comment where the first is declared calls it "the one
refusal that must **stop** a launch's forwarding rather than quietly leaving it
unforwarded". Nothing outside that module ever read a `NoToken`:
`forwarded_claude` matched `TokenLookup::Missing(_) => None` and every reason
went the same way as a host that has simply never run `claude`.

So the refusal existed as a value and as three paragraphs of documentation, and
the behaviour was the fallback those paragraphs argue against. Every test around
it passed, because they all asked `resolve_token` and none asked the launch --
which is the useful lesson here: a unit test of the thing that decides is not a
test that anybody acts on the decision.

`forwarded_claude` now returns `Result<Option<Token>, SessionRefused>`. Both call
sites resolve it *before* building an argv, so a refusal leaves devpod and ssh
unrun rather than starting a session and complaining: a session that started
would already contain an agent asking for a login.

Only a named profile refuses. `Ok(None)` stays the answer for no credential
file, a macOS keychain login, `DEVLAUNCH_NO_CLAUDE_TOKEN`, and a pass that has
not answered -- none of those named an account for this launch, and warning about
them would fire on every launch of every host that does not use Claude.

The `Foreign` check still comes first and is untouched: a container with its own
mounted Claude config forwards nothing and refuses nothing, because forwarding
the host's short-lived token over a credential that can refresh itself is wrong
whether or not a profile was named.

Three problems rather than one string, because the fix differs and only one of
them has somewhere to point: a name that could not be a directory component, a
host that resolves no profiles root at all, and a directory with no readable
credential. The third carries the **directory** and not the credential file the
client looked for, because that string is handed back inside a
`CLAUDE_CONFIG_DIR=...` and a message telling somebody to point that at a `.json`
file would be wrong.

`no_refusal_message_carries_a_credential` asserts what the messages do not say.
This is the one refusal path with a token in scope a call away, so it is worth an
assertion rather than a reading.
Fourteen rows in the tripwire file, from making the profile refusal real:
`SessionRefused` gains a `ClaudeProfile` variant and `ClaudeProfileProblem` is a
new public enum beside it.

Both are `pub` with `String` fields on purpose. The obvious shape was
`ClaudeProfile(claude::NoToken)`, carrying the client's own reason straight
through -- and `clients::claude` is `pub(crate)`, so that would have put a
crate-private type inside a public variant and produced exactly the wart the
`--claude-profiles` branch fixes one commit later: a field a caller can read and
whose type they cannot name.

Regenerated with `scripts/public-api-snapshots.sh` on nightly 1.100.0
(2026-09-03) and cargo-public-api 0.52.0, the pin the script names.
@JSmithRobotics

Copy link
Copy Markdown
Collaborator Author

Superseded by #571, which is the same branch opened from this repository rather than from a fork.

Moved because a cross-fork pull request's base has to be a branch in the base repository, so the stack needed its parents pushed here anyway -- which left three branch names living on two remotes, two of them serving as one PR's head and another's base at the same time. Every update then had to reach both remotes or a diff would quietly misrepresent itself. Same commits, one ref each, and #570 to #573 are now a native GitHub stack.

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