feat(devcontainer): drive wmux from a container over the TCP bridge - #170
Conversation
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. Two things ride along that were separate commits in amirlehmam#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; amirlehmam#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 amirlehmam#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>
`--wsl` picked `0.0.0.0` on the strength of a comment:
// WSL2's NAT gives the distro an address the container resolves as
// host.docker.internal, but 127.0.0.1 inside the distro is not it.
That is true, and it is true only under NAT. NAT is WSL2's default, so the
assumption holds on most machines — but `networkingMode=mirrored` (WSL 2.0+ on
Windows 11) removes the distro's network namespace entirely and gives it the
Windows host's interfaces instead, LAN adapter and VPN adapter included.
`0.0.0.0` in a mirrored distro is a bind on the corporate network, not on an
isolated 172.x. What filters it is the Hyper-V firewall rather than the ordinary
Windows Firewall profile, and the recipe everyone copies to make WSL reachable
is exactly the one that opens it:
Set-NetFirewallHyperVVMSetting -Name '{40E0AC32-...}' -DefaultInboundAction Allow
Mirrored plus that setting puts wmux's control pipe on the LAN. The pipe token
authenticates every request either way, so this is exposure rather than an open
door — but it is a configuration people actually run, not a hypothetical future
WSL change, and it is not a default to pick on someone's behalf.
So the mode is established at runtime instead of inferred from the fact that we
are in WSL:
* WSL first — "microsoft"/"wsl" in /proc/sys/kernel/osrelease AND WSL_INTEROP
or WSL_DISTRO_NAME set. Both, because each is weak alone: a container can
mount a /proc carrying the host's kernel string while having its own network
stack, and the env vars are inherited by anything a WSL shell launches.
`--wsl` outside that refuses and names what it looked for.
* Then `wslinfo --networking-mode`, which prints `nat` or `mirrored`.
* `nat` → bind 0.0.0.0 as before, and say on startup why that is contained,
including that it stops being true under mirrored.
* `mirrored` → refuse to pick 0.0.0.0. The error explains that the distro
shares the host's adapters, names the Hyper-V firewall as the thing standing
between the bind and the LAN, and points at `--host <addr>`.
* absent or unparseable (wslinfo needs WSL 2.0.5+) → `unknown`, which refuses
too. Folding it into `nat` would restore the old silent 0.0.0.0 on exactly
the hosts least able to report what they are doing.
An explicit `--host` is always honoured, `--host 0.0.0.0` under mirrored
included: that is a stated choice rather than an inherited default, and
overruling it would only push people to a wrapper script and lose them the
warning. The beyond-loopback warning is upgraded under mirrored to say the bind
is on the host's real interfaces.
The decision is a pure function in src/cli/wsl-network.ts, so the whole table —
nat/mirrored/unknown x in-WSL-or-not x explicit-host-or-not — is tested on Linux,
including the mirrored branch, which cannot be exercised on a NAT host.
docs/DEVCONTAINER.md asserted flatly that the bind is "reachable from containers
on that host and not from the LAN" and "needs no Windows firewall rule". Both
passages are now qualified with the mode, and there is a Mirrored networking
section with the `.wslconfig` setting, the firewall cmdlet, the `wslinfo` check
and what to pass instead.
Not verified against a real mirrored distro — that needs a machine to flip
`networkingMode` on and shut WSL down, which was not available here. The refusal
is the safe default if the detection is wrong in either direction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`wmux raw-v1` took any line and handed it to the V1 handler. It exists for one caller — wmux-bash-integration.sh reporting shell state from inside a devcontainer, where it cannot reach the pipe directly (issue amirlehmam#19) — but nothing in it said so, so it was a generic V1 passthrough in practice. That is a standing side door rather than a bug with a symptom. Every V1 command added later becomes reachable from a container the day it lands, without anyone deciding it should be, and the pipe's V1 surface stops being something the V1 handler alone defines. `notify` and `report_pr` are already reachable that way today and have no business being. The set the integration emits is six verbs long, so name them: report_pwd report_git_branch clear_git_branch report_shell_state ports_kick report_startup_command The first five are every `_wmux_report` call site in the shipped script. The sixth comes from the other side: the devcontainer feature appends a `report_startup_command` line to its own copy of the integration so a restored pane can be relaunched, so leaving it out would break that path. Anything else exits non-zero naming the accepted set. `raw-v1` keeps its usage string and its COMMANDS entry — the command is still there, it is just no longer a wildcard. The test derives the expected verbs from the shipped script rather than restating them, because the allowlist and the integration drifting apart is the one failure that silently breaks reporting in a container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI and the Claude Code hook helper are two processes dialling the
same socket, and each carried its own deadline. wmux.ts derived one from
`remoteTarget || usesNpiperelay()`; wmux-hook.ts wrote `remote ? 30000 :
5000`. Same intent, two spellings — and the hook's did not know about
npiperelay, so a hook firing from a WSL shell armed the 5s local-pipe
timer for a hop that measures ~7s worst case and destroyed the socket
on a call that was going to succeed.
Both now describe their connection the same way — {remote, pipePath,
env} — and ask transport-deadline.ts for the number. The floor and the
slow-transport predicate live there and nowhere else, so a third client,
or a change to either value, has one place to find.
No behaviour change for a local pipe: the floor raises a deadline and
never lowers one, so a native Windows run keeps its original timings and
a browser verb keeps the longer budget it asked for. WMUX_RPC_TIMEOUT_MS
is likewise a floor rather than a cap.
The agreement test asserts both call sites still route through the
module and that neither has redeclared the constants inline, since a
re-hardcoded literal is precisely how the two drifted apart the first
time.
…orts The CLI is packaged file-by-file — extraResources names dist/cli/wmux.js and dist/cli/wmux-hook.js individually, not the directory. Until now that was harmless because each file was self-contained. Splitting the deadline derivation out (and, earlier in this branch, the WSL bind decision) makes them require siblings that no build step ships. This failure mode is worse than the one packaging.test.ts already guards. A missing resource is a silently-absent feature behind an existsSync warning; a missing sibling module is MODULE_NOT_FOUND on the first line of `wmux ping` and of every Claude Code hook the moment they run from an installed build. And `npm run dev` cannot show it — the module is sitting right there in dist/. So both are listed, and the check is derived from the imports rather than written down: walk the relative imports out of every src/cli entry point that extraResources ships, and require each to be shipped too. Adding the next shared module without packaging it now fails `npm test`, which gates the tag. Enumerating the two we know about is what let amirlehmam#149 through the last version of this file. Found by that check, not by review: wsl-network.js was already missing.
Fallout from making --wsl a checked claim. The suite spawned every bridge with `--wsl`, but each of its tests connects over 127.0.0.1 — the flag only ever picked the bind ADDRESS, which is a different question from the upstream transport being asserted. Now that --wsl is verified against the running environment instead of being a synonym for "bind 0.0.0.0", it made the whole suite fail anywhere outside a real WSL2 distro for reasons unrelated to the relay. Replaced with an end-to-end test of the part a pure function cannot reach. chooseBridgeHost() is handed `inWsl2`; readWslEnvironment() has to produce it from /proc and the interop vars on the actual machine, and the obvious spelling of that probe is wrong in a way that ships easily: a Linux container on a Windows host runs on the WSL2 *kernel*, so its osrelease reads "microsoft-standard-WSL2" while it has no Windows host to reach and no interop to reach it with. Matching osrelease alone would bind 0.0.0.0 in every devcontainer on Windows — the opposite of the point. This suite runs inside exactly such a container, so that case is covered by observation rather than by argument.
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 amirlehmam#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>
|
Merged in 1.1.0. All seven commits, as submitted. This is the third of three, and the split was worth it exactly as you predicted: against today's master the artifact commit is +443/−36 and every line of it is the bridge. The same regeneration in #166 was +1412/−445 of accumulated rot with the actual change buried inside. I could review this one. The four review points1. The The mirrored-mode detail is the part I did not know: the distro has no namespace of its own, so Treating The 2. 3. One transport-derived deadline. This is what I meant by "derived from the transport rather than added per call site", and the bug it fixes is worse than the duplication: the hook's copy didn't know about npiperelay, so a hook firing from a WSL shell armed a 5s timer for a ~7s hop and killed calls that were going to succeed. That's the third time a deadline default has been wrong for a transport it wasn't designed for (#153 was the first). A floor that only ever raises is the right shape. 4. Artifacts. Regenerated, and The packaging gapThis is the most valuable thing in the PR and it isn't the feature.
I reproduced both directions before merging, because a packaging claim deserves it: Same class as #149 and #81 — works in dev, silently absent when installed — and it has now bitten this project three times. Deriving the check from the imports rather than a hardcoded list is what makes it hold, and the self-check test ("finds the sibling imports it is meant to be checking") is the detail that keeps it from quietly matching nothing. That it caught Windows verification — your halfNative Windows 11, Core Ultra 9 275HX / RTX 5090, on the merge commit:
So the two Mirrored modeNoted, and I'm not going to pretend otherwise either — it stays unverified for now. I don't have a mirrored host and I'm not reconfiguring You're right that the refusal is the safe default whether or not anyone gets there: the untested branch's failure mode is "refuses to start a bridge that would have worked", which costs a confused user an error message, versus "binds the control pipe to the LAN", which doesn't. That asymmetry is what makes shipping it acceptable. If anyone reading this runs Two small things, not blocking
That's #166 fully landed, in three reviewable pieces, with two bugs found along the way that had nothing to do with the feature. Thanks for taking the split — it was the right call and you did the harder half of it. |
Third and last of the three PRs #166 was split into. #168 and #169 are merged, so this is the remainder the split was for: the bridge itself, on its own, with regenerated artifacts and
docs/DEVCONTAINER.md.Rebuilt on current master (
3078b48, 1.0.1) rather than rebased — four of the eleven commits on the old branch are now upstream and had nothing left to carry:fix(cli): ask the instance for the config path1bc0089fix(cli): improve path handling…7a90462, minus the npiperelay half, which folds into commit 1 here — it is a fix to a function this branch introducestest(bash-path): assert the candidate order…1283433; the three test files are byte-identical to what is on masterfix(shell-integration): sync the packaged bash integrationc573842, which did all three copiestests/unit/cli-config-path.test.tsis master's version untouched — spawningdist/cli/with abeforeAllbuild is better than what #166 had, and nothing here needs to touch it.What it does
wmux bridgeexposes the local pipe on TCP so a devcontainer can drive the wmux running on the Windows host (issue #19, on top of the bridge from #78). The container has no\\.\pipe\wmux, so every CLI call and every Claude Code hook failed silently — the sidebar sat on "Running" while the agent idled.Rejected alternatives are written up in
docs/DEVCONTAINER.md(AF_VSOCK, a Windows-side listener, cross-boundary Unix sockets).Changes since #166
Four, from the review there.
1. The
0.0.0.0bind is now checked, not assumed#166 asked what the blast radius of
--wslbinding0.0.0.0was. The honest answer is that it depends on a WSL setting the code never looked at:0.0.0.0iseth0on a private 172.x plus loopback — reachable from containers via the host gateway, genuinely not reachable from the LAN, no firewall rule involved. The comment was right, and it is kept verbatim as the NAT explanation.networkingMode=mirroredin.wslconfig). The distro has no namespace of its own — it shares the Windows host's interfaces, including the physical LAN adapter and any VPN adapter.0.0.0.0there is a bind on the corporate network. Inbound traffic to a mirrored distro is filtered by the Hyper-V firewall, not the ordinary Windows Firewall profile, and the widely-copied "make WSL reachable" recipe isSet-NetFirewallHyperVVMSetting -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -DefaultInboundAction Allow. Mirrored plus that setting puts wmux's control pipe on the LAN. The pipe token still authenticates every request, so it is exposure rather than an open door — but it is a configuration people actually run, not a hypothetical.So the mode is read from
wslinfo --networking-mode(WSL 2.0.5+) and the bind follows from it:--wsldoesnat0.0.0.0, and says why that is safe heremirrored--host <addr>wslinfo--hostgiven explicitlyThe decision is a pure function (
src/cli/wsl-network.ts), table-tested over mode × in-WSL2 × explicit-host, so none of it needs WSL to run.One thing worth flagging, because the obvious spelling of the WSL2 probe is wrong in a way that ships easily: a Linux container on a Windows host runs on the WSL2 kernel. Its
/proc/sys/kernel/osreleasereadsmicrosoft-standard-WSL2while it is not a WSL2 distro and has no interop to reach a Windows host with. Matching osrelease alone would bind0.0.0.0in every devcontainer on Windows — the exact opposite of the intent.isWsl2()requires the interop vars too, and there is an end-to-end test for it that passes precisely because CI-style containers are that case.2.
raw-v1is an allowlistIt was a generic passthrough: whatever line you gave it reached the V1 handler. That is a standing side door — every V1 command added later becomes container-reachable the day it lands, without anyone deciding it should be. Now restricted to the six verbs the shell integration actually emits (
report_pwd,report_git_branch,clear_git_branch,report_shell_state,ports_kick,report_startup_command); anything else exits non-zero naming the accepted set.The test derives the expected verbs by scanning
_wmux_reportcall sites insrc/shell-integration/wmux-bash-integration.sh, so the allowlist and the integration cannot drift apart silently.3. One transport-derived deadline, not two
wmux.tsderived its deadline fromremoteTarget || usesNpiperelay();wmux-hook.tshad its ownremote ? 30000 : 5000. Same intent, two spellings — and the hook's did not know about npiperelay, so a hook firing from a WSL shell armed the 5s local-pipe timer for a hop that measures ~7s and killed a call that was going to succeed.Both now describe their connection the same way and ask
transport-deadline.ts. No behaviour change for a local pipe: the floor only ever raises a deadline.4. Artifacts regenerated
resources/cli/*.jsfrom a cleannpm run build:mainon the final tree, as the last commit.42e3ccachanged what that commit means, for the better. It is now an invariantnpm run verify:resourceschecks byte-for-byte rather than bookkeeping that can slip, and — more to the point of the split — it is finally readable. The same regeneration in #166 was +1412/-445, because the checked-in copy had drifted ~1700 diff lines behind its own source and the bridge's output was buried in unrelated rot. Against today's master it is +443/-36 plus the two new modules, and all of it is the bridge.That check also covers
resources/shell-integration/, which is why the bash integration'sWMUX_REMOTEbranch is applied to both copies in commit 1: #169 resynced from a pre-bridgesrc/, so the packaged copy has thewmuxshim but not the transport, and leaving it that way failsnpm test.One thing this turned up
Splitting those two modules out exposed a packaging gap.
extraResourcesshipsdist/cli/wmux.jsanddist/cli/wmux-hook.jsfile-by-file, not the directory — harmless while each was self-contained, but a shared sibling module isMODULE_NOT_FOUNDon the first line ofwmux pingand of every Claude Code hook in an installed build.npm run devcannot show it; the module is sitting right there indist/.That is a worse failure than the one
packaging.test.tsalready guards (a missing resource is a silently-absent feature behind anexistsSyncwarning), so the check is derived the same way that file'sprocess.resourcesPathscan is: walk the relative imports out of each shipped CLI entry point and require each to be packaged too.wsl-network.jswas already missing when I added it — found by the check, not by review.Verification
npm run typecheckclean.npm run lintreports 29 problems (16 errors, 13 warnings) — identical to master, none in files this branch touches.npm run verify:resourcespasses.Full suite against a detached
upstream/masterworktree sharing the samenode_modules:master(3078b48)The failing set is identical one-for-one — the Windows-path and live-
Win32_Processcases (orchestration-watcher,pty-ledger×2,pty-manager,shell-context-menu×3) that do not pass on Linux on master either. The 4 added skips are the npiperelay round-trip cases, which need a real WSL2 distro; the suites they live in do run, at 65 passed / 4 skipped across the eight bridge-related files.Checked by hand from the container this was built in:
node resources/cli/wmux.js --help— the packaged copies resolve their new siblingsraw-v1 definitely_not_a_verb …→ exits 1, naming the six accepted verbsbridge --wsl→ exits 1, naming what it looked for and pointing at--host. This container is the false-positive case from §1: WSL2 kernel inosrelease, no interop vars, correctly refusedresources/cli/*.jsbyte-identical to a freshnpm run build:mainNot verified, and I am not going to imply otherwise: the mirrored-mode branch. This was developed on a NAT host. Exercising it needs
networkingMode=mirroredin.wslconfigand awsl --shutdownon a scratch machine, wherewslinfo --networking-modeshould printmirrored,--wslshould refuse,--host 0.0.0.0should bind with the upgraded warning, andip addrinside the distro should show the host's LAN adapter — which is the observation that makes the risk concrete rather than argued. The refusal is the safe default whether or not anyone gets to that.Draft until you have had a look at the split — happy to reshape the commit series.