feat: bind a named Claude profile as the container's configuration, so it can refresh - #652
Open
JSmithRobotics wants to merge 5 commits into
Open
JSmithRobotics wants to merge 5 commits into
JSmithRobotics wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Sorry @JSmithRobotics, your pull request is larger than the review limit of 150,000 diff characters
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:
|
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
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
force-pushed
the
feat/claude-profile-mount
branch
from
October 2, 2026 10:32
473393e to
0eeede4
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
--claude-profileforwards 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") routesstraight into the forwarding path:
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 rootrather than under
CLAUDE_CONFIG_DIR, so binding a profile never touches it.ClaudeProfileMountsits besidePixiCacheand does the same job by the samemeans: 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_argscomposes both only when thecall actually creates or rebuilds the container, because a
--mountapplies onlythen while
--workspace-envwould otherwise be re-applied alone on everyupand silently repoint
CLAUDE_CONFIG_DIRat 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
9a47af9builds — forwarding does not work at all, and the choice is not betweentwo 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
dlannouncing a--mountthat devpod silentlydropped, measured through this mount. The same
--mountplus--workspace-envpair 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 warningsand
cargo fmt --checkare clean on the port. I have not runcargo test --workspaceor 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-nightlynames, and the host I ported onhas 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