Skip to content

fix(cli): cross-platform config and locales paths, and a bash-path test repair - #168

Merged
amirlehmam merged 3 commits into
amirlehmam:masterfrom
stlindenau:fix/cli-path-fallbacks
Aug 14, 2026
Merged

fix(cli): cross-platform config and locales paths, and a bash-path test repair#168
amirlehmam merged 3 commits into
amirlehmam:masterfrom
stlindenau:fix/cli-path-fallbacks

Conversation

@stlindenau

Copy link
Copy Markdown
Contributor

First of the three splits of #166, in the order you asked for.

Three commits, src/cli/wmux.ts and tests/ only. No resources/ artifact is touched — see the note at the bottom, that turned out to be its own story.

What is here

wmux config path asks the instance. It built the path from the caller's own home directory, which only holds when the CLI runs on the same Windows box as wmux. From WSL or a container over the bridge it printed /home/vscode\.wmux\config.toml — not the file wmux reads, and not a well-formed path on either OS. loadUserConfig() already records the real path and config.get returns it, so ask; the local guess stays as the no-instance answer.

Both no-instance fallbacks are spelled for the OS in hand. config path's remaining guess and locales path, which has no instance-side answer to ask for at all, both hardcoded backslashes. The separator now comes from the home directory that was found rather than from process.platform, because the interesting case is exactly where those two disagree: a POSIX $HOME gets path.posix.join, everything else gets path.join. $HOME is now preferred over USERPROFILE — under WSL both are set and USERPROFILE is the Windows one arriving over interop.

locales path can still name the wrong directory across the WSL boundary. Fixing that properly needs a locales.get reporting the loaded directory the way config.get reports path; out of scope here.

The bash-path helper and its test. bashPathCandidates() offered /c, /mnt/c, /cygdrive/c in a fixed order; in a Linux checkout the bash on PATH mounts a Windows drive at /mnt/c if it sees one at all, so leading with /c put the fallback branch in charge. The order is now conditional on process.platform, the set is unchanged, and the probe still decides. orchestration-status-vocab.test.ts gains a bashExists(hookPath) term in its skip condition — hasBash() only says a bash exists, not that it can see the hook script.

bash-path.test.ts is in the same commit and not a separate one because it cannot survive the helper change alone: it pinned the Git-Bash-first order literally, and four assertions go red on Linux and macOS the moment the order becomes conditional. Splitting them would leave a commit that fails npm test for anyone not on Windows.

What is not here, and why

You asked for this "minus the npiperelay-specific parts if they're separable". They are not: findNpiperelay() does not exist on master — git grep npiperelay master -- src/cli/wmux.ts is empty. It arrives with the bridge, so its path.delimiter / Path-vs-PATH fix has to ride with PR 3. Flagging rather than silently omitting.

The resources/cli/wmux.js conflict you expected

It is not there. Nothing in 8122b69..master touches src/cli/ or resources/cli/#153's timeout work (18940c0) is below the PR base, and #158 (1dd0d9e) is in claude-context.ts and friends. So this PR had no artifact conflict to resolve.

But while checking that, something else turned up, and it changes one test in this PR:

resources/cli/wmux.js is 1519 diff lines behind a build of master's own src/ (wmux-hook.js is 64). It is missing timeoutMessage and browserDeadline from #153, subcommandError from #156, and rename-surface from #104. Nothing shipped is broken by this — package.json's bin, electron-builder.json's extraResources and step 7 of the release process all use dist/cli/*.js, so releases and installs get a fresh build. The stale copies are read by dev-mode Claude hooks (claude-context.ts:394) and by anything consuming the CLI out of a repo checkout.

cli-config-path.test.ts as originally written spawned resources/cli/wmux.js, on the stated grounds that it is "the file a released wmux actually runs" — which is the belief above, and it is wrong. It now spawns dist/cli/wmux.js and compiles it in beforeAll (~3s; dist/ is gitignored and npm test runs ahead of npm run build:main). Building unconditionally is what makes the spawned CLI the working tree's rather than a leftover.

I have deliberately not regenerated the artifact here. Doing so would put 1519 lines of unrelated catch-up into a three-file fix and bury the actual change. Same class of bug as the shell-integration drift in PR 2 — happy to send a fourth PR that regenerates both resources/cli/*.js, or a CI check that resources/ matches a fresh build, whichever you prefer. Tell me which and I will open it.

Verification

  • npm run typecheck clean (both tsconfigs).
  • Full suite on this branch: 6 failed, 993 passed, 2 skipped. Same suite on a detached master worktree with the same node_modules: 6 failed, 988 passed, 2 skipped. The failing set is identical one-for-one — the Windows-path and live-Win32-probe cases (shell-context-menu ×3, pty-ledger ×2, pty-manager > resolveSpawnCwd). This PR adds 5 passing tests and takes nothing green to red.
  • Linux only. bashPathCandidates() still has a Windows branch a Linux run cannot exercise, and wmux config path / wmux locales path want a check from a native Windows shell — that half is yours.

🤖 Generated with Claude Code

stlindenau and others added 3 commits August 14, 2026 13:21
`wmux config path` built the path from the caller's own home directory:

    console.log(`${os.homedir()}\.wmux\config.toml`)

That only holds when the CLI runs on the same Windows box as wmux. Run it from
WSL, or from a devcontainer over the bridge, and it prints
`/home/vscode\.wmux\config.toml` — not the file wmux reads, and not a
well-formed path on either OS. Editing "the config file" there edits nothing,
which is a long way to walk before finding out why a setting had no effect.

loadUserConfig() already records the real path and `config.get` returns it, so
ask. The local guess stays as the answer when no instance is reachable, but
built with path.join so it is at least self-consistent with the filesystem the
CLI is actually on.

`wmux locales path` cannot be fixed the same way — `config.get` carries no
locales directory, so there is nothing to ask the instance for. The commit
after this one gives both fallbacks a spelling that is at least well-formed for
the OS in hand; the locales one can still name the wrong file when the CLI and
wmux are on opposite sides of the WSL boundary.

The test spawns dist/cli/wmux.js, and compiles it first. It originally spawned
resources/cli/wmux.js on the grounds that that is what a released wmux runs,
which turns out not to be so: package.json's `bin`, electron-builder's
extraResources and step 7 of the release process all name dist/cli/wmux.js.
resources/cli/wmux.js is a checked-in copy of that build with nothing enforcing
the copy — it is 1519 diff lines behind a build of master's own src/ as of
1.0.0 — so a test spawning it asserts against whatever was committed last
rather than against the branch under test. Building in beforeAll costs ~3s and
is needed either way: dist/ is gitignored and `npm test` runs ahead of
`npm run build:main`.

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 16b0d4c, opened as part of amirlehmam#166)
Both no-instance fallbacks hardcoded backslashes:

    console.log(`${home}\.wmux\locales`)

which is the same bug the previous commit fixed for `config path`, in the one
place `config path` still has to guess and in `locales path`, which has no
instance-side answer to ask for at all. From WSL or a container that prints
`/home/vscode\.wmux\locales` — a string that names nothing on either side of
the boundary.

Pick the separator from the home directory that was found rather than from
`process.platform`, because the interesting case is exactly the one where they
disagree: a POSIX $HOME (`/home/vscode`, no backslash anywhere) gets
path.posix.join, everything else gets path.join. Node's path.join on Windows
would join a POSIX home with backslashes and produce the same hybrid.

$HOME is preferred over USERPROFILE now, reversing the old order. Under WSL
both are set — USERPROFILE is the Windows one arriving over interop — and the
CLI's own filesystem is the Linux one.

This is the two path-fallback hunks of c693c06. The third hunk
in that commit fixes findNpiperelay() to split PATH on path.delimiter and to
read `Path` as well as `PATH`; it is left out because findNpiperelay() does not
exist here — it arrives with the devcontainer bridge, and the fix will ride
with it.

`locales path` can still name the wrong directory when the CLI and wmux sit on
opposite sides of the WSL boundary. Fixing that needs a `locales.get` that
reports the loaded directory, the way config.get already reports `path`.

Co-Authored-By: Claude <noreply@anthropic.com>
`bashPathCandidates()` offered `/c`, `/mnt/c`, `/cygdrive/c` in that fixed
order, and `toBashPath()` documented Git Bash's `/c` as preferred outright.
That is right on a Windows box with Git for Windows and wrong everywhere else:
in a Linux checkout — a container, WSL, a CI runner — the bash on PATH mounts a
Windows drive at `/mnt/c` if it can see one at all, and leading with a spelling
that never resolves puts the fallback branch in charge.

So the order is now conditional on `process.platform` while the set stays
fixed: Git Bash first on Windows, the WSL mount first elsewhere, all three
always offered, and the probe still decides. `toBashPath()`'s comment is
qualified to match — off Windows there is no Git Bash to prefer.

`bashExists()` probes through a login shell (`-lc`) so it sees the environment
the orchestrator scripts are run under rather than a bare non-interactive one,
and names $0 `_`, which is what it is — a placeholder, not the shell.

`orchestration-status-vocab.test.ts` gains a `bashExists(hookPath)` term in its
skip condition. `hasBash()` alone only says a bash exists; it does not say that
bash can see the hook script, which is the thing the suite needs and the case
this change exists to handle. Unreachable now skips loudly instead of failing
on a path the shell cannot open.

`bash-path.test.ts` moves in the same commit because it cannot survive the
helper change on its own — it pinned the Git-Bash-first order literally, and
four of its assertions go red on Linux and macOS the moment the order becomes
conditional. Splitting the two would leave a commit that fails `npm test` for
anyone not on Windows. Rewritten so the invariant and the variant are asserted
separately: the spelling set order-independently with `arrayContaining` plus a
length, the order once against the same `process.platform` condition the helper
uses. The probe-driven case gains a Git Bash counterpart to the WSL one, so
"the probe decides, not the ordering" is covered from both directions.

On a Linux run neither suite changes result — both are green before and after,
and the status-vocab suite still executes rather than skipping. The change is
for the hosts where the reachable bash is not the one the ordering assumed;
verifying that half needs a Windows box with WSL bash but no Git Bash.

This is c693c06's test-helper hunks plus 64fb8d8, from the
branch in amirlehmam#166.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stlindenau
stlindenau marked this pull request as ready for review August 14, 2026 14:29
@amirlehmam
amirlehmam merged commit 172ac6a into amirlehmam:master Aug 14, 2026
amirlehmam added a commit that referenced this pull request Aug 14, 2026
Both PRs ended by asking which direction I wanted for the checked-in
duplicates. This is the answer: keep them, and check them.

resources/shell-integration/ is a copy of src/shell-integration/, and
resources/cli/*.js a copy of the tsc output in dist/cli/. Nothing kept
either in step and both had rotted — the packaged PowerShell integration
predated the pipe's auth gate, so it sent V1 lines with no token and every
report was rejected into a void with no way to notice, and the CLI copy
was ~1700 diff lines behind its own source.

That is exactly #137, where a second copy of the app icon went stale for
three releases: a duplicate with no enforcement is not a duplicate, it is
a time bomb. #169 resynced today's drift; this stops tomorrow's.

Deletion was the other option offered and is not taken here. Nothing wmux
ships reads either directory (extraResources maps src/shell-integration
and dist/cli), but external consumers read them out of a repo checkout —
which is how the drift was found — and removing a path they depend on is
not something to do silently in a patch release.

Split by what each half costs:

- npm test covers shell-integration, which needs no build, so the check
  gates a release through CI's existing test step.
- npm run verify:resources covers the CLI artifacts too, and CI runs it
  after build:main, which is what makes dist/cli exist. It skips loudly
  rather than passing quietly when dist/ is absent — a check that
  silently does nothing reads as coverage it is not providing.

resources/cli/*.js is regenerated here to make the invariant true.
@amirlehmam

Copy link
Copy Markdown
Owner

Merged in 1.0.1. Exactly the split I asked for, and it corrected two things I'd asserted in the #166 review.

Where I was wrong

The artifact conflict I predicted doesn't exist. I told you resources/cli/wmux.js would conflict because "#153's timeout work and my #158 changes both landed there". You checked: 18940c0 is below your PR base, and #158 is in claude-context.ts and friends. Nothing in 8122b69..master touches src/cli/ or resources/cli/. I asserted a merge conflict without looking, which is exactly the class of thing I've been thanking other people for catching this week.

And the belief underneath it was wrong too. resources/cli/wmux.js is not "the file a released wmux actually runs" — electron-builder.json maps dist/cli/wmux.jscli/wmux.js, and step 7 of the release process copies from dist/. You were right to re-point cli-config-path.test.ts at dist/cli/wmux.js and to build it in beforeAll; a test spawning the stale copy would have been testing a file nothing runs.

findNpiperelay() doesn't exist on master. My "minus the npiperelay-specific parts if they're separable" was an instruction that couldn't be followed, because the function arrives with the bridge. Flagging that instead of quietly dropping it is the right call.

Windows verification — the half you couldn't run

Both branches, on 1.0.1 with everything else merged:

$ node dist/cli/wmux.js config path
C:\Users\aeont\.wmux\config.toml          # instance answered

$ WMUX_PIPE='\\.\pipe\does-not-exist' node dist/cli/wmux.js config path
C:\Users\aeont\.wmux\config.toml          # fallback, correct separators

And the separator heuristic across the cases that matter:

$HOME result
/home/vscode /home/vscode/.wmux/config.toml
C:\Users\aeont C:\Users\aeont\.wmux\config.toml
\\server\share \\server\share\.wmux\config.toml

The UNC row is the one I went looking for, since home.includes('/') && !home.includes('\\') is a heuristic rather than a parse — it lands on path.join, which is right.

bashPathCandidates()'s Windows branch is now exercised too: full suite green here at 1021 passing, 86 files, with your 5 added.

The decision you asked for

You offered a fourth PR — regenerate the artifacts, or a CI check. I took both, in 1.0.1, so you don't need to:

  • resources/cli/*.js regenerated (it was 1745 diff lines behind by the time your branch merged).
  • npm run verify:resources compares resources/ against its sources, wired into CI after build:main, which is what makes dist/cli exist. It skips loudly rather than passing quietly when dist/ is absent — a check that silently does nothing reads as coverage it isn't providing.
  • The build-free half (shell-integration) is a unit test instead, so it gates a release through CI's existing npm test step even if the build step ever moves.

I did not delete the directories, which was your other option. Nothing wmux ships reads them, but you read them out of a checkout and you're unlikely to be the only one — removing a path external consumers depend on isn't something to do silently in a patch release. If you'd still prefer deletion, say so and I'll do it with a deprecation notice in a minor.

One small thing I noticed

cli-config-path.test.ts compiling into dist/ during npm test means the test suite mutates build output. I hit one transient collision running a build concurrently with vitest — 1008/1021 with a collection error, not reproducible in three subsequent clean runs, and impossible in CI since those steps are sequential. Not asking you to change it; the unconditional build is what makes the spawned CLI the working tree's, and that's the property worth having. Just noting it in case it ever surfaces as a mystery flake.

Next

That leaves PR 3, the bridge itself. Whenever you're ready — the two flags from my #166 review still stand (the 0.0.0.0 namespace assumption, and narrowing wmux raw-v1 to the reports the integration actually emits), and resources/cli/*.js is now clean and enforced, so a regenerated artifact in that PR will be a real diff rather than catch-up noise.

Thanks for splitting it. Both halves shipped inside a day.

@stlindenau
stlindenau deleted the fix/cli-path-fallbacks branch August 14, 2026 14:39
amirlehmam pushed a commit that referenced this pull request Aug 14, 2026
Claude Code increasingly runs in a Linux devcontainer while wmux runs on
Windows. Nothing in that container can open \\.\pipe\wmux, so the CLI, the
hooks and the shell integration all failed silently: no cwd, no git branch,
and a sidebar stuck on "Running" forever.

The bridge from issue #78 is most of the answer already — it just could not
reach the pipe from where it needed to run. Three pieces close the gap:

* connectTransport() learns npiperelay.exe, so `wmux bridge` can run INSIDE
  WSL2 and still reach the Windows pipe over interop. That placement is the
  security property: 0.0.0.0 there is the WSL2 namespace, reachable from
  containers on the host and not from the LAN, with no firewall rule. AF_VSOCK,
  a Windows-side listener and cross-boundary Unix sockets were tried first and
  are documented as rejected in the code.
* The bridge keeps relays warm and tears down half-close-aware. Spawning
  npiperelay measures ~7s on an AV-scanned corporate host, and destroying both
  sides on either 'close' killed the frame of any client that writes and hangs
  up — which is every Claude Code hook. Deadlines gain a floor on the slow
  transports for the same reason; the 5s default sat below the round-trip, so
  calls that had already succeeded were reported as timeouts.
* `wmux raw-v1` gives the bash integration a way to send its V1 lines through
  the CLI's transport instead of a temp file it cannot write to, and
  wmux-hook.js gets the same TCP branch.

report_startup_command is new and rides the same path: a shell declares how to
bring its own surface back, stored per surface, so a restored pane re-enters
its container instead of coming up as a bare WSL prompt.

docs/DEVCONTAINER.md is the standalone setup — one binary, one command, two
environment variables, no vendor tooling.

Two things ride along that were separate commits in #166, because on current
master they have nothing to stand on their own for. The npiperelay lookup
splits PATH on path.delimiter and falls back to process.env.Path — the
Windows spelling — which only matters for the finder this commit introduces;
#168 landed the rest of that change and deliberately left this half behind.
And resources/shell-integration/wmux-bash-integration.sh is updated in step
with its source, which #169 plus the check in 42e3cca now require: the
packaged copy was resynced from a pre-bridge src/, so it has the `wmux` shim
but not the WMUX_REMOTE branch, and leaving it that way fails npm test.

Co-Authored-By: Claude <noreply@anthropic.com>
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.

2 participants