feat(devcontainer): drive wmux from a container over the TCP bridge - #166
feat(devcontainer): drive wmux from a container over the TCP bridge#166stlindenau wants to merge 7 commits into
Conversation
…32 one A workspace can hold a pwsh pane and a WSL pane at the same time, and both report their directory over the same `report_pwd`. `workspace.cwd` was one last-writer-wins field for both filesystems, so a single pwsh pane rewrote it to C:\Users\<user> — and since a plain WSL pane never reports a cwd of its own (wmux exports WMUX_INTEGRATION=1 into the distro but installs no rc hook there), the next WSL pane fell back to that Win32 path, failed isPosixPath, and got `--cd ~`. Every new WSL pane opened in the home directory instead of the project, and a restore brought the whole workspace back there. `posixCwd` is what issue amirlehmam#134 already did per-terminal, applied to the workspace: stop making one field answer for two namespaces. It is additive and only ever written by a POSIX report, so a Win32 pane cannot erase the fallback its neighbours depend on. Sessions saved before the field existed recover via the isPosixPath(cwd) seed in replaceAllWorkspaces. The second half is the shell, not the path. resolveSpawnCwd's answer for a POSIX cwd is %USERPROFILE% — honest, because CreateProcess cannot open a POSIX directory and rejects a \\wsl.localhost\... UNC one too — but it means a new tab or split in a WSL workspace lands in the Windows home folder. wsl.exe is the one shell that can reach the path, so resolveShellForCwd substitutes it. Only pwsh/cmd are replaced: an unclassifiable spec may be a deliberate remote command line such as `ssh user@host` (issue amirlehmam#78), and sending that somewhere else entirely is worse than the wrong directory. The predicates live in src/shared/paths.ts so the renderer applies the same rule the PTY layer does rather than re-deriving it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A read or parse failure discards the WHOLE file: every [terminal], [keys], [browser] and [appearance] section reverts to its default. The errors were always returned to the caller and printed by `wmux config show`, but nothing ever told you to go and look — so one stray character presented as "my setting has no effect", indistinguishable from a mis-set value or a wmux bug. Warn on the way past, naming the file and pointing at `wmux config show`. Per-key mapping errors get the same treatment: they are survivable, the rest of the file still applies, but a silently skipped key reads as a wmux bug. Deduped, because loadUserConfig runs on every WSL pane spawn as well as at startup — undeduped, a single bad file would warn once per pane for the life of the app. `wmux reload-config` clears the record: the user is iterating on the file and needs to see whether the edit fixed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 amirlehmam#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. Co-Authored-By: Claude <noreply@anthropic.com>
`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` has the same defect and is deliberately left alone:
config.get carries no locales directory, so it cannot be fixed the same way and
does not belong in this change.
Co-Authored-By: Claude <noreply@anthropic.com>
`resources/shell-integration/wmux-bash-integration.sh` had not moved since 395156c (v0.5.2). Both a45500e (the `wmux` shell shim) and 9fd08ed (the devcontainer transport) only touched the copy under `src/`, so the two have been silently diverging for a while. Nothing in the app noticed: electron-builder copies `src/shell-integration` into the package (electron-builder.json), and in dev pty-manager resolves the same directory. `resources/` only matters to consumers that read it out of a repo checkout — which is exactly what a devcontainer does when the wmux tree is bind-mounted at /opt/wmux and the container copies the integration out of it. Those shells got the pre-bridge `_wmux_report`: no `wmux` shim, no WMUX_REMOTE branch, straight to the Windows-Temp file drop. `mkdir -p` there is silenced but the append is not, so every single prompt in the container printed bash: /mnt/c/Users/<user>/AppData/Local/Temp/wmux/messages: No such file or directory and, error aside, the pane still reported nothing to the sidebar. Plain copy of the src file, no hand-edits, so the two cannot disagree about anything else either. The .cmd and .ps1 copies have drifted the same way and are deliberately left for a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ally gets c693c06 made `bashPathCandidates()` platform-conditional — Git Bash's `/c` first on Windows, WSL's `/mnt/c` first everywhere else — so that the orchestrator suites can resolve a Windows drive path when the bash on PATH is a Linux one. `tests/unit/bash-path.test.ts` was not moved with it and still asserted the fixed Git-Bash-first order it was written against in amirlehmam#130: expected '/mnt/c/dev/wmux' to be '/c/dev/wmux' On Windows the suite passes, which is why the change looked clean. On Linux and macOS four assertions fail, so `npm test` is red for anyone who is not on the platform wmux ships for — including the container this branch exists to support. Pinning them back to `/c` would only move the failure to the other half of the contributors, so the assertions are split by what is actually invariant and what is not. The set of spellings offered is invariant: all three flavours, always, checked order-independently with `arrayContaining` plus a length. The order is not, so it is asserted once against the same `process.platform` condition the helper uses, named `likeliestMount` with the reasoning attached. `toBashPath`'s two preference cases follow from that constant rather than repeating the literal. The probe-driven case gains a Git Bash counterpart to the existing WSL one, so the "probe decides, not the ordering" property is covered from both sides rather than only the direction that happens to match this platform's default. The doc comment on `toBashPath` still said Git Bash is preferred, full stop; qualified with the platform, since that is now only half the story. No behaviour change — `tests/helpers/bash-path.ts` keeps the ordering from c693c06 untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
You asked whether to split before you did the work, so: yes please, and I've taken two of the seven commits already so you have less to rebase. Cherry-picked into 1.0.0 (with
Both verified on Windows, which is the side your run couldn't exercise: Why the rest waits, and what it needsNot a rejection of the feature — 1. 2. 3. What I'd take next, in order
Split that way I can review and ship 1 and 2 quickly, and give 3 the attention it actually deserves. Two things I want to flag on the bridge itself, for when it comes back
One correction to your notes
Good find, and it's the second time that default has been wrong for a transport it wasn't designed for (#153 was the first, for browser commands). When the bridge PR comes back, I'd rather the floor be derived from the transport than added per-call site — three places choosing their own timeout is how the first one got missed. Thanks for opening it as a draft and asking first. That saved both of us a merge conflict and got two of your fixes shipped a week earlier than they would have been. |
|
Thanks for the two cherry-picks and for the review. Split as asked, in your order. Two of them are up here; the third is in my fork for now.
Two corrections
The But the artifacts are stale on master anywayDifferent problem, same root as #169. On
Last touched at Worth saying that it doesn't currently break anything: Which raises the real question: who are those directories for? As far as I can tell the only consumers are tools reading the repo checkout — mine is one of them, which is how I noticed. If that's the intent they need to be correct; if it isn't, deleting them is cleaner than syncing them. Your call, and #169 works either way. The CI checkThis class of bug recurs because nothing enforces the copy — that's three instances now (the three shell-integration files, and both CLI artifacts). Want a CI check that On the
|
|
Superseded by #168, #169 and stlindenau#2 — see the comment above for the split and the two corrections. |
`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 #166)
`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 stlindenau/wmux@c693c06's test-helper hunks plus 64fb8d8, from the branch in #166. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First of the three splits of #166. `wmux config path` rebuilt 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 well-formed on either OS. It now asks the instance, which already records the real path, and keeps the local guess only for when nothing answers. Both no-instance fallbacks now take their separator from the home directory in hand rather than from process.platform, because the case that matters is exactly where those two disagree. Also repairs the bash-path helper's test: the candidate ORDER is now conditional on the platform (a Linux bash mounts a Windows drive at /mnt/c, not /c), so the test that pinned Git-Bash-first literally had to move with it or go red off Windows. Co-Authored-By: stlindenau <stlindenau@users.noreply.github.com>
#166) Second of the three splits of #166. Generated from master's own src/. resources/shell-integration/ is a checked-in copy of src/shell-integration/ with nothing keeping it in step, and all three files had drifted. The PowerShell one is the serious case: its copy predates #72, so Send-WmuxMessage sends V1 lines with no auth token prefix — and the pipe server rejects every V1 command but ping without one. Anything running that copy reports no cwd, no git branch, no shell state and no PR, and has no way to notice, because the pipe answers `unauthorized` into a void. No release path reads these files (electron-builder maps src/, and the dev fallback in getShellIntegrationPath resolves to src/ too), so this never reached a released install. What reads them is anything consuming wmux out of a repo checkout, which is how it surfaced. Co-Authored-By: stlindenau <stlindenau@users.noreply.github.com>
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>
resources/cli/*.js is a copy of the tsc output in dist/cli/, and 42e3cca made that an invariant rather than a hope: `npm run verify:resources` compares the two byte-for-byte and CI runs it after build:main. So this is no longer bookkeeping that can slip — the branch does not pass without it. Two files are new. transport-deadline.js and wsl-network.js are shared modules this branch introduces, and verify-resources compares directories, not a list of names, so a build output with no copy fails the same way a stale copy does. electron-builder.json names them too (earlier commit), for a different reason: the CLI is packaged file-by-file, and a missing sibling is MODULE_NOT_FOUND on the first line of every hook rather than a degraded feature. The reason this is readable at all is 42e3cca. In #166 the same regeneration read +1412/-445, which is what made that PR unreviewable: the checked-in copy had drifted ~1700 diff lines behind its own source, so the bridge's output was buried in years of unrelated rot. Regenerating from today's master, the diff is just the bridge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third and last split of #166. `wmux bridge` exposes the local pipe on TCP so a container can drive the wmux running on the Windows host: the container has no \.\pipe\wmux, so every CLI call and every Claude Code hook failed silently, and the sidebar sat on "Running" while the agent idled. Four things changed since the #166 review, all of them things I asked for and one of them better than what I asked for: - The 0.0.0.0 bind is checked rather than assumed. `--wsl` now reads `wslinfo --networking-mode` and only picks 0.0.0.0 once it has confirmed NAT. Under mirrored networking the distro shares the Windows host's real interfaces — LAN and VPN adapters included, gated by the Hyper-V firewall rather than the ordinary profile — so it refuses and names `--host <addr>`. Unknown is treated as unknown, not folded into the NAT default. An explicit --host is always honoured. - `raw-v1` is an allowlist of the six verbs the shell integration emits, not a generic passthrough that would have made every future V1 command container-reachable the day it landed. The test derives the expected set by scanning the integration's own call sites. - One transport-derived deadline instead of two spellings. The hook's copy did not know about npiperelay, so a hook firing from a WSL shell armed the 5s local-pipe timer for a ~7s hop and killed calls that were going to succeed. - Artifacts regenerated, and now readable: 42e3cca turned that commit from ~1700 lines of accumulated rot into +443/-36 of actual bridge. It also found a packaging gap on the way: extraResources ships the CLI entry points file-by-file, not the directory, so a shared sibling module is MODULE_NOT_FOUND on the first line of `wmux ping` and of every hook in an installed build — invisible to `npm run dev`, where the module is sitting right there in dist/. packaging.test.ts now walks the relative imports out of each shipped entry point and requires each to be packaged too. It caught wsl-network.js before review did. Co-Authored-By: stlindenau <stlindenau@users.noreply.github.com>
Summary
Seven commits on top of
master(v0.50.0). The headline one lets thewmuxCLI, the Claude Code hooks and the shell integration reach\\.\pipe\wmuxfrom inside a Linux devcontainer; the rest are standalone fixes found along the way.Each commit stands alone and explains its own reasoning in the message — reviewing commit by commit will be a lot easier than reading the combined diff. Happy to split this into separate PRs if you'd prefer; opened as a draft so you can say so before I do.
What's in it
feat(devcontainer)(9fd08ed) —wmux bridge --wslruns the byte relay inside WSL2 and reaches the Windows pipe vianpiperelay.exeover interop.0.0.0.0there is the WSL2 network namespace: reachable from containers through the Docker host gateway, not from the LAN, and no firewall rule needed. Builds on the remote transport from 是否可以添加远程WMUX的管理 #78. Addswmux raw-v1so the bash integration can send V1 lines through the CLI's transport instead of a Windows temp file it cannot write, plusreport_startup_commandso a restored pane re-enters its container instead of coming up as a bare WSL prompt. AF_VSOCK, a Windows-side listener and cross-boundary Unix sockets were all tried first and are documented as rejected in the code. Setup guide:docs/DEVCONTAINER.md.fix(cwd)(f5f782c) — a workspace tracksposixCwdseparately fromcwd, so a pwsh pane no longer rewrites the directory a neighbouring WSL pane falls back to. Extends what another issue with save session? #134 did per-terminal to the workspace, and is additive: sessions saved before the field existed recover via anisPosixPath(cwd)seed.feat(config)(0fa84ab) — warn when~/.wmux/config.tomlfails to read or parse. The whole file is discarded on failure, so one stray character presents as "my setting has no effect" with nothing pointing at the file. Deduped, becauseloadUserConfigruns on every WSL pane spawn.fix(cli)(16b0d4c) —wmux config pathbuilt the path fromos.homedir()instead of asking the instance, so from WSL or a container it printed/home/vscode\.wmux\config.toml— not the file wmux reads, and not a well-formed path on either OS.fix(shell-integration)(d1c1b58) —resources/shell-integration/wmux-bash-integration.shhad not moved since v0.5.2 while the copy undersrc/had. Consumers that read it out of a repo checkout got the pre-bridge_wmux_reportand an error on every prompt. Plain copy of thesrc/file, no hand-edits. The.cmdand.ps1copies have drifted the same way and are deliberately left for a separate change.fix(cli)(c693c06) — three places in the CLI that assumed one OS's path conventions.findNpiperelay()split$PATHon a hard-coded:, so on native Windows the whole search collapsed to one nonsense entry; it now splits onpath.delimiter, drops empties, and readsPathas well asPATH. Theconfig pathandlocales pathfallbacks — the branch taken when no instance answers — chose their separator from the host rather than from the home directory actually in hand, producing/home/vscode\.wmux\config.toml; they now pickpath.posix.joinfor a POSIX home andpath.joinotherwise.test(bash-path)(64fb8d8) — repairs the test fallout of the commit above.c693c06also made thebashPathCandidates()test helper platform-conditional (Git Bash's/cfirst on Windows, WSL's/mnt/celsewhere) so the orchestrator suites can resolve a drive path under a Linux bash, buttests/unit/bash-path.test.tsstill asserted the fixed Git-Bash-first order it was written against in test: let the orchestrator status-vocab suite run on native Windows #130 — green on Windows, four failures on Linux and macOS. The set of spellings offered is invariant and is now asserted order-independently; the order is not, and is asserted once against the sameprocess.platformcondition the helper uses. No behaviour change.resources/cli/wmux.jsandresources/cli/wmux-hook.jsare thetsc -p tsconfig.node.jsonoutput of their sources — re-checked at this head, and byte-identical to a freshnpm run build:main, so there are no hand-edits in the artifacts.Testing
Measured on Linux (a devcontainer), which is not where most of this code lives, so the honest form is a comparison against
masteron the same machine. Baseline:8122b69checked out in a detached worktree, samenode_modules, same commands.master8122b6964fb8d8npm run typechecknpm run lintnpx vitest runThe failing set is identical to
master, one for one — same files, same test names:All of them are Windows-path or live-Win32-probe cases that cannot pass off Windows; none of them are new. Nothing this branch adds breaks anything that worked before it.
The four new suites pass:
workspace-posix-cwd21/21,wmux-hook-remote-transport5/5,cli-config-path3/3, andwmux-bridge-transport1/1 — including the case that relays TCP to a Unix-socket upstream, which is the transport shape the container path depends on. That suite's other four cases (npiperelay stdio, warm relay pool) are gated to WSL2 and skip here, which is where the +4 skipped comes from.A run on Windows is still worth having before merge, and matters a little more now:
c693c06and64fb8d8both branch onprocess.platform, so each has a side no Linux run exercises.The devcontainer path itself is in daily use in the setup
docs/DEVCONTAINER.mddescribes (Windows 11 + WSL2 + Docker devcontainer, Claude Code inside the container).Notes
wmux locales pathstill cannot be resolved the waywmux config pathnow is —config.getcarries no locales directory, so there is nothing to ask the instance for.c693c06only makes its local fallback produce a well-formed path for the OS it runs on; it can still be the wrong path when the CLI and wmux are on opposite sides of the WSL boundary.🤖 This PR and the code in it were written with the help of Claude Code; every commit but
c693c06, where the trailer was dropped by accident, carries aCo-Authored-By: Claudetrailer.