feat: --claude-profile forwards a named Claude login - #566
feat: --claude-profile forwards a named Claude login#566JSmithRobotics wants to merge 6 commits into
Conversation
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.
Reviewer's GuideAdds 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 credentialsequenceDiagram
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
Flow diagram for Claude profile resolution precedenceflowchart 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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| &self.host.claude, | ||
| ) { | ||
| claude::TokenLookup::Found(token) => Some(token), | ||
| claude::TokenLookup::Missing(_) => None, |
There was a problem hiding this comment.
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.
| /// 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. |
There was a problem hiding this comment.
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.
| /// 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 Report❌ Patch coverage is Additional details and impacted files
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
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.
️✅ 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. 🦉 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. |
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.
e0c614e to
da3ec2c
Compare
|
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. |
dl --claude-profile <name>forwards a named Claude login instead of the defaultone, 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 whereverCLAUDE_PROFILES_DIRpoints, each holding the.credentials.jsonaclaudelogin writes and each a
CLAUDE_CONFIG_DIRof its own, which is what makes thelogins 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_DIRstill wins overCLAUDE_PROFILES_DIR, so ascratch 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 ../../etcis refused by the rule rather than becoming atraversal that fails later on a read. Empty, leading
-, leading.(whichsubsumes
.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 aprofile was typed on this command line for this launch and nothing ambient should
beat an explicit argument.
DEVLAUNCH_NO_CLAUDE_TOKENstill comes first: a machinethat has opted out has no account to choose.
--claude-profile defaultresolves the login you would get anyway and neverconsults a
default/directory, so a picker has something to select and a recalledline has a way to say "not the profile I used last time".
What it deliberately does not reach
--devcontainer, so no workspace canquietly forward an account chosen weeks ago. It resolves per session.
--purgeand--prunewalk devlaunch's cache, so a login was never in reach ofeither. There is a test asserting the profiles root is unreachable from both.
claudeis paired to for RemoteControl. Two credentials, and this moves one of them.
Foreignforwards nothing, profileor no profile.
aidpasses the flag through. A verb that forwards no login says it is ignoring it,as
--devcontainerdoes; a global command refuses it. Completion offers theprofiles that exist plus
default, read off the disk rather than the completioncache, because a profile made a minute ago has to complete now.
Public surface
flows::launch::Host::with_claude_profileis a promised row: it is a buildermethod on a type
apire-exports, so its signature is a contract.domain::xdg::claude_profiles_rootis binary surface. The third commit carries theregenerated 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:
--claude-profile <name>to forward credentials from named host Claude profiles for workspace launches andaidsessions.defaultselection.Bug Fixes:
Enhancements:
DEVLAUNCH_CLAUDE_PROFILES_DIRandCLAUDE_PROFILES_DIR, with command-line profiles taking precedence over ambient tokens.Documentation:
aid, and their interaction with workspace storage and cleanup.Tests: