Skip to content

Commit 0774aa9

Browse files
MaceWinduclaude
andauthored
add hostProject support for Windows-resident worktrees (#20)
* add hostProject schema and validation Introduces a `type` discriminator on projects[] with two variants: `distro` (existing default — bare mirror inside the distro) and `host` (new — Windows- side checkout, sessions live on the host and mount into the distro). The host variant adds `hostCheckout` (required) and `hostShadows` (list of host tools to wrap via a per-session PATH shim). Schema validation rejects cross-type field usage (no `remote` on host, no `hostCheckout`/`hostShadows` on distro) and disallows the global-scoped `hostTools` block under a hostProject — wrappers must go through `hostShadows` so they land in a per-project bin dir instead of /usr/local/bin (which would shadow distro-installed tools for parallel distroProject sessions). This commit is scaffolding only: the new fields are validated but not yet wired into Projects/Sessions/Mounts. Subsequent commits add the resolver, the worktree + mount lifecycle, and the entry-point wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * add host-shadow resolver and per-project bin-dir helpers HostShadows.psm1 takes a shadow name (`pwsh`, `git`, ...) and returns the concrete Windows .exe to wrap into the distro. Resolution order is PATH-first (via `where.exe`) so the user's day-to-day install wins, with a built-in catalog of well-known install paths as a backup for headless / pruned-PATH environments. A mismatch between PATH and catalog yields a warning so users can pin via the explicit `{ name, windowsExe }` form. Install-HostShadowsForProject writes the resolved wrappers into a per-project bin dir at /home/claude/host-projects/<project>/bin/, which open-claudearium prepends to PATH only for sessions of that hostProject. This keeps the host wrappers strictly scoped to their project — distro-installed `git`/`pwsh` remain visible to every other session. Also tightens the test-description regex in pure/Gotchas.Tests.ps1: the original `[^'"]*` class blinded the scanner to a `<word>` placeholder that sat after a nested opposite-quote (`It "... '<name>.exe'"`). I stepped on this writing the new HostShadows tests, so the fix lands in the same commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wire hostProject through project / session / mount lifecycle End-to-end plumbing for the new hostProject type: * `project add -HostProject -HostCheckout <path>` registers a host-resident project (no bare mirror, no clone), resolves hostShadows via where.exe with built-in fallbacks, and installs wrappers into a per-project bin dir at /home/claude/host-projects/<project>/bin/. * `session new` and `session remove` branch on the profile-recorded project type. For hostProjects the worktree is created on the Windows side at `<hostCheckout>-sessions\<name>` and auto-mounted into the distro at `/host/<project>/<name>` via Mounts.Get-MergedDesiredMounts (profile.hostMounts ∪ session mounts). * `project remove` for a hostProject tears down every session (host worktrees + mounts) and the per-project bin dir, but leaves hostCheckout itself untouched. * `open-claudearium.ps1` sources a per-project init.sh that prepends the bin dir to PATH. The init.sh lives at /home/claude/host-projects/<project>/init.sh and is read by bash from disk — avoiding wsl2-gotchas.md #1, where putting `$PATH` in the wsl.exe argv would mangle it to empty and break every host session. Parallel distroProject sessions are entirely unaffected: PATH overrides are bash-local to the host session, and the distro's /usr/bin tools stay authoritative for every other shell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * document hostProject support * Cookbook recipe: "Work on a Windows-specific project as a hostProject", including the dogfood case (using Claudearium itself as a hostProject). * usage.md: project add gets a -HostProject branch with the example layout; session new/remove get the type-aware behavior described. * design-decisions.md §22: rationale for per-session PATH shadowing (no global hostTools cross-talk between hostProject and distroProject sessions sharing the same distro). * wsl2-gotchas.md #20: routing PATH prepend through a per-project init.sh sourced by the launcher, after we hit gotcha #1 again with literal `$PATH` in the wsl.exe argv. * host-tool-notes/pwsh.md + git.md: per-tool argv / path-translation caveats Claude will see when working in a hostProject session. * README: one-line mention under features. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * add hostProject end-to-end distro test Covers the full hostProject lifecycle against the ephemeral test distro: * `project add` records `type=host`, `hostCheckout`, and `hostShadows` in the profile, deploys the per-project bin dir + init.sh, and the init.sh carries a literal `:$PATH` (verifying gotcha #20's fix survives a real base64-transport round-trip into the distro). * `session new` creates the sibling host worktree at `<checkout>-sessions/<name>` and registers an fstab managed-block entry for `/host/<project>/<session>`; the seed file is readable through the mount. * `session remove -Force` tears down the worktree and the mount while leaving the project-scoped bin dir intact for any other sessions. * `project remove -Force` finally drops the bin dir, but the user's hostCheckout itself is never touched. The test stands up a real one-commit git checkout in `%TEMP%` and uses that as `hostCheckout`, so it exercises the same git worktree path the production code uses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix reconcile + mount verbs to handle hostProjects without trampling Three issues surfaced by code review and the first distro-lane run: 1. reconcile crashed on any profile containing a hostProject. Get-ProjectsActualFromDistro only enumerated bare mirrors under /home/claude/mirrors/, so every hostProject diffed as 'add' and Invoke-ProjectsApply unconditionally called New-ProjectMirror -Remote (which is empty for hostProjects). Both sides now branch on type: the actual list also enumerates /home/claude/host-projects/<name>/, and the apply path dispatches to Invoke-HostProjectApply / Remove-HostShadowsForProject. 2. mount add/remove/sync called Set-HostMountsInDistro directly with just profile.hostMounts, which wiped session-derived fstab entries — any running hostProject session would lose its mount. They now route through Invoke-MergedMountsApply (profile ∪ session-derived). Same fix is applied to reconcile's mountsDiff computation. 3. Invoke-ProjectAdd's hostProject branch crashed when the host checkout had no `origin` remote: Resolve-SmartProjectName has a mandatory non-empty parameter and the unconditional `-Remote (Resolve-SmartRemote ...)` pass- through fed it $null. Guarded. Bonus: Invoke-SessionNew's hostProject path now wraps the worktree + state + mount + shadow sequence in try/finally, rolling back the host worktree and state if a downstream step throws. Distro test: HostProjects.Tests.ps1 had a literal `$PATH` inside a double-quoted It description that crashed Pester discovery under StrictMode (same trap as the gotchas regex catches) — switched to single quotes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fall back to --detach when a hostProject session would collide with another worktree Every hostCheckout is itself a worktree, so `git worktree add ... <branch>` for a session refuses with exit 128 ("'branch' is already used by worktree at ...") whenever the user's main checkout sits on the same branch they asked the session to use. CI distro lane surfaced this on the first end-to-end run. New-HostSession now reads `git worktree list --porcelain` and detects when the requested branch is already claimed by any existing worktree. On collision it adds `--detach` to the worktree-add invocation and notes the fallback in the dashboard output — the session lands at the branch tip in detached HEAD, and the user can `git switch -c <name>` inside if they want to start committing. Distroguarded with `-NewBranch`: passing that flag still creates a fresh branch off `-BaseBranch` as before. The end-to-end test now exercises both paths: the existing-branch case (which hits the auto-detach fallback because the seed checkout is on master) and a `-NewBranch -BaseBranch master` case that verifies the fresh-branch happy path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix host-session lookup that throws "Index outside bounds" under StrictMode The HostProjects distro test ("tears down the worktree and the mount, leaving the bin dir intact") fails on the `session remove` path with "Index was outside the bounds of the array." Root cause is that Get-Sessions returns `,$all` to preserve array shape across the function boundary, but Remove-HostSession then does: $session = @(Get-Sessions ... | Where-Object { ... })[0] Piping the comma-wrapped result without parens delivers the entire array as a single `$_` to Where-Object. The filter's `.name` test then runs against the array (not its elements), the predicate fails for any real session, `@()` collects nothing, and `[0]` on the empty result throws under StrictMode. Same root cause hits two more call sites: - Invoke-ProjectRemove iterated over @(Get-Sessions ...) — the bare function call wrapped in @() ends up as a 1-element array containing the inner sessions array, so the foreach binds `$s` to the whole array and `[string]$s.name` broadcasts member-access enumeration into a space-joined string of every session name. The cascade delete then called Remove-HostSession with that joined-string name, which threw before any session was actually torn down. - open-claudearium.ps1 dashboard built its session table the same way — `$sessions.Count` was always 1 when any sessions existed, and the loop displayed broadcast-joined `.project` / `.name` / `.branch` fields instead of one row per session. All three sites now read Get-Sessions into a plain variable and iterate explicitly. Test-SessionExists is unchanged: it pipes through parens (`(Get-Sessions ...) | Where ...`), which forces the wrapper to unroll before reaching the predicate, so it was already correct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ea40fa4 commit 0774aa9

20 files changed

Lines changed: 1931 additions & 88 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ A PowerShell-managed WSL2 sandbox for running [Claude Code](https://docs.claude.
1010
- **Optional:** all sandbox traffic routed through a user-supplied WireGuard tunnel, with an nftables killswitch that drops anything not going through the VPN — **except** host-subnet traffic, so local services like a host-side Seq instance remain reachable.
1111
- **Optional:** Windows-only utilities (e.g. Claudelk for BLE LED strips) reachable from inside the sandbox via WSL's Windows-interop bridge — no `usbipd` passthrough required, no host-side listener daemon needed.
1212
- **Optional:** already-authenticated host CLIs (`gh`, `glab`, `acli`, `seqcli`) attachable as drop-in wrappers so you keep your Windows-side browser OAuth instead of re-authenticating inside WSL.
13+
- **Optional:** Windows-specific repos (PowerShell, .NET-on-Windows — including Claudearium itself) registerable as **hostProjects**, with sessions backed by host-side `git worktree` paths and a per-session PATH that exposes host `pwsh` / `git` without disturbing parallel distroProject sessions.
1314
- **Project-agnostic:** the same script bootstraps sandboxes for any project. Project layout, repo URL, mounts, tools, and Claude Code settings are all per-project configuration in a single declarative profile file.
1415

1516
## What this is *not*

claudearium.ps1

Lines changed: 338 additions & 39 deletions
Large diffs are not rendered by default.

docs/cookbook.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,71 @@ The cwd is auto-translated by WSL interop, so `gh pr view` from a `cd`-ed repo j
165165

166166
**Claude sees the gotcha automatically.** If you have `profile.claudeFile` set (caveman-lite / host-copy / custom-path), the attach also writes `~/.claude/host-tools/gh.md` with the full recipe and appends a one-line caveat block to `~/.claude/CLAUDE.md`. So Claude in WSL knows from the first session: "argv paths need `wslpath -w`; see the per-tool file for details."
167167

168+
## Work on a Windows-specific project (e.g. Claudearium itself) as a hostProject
169+
170+
Some repos can't be developed entirely from inside the distro — their tests
171+
need PowerShell on Windows, or they invoke `wsl.exe`, or they target
172+
.NET-on-Windows. Claudearium handles these as **hostProjects**: the
173+
checkout lives on Windows, sessions are host-side `git worktree add` paths
174+
mounted into the distro, and selected host tools (`pwsh`, `git`) are
175+
exposed in the session via a per-project bin dir on `PATH`. Claude Code
176+
edits files in the mount, but `pwsh -File .\test-foo.ps1` actually runs on
177+
the host.
178+
179+
```powershell
180+
# 1. Register Claudearium itself as a hostProject.
181+
.\claudearium.ps1 project add Claudearium `
182+
-HostProject `
183+
-HostCheckout C:\GitHub\Claudearium `
184+
-HostShadows pwsh,git
185+
186+
# 2. Create a session. The worktree appears at C:\GitHub\Claudearium-sessions\dev
187+
# on the host, and at /host/Claudearium/dev inside the distro.
188+
.\claudearium.ps1 session new dev -Project Claudearium -Branch master
189+
190+
# 3. Launch.
191+
.\open-claudearium.ps1 -Project Claudearium -Session dev
192+
```
193+
194+
Inside the new tab:
195+
196+
```bash
197+
pwd # /host/Claudearium/dev
198+
which pwsh # /home/claude/host-projects/Claudearium/bin/pwsh
199+
which git # /home/claude/host-projects/Claudearium/bin/git
200+
echo "$PATH" | tr ':' '\n' | head # bin dir first, then the usual /usr/local/bin, /usr/bin, ...
201+
202+
# Run the host-side test suite without leaving the session:
203+
pwsh -File ./test-claudearium.ps1 -Auto -Only pure -CI
204+
```
205+
206+
A parallel distroProject session opened in another wt tab still resolves
207+
`pwsh` and `git` to the distro's `/usr/bin` copies — the bin-dir prepend
208+
is bash-local to the host session.
209+
210+
Cleanup:
211+
212+
```powershell
213+
.\claudearium.ps1 session remove dev -Project Claudearium
214+
# C:\GitHub\Claudearium-sessions\dev is gone; the mount is gone.
215+
216+
.\claudearium.ps1 project remove Claudearium -Force
217+
# Per-project bin dir is gone. C:\GitHub\Claudearium itself is untouched.
218+
```
219+
220+
A few practical notes:
221+
222+
- The session's working directory in the distro maps to a path on `/mnt/c`-style
223+
drvfs storage. Git operations are slower than against a Linux-native worktree.
224+
For Claudearium that's fine — the repo is small.
225+
- The `hostShadows` PATH prepend doesn't auto-translate path arguments.
226+
When passing `.ps1` paths to host `pwsh`, the cwd is auto-translated by
227+
WSL interop (so relative paths just work), but absolute Linux paths to
228+
files need `wslpath -w` first. See `templates/host-tool-notes/pwsh.md`.
229+
- Don't list a shadow in `hostShadows` AND install the same tool inside the
230+
distro via `tools.<name>` — the per-session bin dir wins on PATH for that
231+
session, so you'd silently never use the distro copy. Pick one home.
232+
168233
## Stay current with the latest release
169234

170235
```powershell

docs/design-decisions.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,3 +456,66 @@ limits the notes set to tools claudearium itself opts into
456456
host-attach via `HostExeNames`. The pure test `has a shipped template
457457
for every catalog tool that opts in to host-attach` keeps the templates
458458
in lockstep with the catalog automatically.
459+
460+
## 22. hostProjects: host-side worktrees + per-session PATH shadowing
461+
462+
**Decision:** project entries carry a `type` field. `distroProject`
463+
(default) keeps the existing bare-mirror-inside-the-distro model.
464+
`hostProject` is a Windows-resident variant: the project owns no mirror
465+
inside the distro; sessions are `git worktree add` paths created on the
466+
host at `<hostCheckout>-sessions\<session>` and auto-mounted into the
467+
distro at `/host/<project>/<session>` via the fstab managed block.
468+
Host tools the project needs (`pwsh`, `git`, ...) are wrapped into a
469+
per-project bin dir at `/home/claude/host-projects/<project>/bin/`,
470+
which open-claudearium prepends to `PATH` only when launching sessions
471+
of that hostProject.
472+
473+
**Why a new project type rather than reusing `hostMounts` + global
474+
`hostTools`:** the user originally had to choose between
475+
476+
1. **Distro-resident worktree, mount the host checkout read-write.** Edits
477+
work, but tests still have to invoke host PowerShell somehow, and the
478+
global `hostTools` wrappers live in `/usr/local/bin` — they'd be on
479+
PATH for every other distroProject session too, silently shadowing the
480+
distro's `git` / `pwsh`. That's the conflict mode we promised to avoid.
481+
2. **Just run Claude Code on Windows.** Loses the session model, the
482+
killswitch, the central dashboard, the per-project claudeSettings —
483+
everything claudearium gives you for free.
484+
485+
The new `hostProject` type lets the same distro host both kinds of
486+
projects in parallel. Per-session PATH shadowing (option 1's failure mode
487+
inverted) is the key invariant: distro-installed `git`/`pwsh` remain
488+
authoritative for every shell *except* sessions belonging to a
489+
hostProject that declared them as `hostShadows`.
490+
491+
**Why per-project (not global, not per-session) bin dirs:** global would
492+
cross-talk into other projects; per-session would mean N copies of the
493+
same wrapper for one project's N parallel sessions, plus a setup tax on
494+
every session-create. Per-project is the natural scope — every session of
495+
project X wants the same shadows — and the bin dir is wiped + rewritten
496+
on `project add` / `reconcile`, so a profile edit (add/remove a shadow)
497+
costs O(1) regardless of how many sessions of that project exist.
498+
499+
**Why PATH prepend lives in a per-project `init.sh` sourced by the
500+
launcher, not in the wsl.exe argv directly:** `wsl.exe ... -- bash -lc
501+
"export PATH=<bin>:$PATH; exec claude"` looks like the obvious form, but
502+
gotcha #20 (variant of gotcha #1) silently mangles the literal `$PATH`
503+
to an empty string before bash sees it. Routing the prepend through a
504+
file bash reads from disk sidesteps the mangling entirely. Same
505+
philosophy as `Invoke-InDistroScript`'s base64 transport.
506+
507+
**Why a `git worktree add` sibling to `hostCheckout` rather than under
508+
`%LOCALAPPDATA%`:** the user asked for proximity ("will it work nice
509+
with claude project permissions?"). Sibling worktrees show up in
510+
Explorer next to the main checkout, are obvious to clean up by hand if
511+
needed, and inherit the same Claude Code trust-prompt semantics as
512+
distroProject sessions (each session worktree is a distinct directory
513+
from Claude's POV, but profile-level `claudeSettings.permissions`
514+
propagates uniformly).
515+
516+
**Why `hostTools` (the global block) is rejected at the project level
517+
for hostProjects:** Test-Profile refuses a hostProject entry that
518+
carries `hostTools: [...]`. The intent of `hostTools` is global
519+
wrappers in `/usr/local/bin` — exactly the cross-talk vector we're
520+
designing around. `hostShadows` is the project-scoped replacement.
521+
Failing fast keeps users from accidentally building the conflict mode.

docs/usage.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,46 +52,62 @@ Reads the profile, diffs it against the recorded state, prints the diff, and pro
5252

5353
## `project <subverb?>`
5454

55+
Projects come in two flavors. **distroProjects** (the default) clone a bare mirror inside the distro and run all git work in Linux; sessions are distro-side worktrees. **hostProjects** (`-HostProject`) skip the mirror entirely — the user's Windows checkout is the source of truth, sessions are host-side `git worktree add` paths mounted into the distro, and a per-project bin dir on the session's `PATH` makes selected host tools (`pwsh`, `git`, …) callable as bare commands. Use hostProjects for Windows-specific repos (PowerShell, .NET-on-Windows) where tests have to run on the host anyway.
56+
5557
**`project`** (no subverb) — interactive dashboard listing projects with row-actions (`+` add, `s <n>` show, `d <n>` remove, `q` quit).
5658

57-
**`project add [<name>]`** — adds a project to the profile and clones its bare mirror inside the distro. Smart defaults pull from `-HostCheckout`'s `origin` URL (or the current working directory if it's a git checkout). Falls back to `master` for the default branch. The repo name is derived from the URL's last path segment.
59+
**`project add [<name>]`** — adds a project to the profile.
5860

5961
```powershell
60-
# Auto-detect from the host checkout (recommended; the wizard's defaults are pre-filled).
62+
# distroProject: auto-detect remote/branch from a host checkout, clone a bare mirror.
6163
.\claudearium.ps1 project add -HostCheckout C:\src
6264
63-
# Fully scripted.
65+
# distroProject: fully scripted.
6466
.\claudearium.ps1 project add acme `
6567
-Remote git@gitlab.example.com:acme/acme.git `
6668
-DefaultBranch master `
6769
-NonInteractive
70+
71+
# hostProject: register C:\GitHub\Claudearium directly; sessions live on the host.
72+
.\claudearium.ps1 project add Claudearium `
73+
-HostProject `
74+
-HostCheckout C:\GitHub\Claudearium `
75+
-HostShadows pwsh,git
6876
```
6977

78+
Smart defaults pull from `-HostCheckout`'s `origin` URL (or the current working directory if it's a git checkout). distroProjects fall back to `master` for the default branch. The repo name is derived from the URL's last path segment.
79+
80+
`-HostShadows` accepts a list of names known to the built-in catalog (currently `pwsh`, `git`) — each resolved via `where.exe` first, then well-known install paths. To pin an exact binary, use the explicit form in the profile: `hostShadows: [{ name: "pwsh", windowsExe: "C:\\Custom\\pwsh.exe" }]`. The default when `-HostProject` is passed without `-HostShadows` is `pwsh,git`.
81+
7082
**`project list`** — table of projects with profile-vs-materialized status. Useful for noticing drift (mirror present but not in profile, or vice-versa — both nudge you toward `reconcile`).
7183

7284
**`project show <name>`** — detailed view of one project, including any sessions tracked against it.
7385

74-
**`project remove <name>`** — deletes bare mirror, every session of this project, and the profile entry. Asks for confirmation unless `-Force`.
86+
**`project remove <name>`** — deletes the bare mirror (distroProject) or the per-project bin dir (hostProject), every session of the project, and the profile entry. For hostProjects, the `hostCheckout` itself is **never** deleted. Asks for confirmation unless `-Force`.
7587

7688
## `session <subverb?>`
7789

7890
**`session`** (no subverb) — interactive dashboard of all sessions across all projects (filter with `-Project`). Row-actions: `d <n>` remove, `q` quit.
7991

80-
**`session new <name>`** — creates a git worktree under the project's bare mirror.
92+
**`session new <name>`** — creates a git worktree. The wiring depends on the project's type (recorded in the profile):
93+
94+
- **distroProject**`git worktree add` runs inside the distro off the project's bare mirror. The worktree lives at `/home/claude/projects/<project>/sessions/<session>` and shares the mirror at `/home/claude/mirrors/<project>.git` with every other session. Subsequent `git fetch`es from any session populate the mirror once for all of them.
95+
- **hostProject**`git worktree add` runs on the Windows side against the project's `hostCheckout`. The worktree lands at `<hostCheckout>-sessions\<session>` (e.g. `C:\GitHub\Claudearium-sessions\dev`). The distro auto-mounts that Windows path at `/host/<project>/<session>` via the fstab managed block, and `open-claudearium.ps1` opens the session with that mount as the working directory.
8196

8297
```powershell
83-
# Check out an existing branch:
98+
# distroProject: existing branch
8499
.\claudearium.ps1 session new mainline -Project Claudelk -Branch master
85100
86-
# Create a new branch off the project's default branch:
101+
# distroProject: new branch off master
87102
.\claudearium.ps1 session new feat-1234 -Project acme -Branch feature/PROJ-1234-some-feature -NewBranch -BaseBranch master
88-
```
89103
90-
Sessions live at `/home/claude/projects/<project>/sessions/<session>` inside the distro and share the bare mirror at `/home/claude/mirrors/<project>.git` with every other session of the same project. Subsequent `git fetch`es from any session populate the mirror once for all of them.
104+
# hostProject: existing branch (the worktree shows up on Windows at C:\GitHub\Claudearium-sessions\dev)
105+
.\claudearium.ps1 session new dev -Project Claudearium -Branch master
106+
```
91107

92108
**`session list [-Project <p>]`** — table with project / session / branch / dirty state / created-at.
93109

94-
**`session remove <name> -Project <p>`** — removes the worktree (and prunes the bare mirror's worktree metadata). Refuses if the worktree has uncommitted files unless `-Force`.
110+
**`session remove <name> -Project <p>`** — removes the worktree (and prunes the bare mirror's worktree metadata for distroProjects, or unmounts + removes the host worktree for hostProjects). Refuses if there are uncommitted files unless `-Force`.
95111

96112
See [sessions.md](./sessions.md) for the parallel-sessions deep dive (model, isolation table, launcher, `wt` integration).
97113

docs/wsl2-gotchas.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,45 @@ A pure regression test in `tests/pure/Profile.Tests.ps1` exercises the
528528

529529
---
530530

531+
## 20. Putting `$PATH` in the `wsl.exe -- bash -lc <cmd>` argv makes PATH empty
532+
533+
**Symptom:** A hostProject session's `open-claudearium.ps1` tab launches but
534+
`claude` can't be found, or any tool invocation fails with "command not
535+
found." Tracing shows `echo "$PATH"` inside the new shell prints just
536+
`/home/claude/host-projects/<project>/bin:` — no `/usr/bin`, no `/bin`, no
537+
anything else.
538+
539+
**Cause:** This is gotcha #1 striking again under a new disguise. The session
540+
launcher built the bash command as a single argv string —
541+
`export PATH='/home/claude/host-projects/<p>/bin':$PATH; exec claude` — then
542+
passed it to `wsl.exe -d <distro> -u claude --cd <wt> -- bash -lc <cmd>`.
543+
The `$PATH` substring goes through the same `pwsh → wsl.exe → WSL VM → bash`
544+
argv chain as gotcha #1, so it is silently pre-expanded to an empty Windows
545+
environment variable before bash ever reads it. Result: the prepend wins
546+
against an empty tail, and the entire system PATH disappears.
547+
548+
**Fix as applied:** the PATH prepend lives in a per-project file inside the
549+
distro, written by `Install-HostShadowsForProject`:
550+
551+
```bash
552+
# /home/claude/host-projects/<project>/init.sh
553+
export PATH='/home/claude/host-projects/<project>/bin':$PATH
554+
```
555+
556+
`open-claudearium.ps1`'s `Resolve-SessionBashCommand` returns a string that
557+
*sources* that file: `source '/home/claude/host-projects/<p>/init.sh'; exec
558+
claude`. No `$VAR` in the wsl.exe argv. Bash reads the script from disk —
559+
where `$PATH` is just text bytes — so the prepend composes correctly with
560+
whatever PATH the login shell set up. Same fix philosophy as gotcha #1:
561+
keep argv pure-ASCII and route the dynamic content through a file/pipe.
562+
563+
A regression test in `tests/pure/HostShadows.Tests.ps1` pins
564+
`Get-HostShadowInitScriptPath` to the on-disk path the launcher expects;
565+
the distro lane (`tests/distro/HostProjects.Tests.ps1`) end-to-end verifies
566+
PATH contains both the bin dir and `/usr/bin` after a host-session open.
567+
568+
---
569+
531570
## Quick-reference table
532571

533572
| If you see... | Look at gotcha |
@@ -551,3 +590,4 @@ A pure regression test in `tests/pure/Profile.Tests.ps1` exercises the
551590
| `host.internal` resets to nothing on reboot | [#6](#6-wsls-network-generatehosts--true-overwrites-etchosts-at-boot) |
552591
| `setup -Name foo` wipes the wrong distro | [#18](#18-psboundparameters-inside-a-function-is-the-functions-bound-params-not-the-scripts) |
553592
| `Could not find a part of the path` from `New-Item` on a `[bracket]` dir | [#19](#19-new-item--itemtype-directory--path-dir-interprets-wildcards-in-the-directory-name) |
593+
| Host session opens with PATH = just the bin dir (no /usr/bin) | [#20](#20-putting-path-in-the-wslexe----bash-lc-cmd-argv-makes-path-empty) |

0 commit comments

Comments
 (0)