Skip to content

feat: bind a named Claude profile as the container's configuration, so it can refresh - #652

Open
JSmithRobotics wants to merge 5 commits into
mainfrom
feat/claude-profile-mount
Open

JSmithRobotics wants to merge 5 commits into
mainfrom
feat/claude-profile-mount

Conversation

@JSmithRobotics

Copy link
Copy Markdown
Collaborator

--claude-profile forwards a profile's access token by value
(clients::claude::resolve_token → claude::Token). That token cannot refresh:
there is nothing to refresh it with and nowhere to persist the result. A
workspace that outlives the expiry has a dead credential until some later launch
replaces it, and the session discovers that mid-run.

This binds the profile directory instead, so Claude Code gets the same files it
has on the host and refreshes exactly as it does there, with the refresh landing
back in the profile.

Why this is worth reading now rather than as a downstream preference

9a47af9 ("configure persistent Herdr workspace environments from dl") routes
straight into the forwarding path:

// flows/herdr_environment.rs
/// The name to pass on as `--claude-profile`, for the two arms that name one.
pub fn profile_name(&self) -> Option<&str> {
    Self::Profile(name) => Some(name.as_str()),
    Self::ServerDefault => Some(DEFAULT_PROFILE),

So the feature whose entire purpose is persistent panes authenticates them with
a credential that cannot outlive its expiry. The longer a herdr workspace does
its job, the likelier it is to be holding a dead token. That is upstream's bug,
not the fork's, and it arrived after the branch point.

What it binds, and what it does not

The profile directory carries the credential, and also CLAUDE.md, agents/,
skills/, hooks/, commands/. Not .mcp.json: that lives at a project root
rather than under CLAUDE_CONFIG_DIR, so binding a profile never touches it.

ClaudeProfileMount sits beside PixiCache and does the same job by the same
means: bind a host directory, point the container at it, via --mount type=bind,... and --workspace-env CLAUDE_CONFIG_DIR=....

The two flags land together or not at all. up_args composes both only when the
call actually creates or rebuilds the container, because a --mount applies only
then while --workspace-env would otherwise be re-applied alone on every up
and silently repoint CLAUDE_CONFIG_DIR at an empty directory.

The trade-off, stated rather than buried

This hands the container a refresh token. The existing design deliberately does
not: forwarding an access token by value means a compromised container holds
something that expires on its own. A bind mount means it holds something that
does not, and can mint more.

That is a real cost and I am not going to argue it away. The exchange is a
profile that survives the container, for a credential the container could abuse
for longer. Where a session is short, forwarding is the better trade and should
stay the default. Where a session is long-lived — which is precisely what
9a47af9 builds — forwarding does not work at all, and the choice is not between
two security postures but between a working credential and a dead one.

It is opt-in per launch and changes nothing for a launch that does not ask.

Provenance, and one interaction worth knowing

These five commits have run in a fork since 2026-09 against long-lived agent
sessions; this is a port onto 0.57.0, where four of the five applied with no
conflict.

The interaction: #651 reports dl announcing a --mount that devpod silently
dropped, measured through this mount. The same --mount plus --workspace-env
pair carries the pixi cache today, so that defect is not introduced here. But it
does mean a profile can be reported bound and not be, and I would rather that
were known while reviewing this than discovered afterwards.

Checks

cargo build --workspace, cargo clippy --locked --all-targets -- -D warnings
and cargo fmt --check are clean on the port. I have not run
cargo test --workspace or the Python suite: both create real devpod containers,
and the host I ported on is running workspaces I am not willing to disturb. CI is
the right place for that, and I would rather say so than let green checks here
imply more than they cover.

The public-API snapshots almost certainly need regenerating, and I could not
do it.
The mount adds public types, so those files must change; the ones here
came across with the port. Regenerating them correctly needs the pinned nightly
scripts/public-api-snapshots.sh --print-nightly names, and the host I ported on
has no rustup. CI will print the authoritative diff -- I would rather flag that
than have a reviewer discover it.

https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28

@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, your pull request is larger than the review limit of 150,000 diff characters

@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.64093% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.59%. Comparing base (11d160d) to head (0eeede4).

Files with missing lines Patch % Lines
rust/dl/src/render.rs 99.06% 2 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 94.81% <99.64%> (+0.15%) ⬆️

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

Components Coverage Δ
shipped code (rust) 94.81% <99.64%> (+0.15%) ⬆️
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.

JSmithRobotics added a commit to JSmithRobotics/devlaunch that referenced this pull request Sep 29, 2026
Both gaps the first version of this list named are now closed. The Claude
profile mount is ported to 0.57.0 and is PR blooop#652 upstream; the public-api
toolchain is fork-only by necessity rather than choice, since upstream fixed the
same nightly-drift problem by pinning a nightly and this fork needs to render
its own snapshots on a host with no rustup.

That second one is worth a sentence, because it was nearly dropped. Judged as an
upstream contribution it is redundant and was correctly refused. Judged as fork
infrastructure it is load-bearing: this fork's public surface differs from
upstream's, so regenerating snapshots is routine here, and without this branch
there is no way to do it without installing a toolchain the repo otherwise never
needs.

With these two, an integration build is a replacement for 0.49.0+fork.3 rather
than a regression from it, which it was not before.

Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
JSmithRobotics and others added 5 commits October 2, 2026 11:07
Re-derives fork commits 8f96b94 and 1fe3c99 on top of upstream 0.49.0's
current shape, ahead of the profile-mount work (plan/16). `ClaudeConfig`
gains a third arm, `Bound`, for a config directory that is exactly
CLAUDE_CONFIG_TARGET with that target itself mounted -- neither a repo's
own arrangement (`Foreign`) nor an untouched container (`Ours`), but the
most "ours" of the three since a bound profile refreshes in place.

Upstream's shape differs from what the fork's original patch assumed in
two ways that matter:

- Upstream never had `CLAUDE_CONFIG_TARGET`, `ClaudeMountFacts`,
  `target_source`, or the uid/writability probing 8f96b94's diff carries
  alongside the enum change -- those belong to the mount itself (plan/16
  step 2 and later) and are left for that step. This commit adds only
  `CLAUDE_CONFIG_TARGET` and the one probe fact (`claudetargetmounted`)
  `ClaudeConfig::parse` needs to decide `Bound`. Nothing binds anything at
  that target yet, so `Bound` is exercised only by direct unit tests
  against synthetic probe reports -- unconstructed by any real launch
  until the mount lands.
- Upstream's ownership verdict (`cfg_dir_is_foreign`) already compares
  mount roots against the container's `$HOME` rather than the config
  directory the fork's version compared against, so 1fe3c99's fix lands
  entirely in the mount *scan* (`claude_config_lines`, and its Rust twin
  `matching_mount_roots`): matching only the config directory, its
  ancestors, and the credential file by name, rather than every
  descendant. A read-only `skills` or `settings.json` mount no longer
  convicts the directory of being foreign.

`MemoWord` (the on-disk verdict cache) gains a matching `Bound` word so a
bound container's verdict survives being remembered rather than reading
back as `Unknown`. `CREDENTIALS_FILENAME` becomes `pub(crate)` in
clients/claude.rs, shared with provision.rs's scan the way it already is
in the fork.

rust/Cargo.toml version is untouched (stays 0.49.0). Public API surface
regenerated with `pixi run public-api`: adds exactly
`ClaudeConfig::Bound`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
Re-derives fork commit 9cdd364 onto v0.49.0. `--claude-profile` forwards a
profile's access token by value (`clients::claude::resolve_token`), and that
token cannot refresh: nothing to refresh with, nowhere to persist the result,
so a workspace outliving its expiry has a dead one until the next launch
replaces it. Binding the profile *directory* instead gives Claude Code the
same files it has on the host -- the credential, but also CLAUDE.md, agents/,
skills/, hooks/, commands/ -- so it refreshes exactly as it does there and the
refresh lands back in the profile. Not `.mcp.json`: that lives at a project's
root rather than under `CLAUDE_CONFIG_DIR`, so binding a profile never touches
it. That is corral's reason (plan/16 in dl-web): its workspaces are long-lived
agent sessions that would otherwise go unauthenticated part-way through a run
with no path back.

`ClaudeProfileMount`, beside `PixiCache` for the same job: bind a host
directory and point the container at it via two `devpod up` flags,
`--mount type=bind,...` and `--workspace-env CLAUDE_CONFIG_DIR=...`. `ensure`
decides from the host what a launch can bind (`NotAsked`/`NotAName`/`NoRoot`/
`Missing`/`Bound`, mirroring `PixiCache`'s shape). The two flags land together
or not at all -- `up_args`/`up_under_stage` compose both only when this call
actually creates or rebuilds the container (`Naming::Create`, or `Naming::Known`
with `Rebuild::Recreate`/`Reset`), since a `--mount` lands only then while
`--workspace-env` would otherwise be re-applied alone on every `up` and
silently repoint `CLAUDE_CONFIG_DIR` at an empty directory. `ClaudeProfileMount`
alone cannot tell any of that; it only says what the host *could* bind.

Where this diverges from replaying 9cdd364's diff, because the surfaces it
assumed have all moved:

- 9cdd364 invented `CLAUDE_CONFIG_TARGET` and decided "was this container
  created with a profile bound" from inside `ClaudeProfileMount` itself,
  by rewriting `forwarded_claude`'s early-exit in the same commit -- discovering
  the coexistence-with-forwarding question fresh. Here, e08a874 (this rebuild's
  step 1) already gave `flows::provision::ClaudeConfig` a third arm, `Bound`,
  decided from the *container's own probe* against a `CLAUDE_CONFIG_TARGET`
  that already lives in `provision.rs`, and `forwarded_claude` already matches
  `Bound` correctly (forwards nothing, warns nothing). `ClaudeProfileMount`
  reuses that constant rather than defining a second one, and this commit does
  not touch `forwarded_claude` at all -- the mount only decides what a launch
  *can* bind; whether a running container actually has it bound is the probe's
  question, already answered.
- 9cdd364 built `ProfileName`, `HostEnv::profile` and `Host::claude_profiles_root`
  as part of the same change. On this tree they already exist, added for
  upstream's own `--claude-profile` token-forward, so this commit only adds
  `clients::claude::profile_dir`, the one join both `from_profile` and
  `ClaudeProfileMount::ensure` need, kept separate rather than deduplicated
  against `from_profile`'s own path-building so as not to disturb its existing
  `ProfileNotAName`/`ProfileUnreadable` distinction.
- `has_credential` already existed on this tree (also built for the listing),
  so "a credential and not just a directory decides Bound" needed no new code,
  just the same call 9cdd364 made.

Three defects a first verifier pass caught before this landed, all fixed in
place rather than as a follow-up:

- `--claude-profile default` reached `profile_dir` unfiltered, so on a host
  with `~/.claude-profiles/default/` it silently bound that directory and the
  container ran as whichever account was in it -- the probe reports `Bound`
  either way, so nothing warned. `ClaudeProfileMount::ensure` now applies the
  same `named != DEFAULT_PROFILE` exclusion `resolve_token` and
  `profile_name_is_offerable` already apply, and `claude::DEFAULT_PROFILE` is
  `pub(crate)` so the three call sites read one constant.
- `LaunchNotice::ClaudeProfileBound` used to fire whenever `ClaudeProfileMount`
  was `Bound`, on every `up` and not only the create -- so a
  `restart --claude-profile <other>` against an existing container claimed the
  new profile "is now the container's Claude configuration" when devpod cannot
  land a `--mount` on one that already exists. It is now gated on whether this
  call actually creates or rebuilds the container, the one fact `up_under_stage`
  has that says whether a `--mount` can land at all. A restart or attach
  against an existing container that named a resolvable, credentialed profile
  instead gets the new `LaunchNotice::ClaudeProfileMountUnappliable`, which
  says only what is true regardless of what is already mounted: this call did
  not bind it, and a `recreate` is what would.
- `ClaudeProfileMount::ensure` answered `NotAName` both for a name
  `ProfileName::parse` rejects and for a host that resolved no profiles root at
  all, which told someone with a perfectly good name that their name was the
  problem. `clients::claude::profile_dir` now returns a `ProfileDirProblem`
  distinguishing the two, and `ensure` has a `NoRoot` arm alongside `NotAName`.

A second verifier pass caught three more, all in the same gate -- it was
"does this call create the container" only on the triple route, where
`resolve_triple` asks devpod `status` first; everywhere else `Naming::Create`
means only "devpod was given `--id`," which is not the same fact. Fixed in
place:

- D2-A: `Plan::Creatable` (a path or a git-URL spec) became
  `Placement::Creating` unconditionally, asking devpod nothing -- so a second
  `dl ./path --claude-profile x` against a container the first call already
  created still carried `Naming::Create` and claimed a bind that could not
  land. `Launch::place_creatable` now asks devpod `status` for this route's
  guessed workspace id, but only when a profile was actually requested --
  scoped to the one case a wrong guess here is dangerous, so every launch that
  names no profile still pays nothing extra, and the enum's own doc gained the
  narrow exception.
- D2-B: the gate read `Naming::Create` alone and ignored `request.rebuild`, so
  `dl <ws> recreate`/`reset` against a `Naming::Known` workspace -- which *is*
  rebuilt, and does land the mount -- got the inverted notice
  (`ClaudeProfileMountUnappliable`, telling the operator to run `recreate`,
  the command they had just run) and never `ClaudeProfileBound`. The gate is
  now `Naming::Create` or `request.rebuild != Reuse`.
- D3 (the worst): `up_args`' composition of the two `devpod up` flags read only
  `ClaudeProfileMount`, with no reference to `naming` or `rebuild` -- so on a
  plain `Naming::Known` `up` both flags still went out: a no-op `--mount` and
  a `--workspace-env CLAUDE_CONFIG_DIR=...` that devpod re-applies every time,
  repointing Claude Code's configuration at an empty directory with nothing
  warning about it and the notice claiming the opposite. `ClaudeProfileMount::
  up_args` now takes `creating_container: bool` and emits both flags or
  neither, so the pair can no longer land apart.

Regression tests for all three: `a_second_creatable_launch_does_not_claim_a_
mount_landed_against_an_existing_container`, `a_recreate_binds_the_profile_
even_though_naming_carries_no_id`, `a_known_workspace_up_with_reuse_emits_
neither_claude_flag`.

The prerequisite this surfaced for step 5 (the probe reporting the bound
*source path*, not just that something is bound, so a profile switch against
an existing container is detectable at all) is recorded against the plan
rather than built here -- `ClaudeConfig::Bound` deliberately carries no
payload today, and that is step 5's fact to add.

README.md, docs/workspace-tools.md: corrected -- `--claude-profile` binds a
directory now, not a forwarded token, so CLAUDE.md/agents/skills/hooks reach
the container (not `.mcp.json`, which lives at the project root instead), a
refresh lands back in the profile, and switching profiles needs a `recreate`.

rust/dl/src/cli.rs: `--claude-profile`'s `--help` text updated to match --
"bind a named Claude configuration directory" rather than the pre-commit
"forward a named Claude login."

rust/devlaunch-core/src/clients/claude.rs: add profile_dir (now returning
ProfileDirProblem), make DEFAULT_PROFILE pub(crate).
rust/devlaunch-core/src/flows/launch.rs: add ClaudeProfileMount (NotAsked/
NotAName/NoRoot/Missing/Bound), thread it through up_args and
up_under_stage, add LaunchNotice::ClaudeProfileBound and
LaunchNotice::ClaudeProfileMountUnappliable; add Launch::place_creatable and
gate the mount/env pair on creating-or-rebuilding rather than on
`Naming::Create` alone.
rust/dl/src/render.rs: render both new notices.
rust/devlaunch-core/public-api.api.txt: regenerated via `pixi run public-api`
(no change -- everything touched by this amendment is private).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
…else

A profile bound at CLAUDE_CONFIG_TARGET is mostly symlinks into a shared
instructions tree -- CLAUDE.md, agents, skills, hooks, commands -- so several
named profiles can share one set of instructions. Binding only the profile
directory (ClaudeProfileMount::up_args, step 2's 9cdd364) puts those links in
the container, where every one of them dangles: their targets are paths
outside the profile that the parent bind never reached.

Each dangling top-level link now gets a second, read-only --mount of its
resolved target at the position the link occupies, shadowing the link with a
real directory so it is never followed. This is 0fbc7ba's mechanism, and it
is still the right one: the problem it fixes is unrelated to what steps 1 and
2 changed (ClaudeConfig's third arm, the mount itself), so nothing about the
symlink layout profiles actually have has moved.

What did not replay cleanly:

- 0fbc7ba's up_args had two flags and one gate; the current up_args(bool)
  gates a pair of flags on creating_container to keep the mount/env pairing
  invariant step 2 built. The new per-link mounts are appended inside that
  same Bound-and-creating_container arm, so they inherit the pairing for
  free rather than needing a guard of their own -- confirmed by a new test,
  a_symlinked_profile_emits_no_extra_mounts_when_this_up_creates_no_container.
- CLAUDE_CONFIG_TARGET now lives once in provision.rs (step 1's e08a874) and
  is read through provision::CLAUDE_CONFIG_TARGET here, rather than being a
  local const in this module as it was at 0fbc7ba.
- 0fbc7ba's own doc comment and one of its guard names claimed a stronger
  property than the mechanism it wrote actually holds: they said no mount may
  name the whole operator ~/.claude as its source. What the code guards
  against is narrower and already sufficient -- no per-link mount ever names
  that directory, because every top-level link in a real profile resolves to
  something under it, never to it. The re-derived doc comment and tests state
  the guarantee the mechanism actually provides instead of the older, looser
  claim.
- Dropped 0fbc7ba's implication (via its `.mcp.json` mention) that this mount
  is relevant to that file. Step 2's review already established .mcp.json is
  project-root-scoped, not CLAUDE_CONFIG_DIR-scoped, and corrected the plan
  file's line making the same claim.

Tests are 0fbc7ba's five, re-scened on this tree's Scene::naming_a_claude_profile
plus a manual symlink fixture (that helper builds a plain directory, not a
profile of symlinks), each watched failing under the mutation it exists to
catch: dropping readonly, keeping an in-profile relative link, emitting links
before the parent mount, and swapping canonicalize for something that still
binds a missing target.

A verifier then found the per-link mount trusted its two interpolated values
completely and bound anything that merely existed:

- devpod's own `--mount` parser splits on `,` and cuts on `=` with
  last-occurrence-wins, so a link name or resolved path carrying either
  character could rewrite a neighbouring field of the very spec meant to
  carry it -- up to and including the bind's own `source=`. Both values are
  now validated: a name or path containing `,` or `=`, or not representable
  as UTF-8, is skipped rather than escaped, because none has a legitimate use
  here.
- Nothing capped what a link could resolve to. A link is now also refused
  when its target is neither a regular file nor a directory (a socket,
  device or FIFO), when it resolves to `/` or to an ancestor of (or the whole
  of) the profiles root -- `$HOME` included -- and past a generous 64-link
  cap.
- The extra binds were entirely unannounced: LaunchNotice::ClaudeProfileBound
  now carries every resolved extra-bind path and whether the cap left links
  out, and dl's render prints them, because a mount the operator is never
  shown was the actual defect.
- the_credential_mount_is_never_readonly asserted a property its own fixture
  could never exercise -- no argv element ever contained ".credentials.json",
  since the fixture's credential is always a real file. Proved vacuous by
  instrumenting with an `ever` flag. The rewritten test builds a profile whose
  `.credentials.json` is itself a symlink pointing outside the profile.
- Corrected two doc comments: the ordering rationale now says what actually
  makes nested binds land (docker sorts by destination depth, not argv
  order) rather than asserting a belief about it, and notes that whether a
  nested destination resolves through the dangling symlink it sits on top of
  is inherited from 0fbc7ba's shipped behaviour and unverifiable without a
  container.

New tests: a_link_name_carrying_devpods_mount_separators_is_refused_rather_than_bound,
a_shared_tree_path_containing_a_comma_is_refused_rather_than_truncated,
a_non_utf8_link_name_is_refused_rather_than_replaced, a_symlink_to_a_socket_is_refused,
a_symlink_to_the_filesystem_root_is_refused, a_symlink_to_an_ancestor_of_the_profiles_root_is_refused,
a_symlink_to_the_profiles_root_itself_is_refused, past_the_cap_extra_links_are_left_out_and_the_cap_is_announced,
a_bound_profiles_extra_binds_are_named_in_the_notice. Each was watched failing when the guard
or field it pins was reverted, and the rewritten the_credential_mount_is_never_readonly was
watched failing against the shadowing bind D3 describes.

A second verifier pass found one more confirmed defect and asked for a
different trade on the credential handling above:

- The `$HOME`/profiles-root refusal in resolve_dangling_symlink_binds was
  defeated by a symlinked profile directory. The catastrophic-target guard
  inferred the profiles root from canonicalize(profile)'s own parent, which
  is only the real root when `profile` is not itself a symlink -- a profile
  bound from `~/.claude-profiles/bear -> /srv/bear` canonicalizes to
  `/srv/bear`, whose parent is `/srv`, so the guard ended up protecting `/srv`
  instead of the real profiles root or `$HOME`. ClaudeProfileMount::Bound now
  carries the host's own claude_profiles_root alongside source, and
  resolve_dangling_symlink_binds canonicalizes that instead of re-deriving
  anything from the profile path. a_symlink_to_an_ancestor_of_the_profiles_root_is_refused
  and a_symlink_to_the_profiles_root_itself_is_refused now symlink the profile
  directory itself out of a second, unrelated tempdir, which is the shape
  that actually exercises the bug; both were watched failing against the old
  parent-derived root before the fix. The now-redundant `resolved == "/"`
  case was dropped: any profiles root is an absolute path, so it already
  starts_with `/`.
- Excluding a symlinked `.credentials.json` by name (the previous commit's
  approach, called out above) turned out to be a different, worse silent
  failure: claude::has_credential decides `Bound` with `is_file`, which
  follows symlinks, so a profile whose credential escapes the profile is
  still `Bound` -- and excluding the link left the parent bind carrying a
  dangling symlink, so `claude` found no credential and prompted for a fresh
  login while the launch notice still claimed a bound account. That entry now
  gets the same shadowing bind as any other escaping link, but read-write
  rather than read-only, since a refresh legitimately writes it in place.
  LaunchNotice::ClaudeProfileBound grew a `credential_bind` field carrying
  that path so the notice, not just the mount, says what actually happened;
  dl's render prints it as read-write, separately from the read-only
  `extra_binds` sentence.
- Added rendering tests for LaunchNotice::ClaudeProfileBound: no extra binds,
  some extra binds (read-only, every path named), the past-the-cap sentence,
  and the new credential_bind sentence (read-write). Previously nothing
  outside launch.rs's own notice-construction tests touched these two message
  branches at all.

A third verifier pass confirmed the previous two fixes as genuine and found
one more gap in the same guard, plus a fail-open path in the type that
carries it:

- The catastrophic-target check only ever tested
  root.starts_with(&resolved), which catches an ANCESTOR of the profiles
  root but not a DESCENDANT of it. A top-level link shaped like
  <root>/bear/otter -> <root>/otter resolves to a sibling profile inside the
  root, passes that check, and binds a different account's own profile
  directory -- credential included -- into the container read-only.
  resolve_dangling_symlink_binds now also refuses a resolved target that is
  inside the profiles root but not this profile's own tree
  (resolved.starts_with(&profiles_root), checked alongside the existing
  ancestor test). a_symlink_to_a_sibling_profile_is_refused pins it with a
  real sibling profile directory carrying its own .credentials.json, and was
  watched failing against the old ancestor-only check.
- Bound::profiles_root being Option<PathBuf> let is_some_and fail OPEN on
  None: with no root, the whole catastrophic check read false and nothing
  was refused. Unreachable today (ensure only reaches Bound through
  claude::profile_dir, which itself requires a root), but the invariant
  making None impossible lives two functions away from the guard, so an
  Option in the guard's own signature could silently stop guarding the
  moment anything upstream changed. resolve_dangling_symlink_binds now fails
  CLOSED instead: an absent or non-canonicalizing root refuses every
  top-level link outright rather than emitting them unchecked.
  with_no_profiles_root_known_every_escaping_link_is_refused pins the chosen
  behaviour, which nothing did before.
- Documented, rather than fixed, a third gap that needs a real container to
  settle: the escaping-credential entry binds its target as a single-FILE
  mount, and a refresh's usual temp-file-plus-rename lands on a bind mount's
  own mount point as EBUSY rather than as an ordinary rename. Recorded in
  place beside that bind as a known limitation affecting only the unusual
  symlinked-credential case; an ordinary real-file credential is unaffected.
- Corrected an overstated doc comment: LaunchNotice::ClaudeProfileBound's
  credential_bind being None does not only mean the credential is a real
  file -- it is also None when the escaping link's name or resolved path
  fails D1's separator validation, or lands past the 64-bind cap.

A fourth verifier pass found the sibling-profile refusal above stated too
broadly: it refused ANY resolved target inside the profiles root, which also
catches the shared-instructions layout this same tree documents as
legitimate -- a dot-prefixed directory under the root (`<root>/.shared/`)
that `claude::ProfileName::parse` refuses outright as a profile name, so it
can never be mistaken for another account's. The guard now refuses a target
inside the root only when its first path component under the root parses as
a `ProfileName` -- an actual sibling account -- and lets a dot-prefixed (or
otherwise non-profile-shaped) directory under the root through, matching the
rule the resolver, the glob and the completion already agree on.
a_symlink_to_a_sibling_profile_is_refused was strengthened into
a_symlink_to_a_sibling_profile_is_refused_but_a_dot_dir_under_the_root_is_not,
asserting both halves in one fixture so an over-broad fix fails it exactly
as loudly as no fix at all; watched failing both with the sibling guard
removed and with it widened back to the old any-target-in-root form.

Also corrected: the ClaudeProfileBound doc comment's list of causes for
credential_bind: None was still incomplete (missing the catastrophic-target
refusal and the pre-existing non-UTF-8 case), and it said the credential's
resolved *name* could carry `,` or `=` when the name is fixed at
CREDENTIALS_FILENAME and only the path varies. The EBUSY note's claim that
an unlink-plus-recreate would "replace the mount point with a container-local
file the host never sees" is wrong: measured in a user namespace over a file
bind mount, the unlink itself also fails with EBUSY, so there is no path that
creates such a shadow file; the note now says so. And the S1 / D-known-
limitation comment prefixes, introduced alongside the D1/D2 series in the
same function, are dropped rather than left as a second, inconsistent
numbering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
…means

Re-derives fork commits 5e3f3e4 and e5491c2 onto v0.49.0. Step 2 of this
rebuild had already made `ClaudeProfileMount::ensure` filter `default` out
before any `claude::profile_dir` join -- a stopgap so `--claude-profile
default` stopped silently binding a hand-made `<root>/default/` directory,
at the cost of `default` binding nothing at all again, same as upstream.

That stopgap is the state 5e3f3e4 assumed it was fixing (it introduced the
filter) and e5491c2 assumed it was building on (it made `default` bind
something real again) -- both already true here, so neither replays. What
this commit actually does is e5491c2's half: `default` now binds
`Host::claude_config_dir`, the unnamed login's own configuration directory
(`$CLAUDE_CONFIG_DIR`, else `$HOME/.claude`), through the same `Bound` arm
and the same `up_args` a named profile uses -- so `CLAUDE_CONFIG_DIR` is set
in the container and `CLAUDE.md`, agents, skills, hooks and commands resolve,
where binding nothing left them silently absent. It falls back to `NotAsked`
when there is no config directory or none holding a credential, which is what
keeps this additive rather than a new way to refuse or to hand a container a
logged-out login.

The current tree also differed from what e5491c2 assumed in shapes that
mattered: `claude::profile_dir` here returns `Result<PathBuf, ProfileDirProblem>`
rather than `Option<PathBuf>` (introduced between the two archived commits), and
`ClaudeProfileMount::Bound` here carries `profiles_root` for the dangling-symlink
catastrophic-target guard, which e5491c2 never had to thread through. Handling
`default` before `claude::profile_dir` is even asked sidesteps the first; passing
`host.claude_profiles_root` straight through for the second means `Bound::profiles_root`
can now legitimately be `None` even when `source` is `Some` (a host with a config
directory but no profiles root) -- `resolve_dangling_symlink_binds`'s existing
fail-closed behaviour on a `None` root already covers this correctly, so only its
comment asserting the old "`None` is unreachable" invariant needed correcting.

`Launch::place_creatable`'s D2-A round-trip guard also excluded `default` from
`profile_requested`, which was correct while `default` never bound anything and
became a live gap the moment it can: a second `dl ./path --claude-profile default`
against a container the first call already created needs the same devpod `status`
round trip a named profile gets, so the exclusion is removed and any
`--claude-profile` at all now pays it.

The single fact all three surfaces must agree on -- that a directory named
`default` under the profiles root is not a profile -- stays in one place,
`claude::profile_name_is_offerable`/`claude::DEFAULT_PROFILE`, read by the
resolver (`resolve_token`), the listing (`claude_profiles::summarise`) and now
this mount, rather than re-spelled a third time.

A review of that change found one confirmed defect and two hardening gaps,
fixed here in the same commit:

`resolve_dangling_symlink_binds` treated "no profiles root configured at all"
and "a configured root that does not yet exist" as the same fail-closed
refusal. The second is the ordinary state for `--claude-profile default` on
any host that has never made a named profile -- `xdg::claude_profiles_root`
answers `Ok($HOME/.claude-profiles)` without checking the directory exists --
so every top-level dangling symlink `default` needs (a chezmoi-managed
`CLAUDE.md`, `agents/`, `skills/`) was silently dropped, and nothing said so.
The two cases are now told apart: a root that fails to canonicalize still runs
the ancestor-of-the-bound-directory check (which needs no root at all, and
already catches `/`) and skips only the sibling-profile check it has nothing
to check against; a root that is `None` outright still fails closed as
before. The refusal is also now visible: `LaunchNotice::ClaudeProfileBound`
carries a new `extra_binds_refused` field, distinct from an empty `extra_binds`
that simply found nothing to bind.

`default`'s bind source is `Host::claude_config_dir` taken verbatim, unlike a
named profile's (always `<root>/<ProfileName>`, never operator-controlled past
the leaf), so nothing stopped `CLAUDE_CONFIG_DIR=$HOME` with a stray
`.credentials.json` from binding the whole home directory read-write. A new
`source_is_catastrophic` check refuses `/`, `$HOME` itself, and the profiles
root itself as a bind source, reaching a new `ClaudeProfileMount::UnsafeSource`
state (and `LaunchNotice::ClaudeProfileSourceUnsafe`) instead of `Bound`; the
workspace still opens with the ordinary forwarded login.

The notice for a bound `default` now says explicitly that this is the host's
primary Claude configuration rather than a sandboxed profile, and that the
bind is read-write -- the same precedent the extra-bind sentences already set.

README.md, docs/workspace-tools.md and the `--claude-profile` help text are
updated to say `default` binds (and still forwards), and to say what actually
happens when a `default` config directory holds no credential (a silent
fallback to forwarding, not a refusal) rather than the refusal the prose used
to promise for every name.

A second review found a security regression in that hardening pass, an
untested guard, a misleading test name and overstated prose, fixed here too:

`resolve_dangling_symlink_binds`'s ancestor-of-the-bound-directory check only
catches `$HOME` when the profile being bound is itself under `$HOME` --
`canonical_profile.starts_with(&resolved)` is true for `resolved == home`
only in that case. `--claude-profile default` against an operator-set
`CLAUDE_CONFIG_DIR` outside `$HOME`, with no `~/.claude-profiles` directory to
canonicalize either, passed a top-level `CLAUDE.md -> $HOME` link straight
through: neither guard could see `$HOME` at all, and the whole home directory
was mounted read-only into the container. `Host::home` is now threaded through
`ClaudeProfileMount::Bound`, `dangling_symlink_binds` and
`resolve_dangling_symlink_binds`, which gained its own `ancestor_of_home`
check run whenever `home` resolves, independent of the profiles root.

That fix would have shipped with no test pinning it, the same way the
sibling-profile check nearly did: deleting `ancestor_of_profile` from the
`catastrophic` disjunction left the whole suite green, because every existing
`/`-link test passes a root that resolves and so is actually caught by
`ancestor_of_root` instead. Three tests now isolate `ancestor_of_profile` and
`ancestor_of_home` by passing a `profiles_root` that is `Some` but does not
canonicalize (so `ancestor_of_root` and `sibling_profile` cannot fire) and
either no `home` or a `home` outside the profile's own tree: a link to `/`,
a link to the profile's own ancestor, and the `$HOME` regression above.
Perturbing the disjunction confirmed each one fails exactly the test meant to
catch it and no other.

`a_profiles_root_that_does_not_exist_still_refuses_an_escaping_link` asserted
the opposite of what its name said -- the body correctly checks that the link
IS bound, which is the whole point of telling "no root configured" and "a
root that does not exist yet" apart, but the name read as the pre-fix
behaviour. Renamed to `..._still_binds_an_escaping_link`.

README.md and docs/workspace-tools.md both promised that `--claude-profile
default` binds "`CLAUDE.md`, agents, skills and hooks" whenever there is
something "worth binding", with the escape hatch phrased as a host with
nothing in that directory. The actual gate is `claude::has_credential`, a
`.credentials.json` file, so a host with a fully populated `~/.claude` whose
login is not a credential file there gets nothing mounted and nothing said --
not what "nothing there worth binding" describes. Both are reworded to name
the real condition: a Claude login that is not a credential file in that
directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
…ffect

A bind is not a guarantee. `ClaudeConfig::Bound` (introduced by `9cdd364`,
carrying no payload) only says the target is mounted and `CLAUDE_CONFIG_DIR`
points at it -- never whether the mount is actually usable. Two things can
leave it silently inert: this container's uid cannot write into the directory
(no uid translation on a bind mount, and a devcontainer that turns off
`updateRemoteUserUID` or pins a fixed user), or -- the case the step 2
verifier flagged and this step closes -- a container created with one
profile and later launched with `--claude-profile` naming a different one,
which keeps the first mount silently because `devpod up` cannot land a new
`--mount` on a container that already exists.

Four new probe facts beside `claudedir`: `claudetargetsource` (the mount's
source at `CLAUDE_CONFIG_TARGET`, read from `/proc/self/mountinfo`'s field 4
the way `claudemounts` already reads field 4 for its own scan, with a Rust
twin -- `target_mount_source_over` -- diffed against the real awk per this
repo's standing rule on a second hand-maintained copy of a fact),
`claudewritable` (`[ -w ]`, tri-state so an unresolvable answer never reads
as a false negative), `claudeuid` (`id -u`) and `claudediruid` (`stat -c
%u`). `ClaudeMountFacts` carries them, reaching the session by the same route
`ClaudeConfig` already takes: `Pass`/`PassReport` gain a `claude_mount`
field, `Provision` gains `last_claude_mount()` (a second question rather
than a wider answer to `provision_tools`, whose signature stays frozen per
`RefCell<ClaudeObservation>` since the source string costs `Copy`.

`forwarded_claude`'s `Bound` arm no longer unconditionally forwards nothing
in silence: `claude_profile_mount_notice` checks the source mismatch first
(the more fundamental problem -- a wrong profile's own uid facts describe
the wrong container) and the uid mismatch second, naming
`LaunchNotice::ClaudeProfileMountSwitched` or `::ClaudeProfileMountUidMismatch`
when the evidence backs either up, and staying silent otherwise -- a mount
this cannot back up with evidence is not reported as broken, same as
`ClaudeConfig::parse` itself.

How this tree differs from what `a9c4c04`'s diff assumed: that commit's
`ClaudeConfig::Bound` was unreachable in practice (the mount had no
implementation yet) and its session gate read `claude_seen.get() !=
Some(ClaudeConfig::Ours)`, so `Bound` fell into the same branch as `Foreign`
and needed the mount facts to *avoid* a false `ClaudeProfileNotForwarded`.
This tree's `9cdd364`/`5e3f3e4` already special-case `Bound` to forward
nothing and warn nothing, so there was no false notice to suppress; instead
the mount facts had to be threaded in to *add* a notice where today there is
none. There is also no `Redirected` notice here: `ClaudeConfig::parse`
already reads a bind pointed at the target but re-exported elsewhere as
`Ours` (a change made between fork versions after `a9c4c04`), which falls
through to ordinary profile-aware token forwarding rather than silence, so
that case needed no new notice.

Left as a documented gap rather than solved: `provision()`'s `TopUp`
fast-path trusts a cached verdict (and skips the live probe entirely) once
`verdicts.trusted` and `has_claude_memo` agree, and that check does not
currently invalidate on a changed `--claude-profile` name -- so the switched-
profile detection above only fires on a launch that actually pays a live
pass (a create, an `AfterUp`, or a cache miss), not on every restart that
names a different profile. The fact is reported wherever a live pass runs;
making every profile change force one is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
@JSmithRobotics
JSmithRobotics force-pushed the feat/claude-profile-mount branch from 473393e to 0eeede4 Compare October 2, 2026 10:32

This branch has not been deployed

No deployments
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