Skip to content

feat(devcontainer): drive wmux from a container over the TCP bridge - #170

Merged
amirlehmam merged 7 commits into
amirlehmam:masterfrom
stlindenau:feat/devcontainer-bridge
Aug 14, 2026
Merged

feat(devcontainer): drive wmux from a container over the TCP bridge#170
amirlehmam merged 7 commits into
amirlehmam:masterfrom
stlindenau:feat/devcontainer-bridge

Conversation

@stlindenau

Copy link
Copy Markdown
Contributor

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:

old commit why it is gone
fix(cli): ask the instance for the config path merged as 1bc0089
fix(cli): improve path handling… merged as 7a90462, minus the npiperelay half, which folds into commit 1 here — it is a fix to a function this branch introduces
test(bash-path): assert the candidate order… merged as 1283433; the three test files are byte-identical to what is on master
fix(shell-integration): sync the packaged bash integration superseded by c573842, which did all three copies

tests/unit/cli-config-path.test.ts is master's version untouched — spawning dist/cli/ with a beforeAll build is better than what #166 had, and nothing here needs to touch it.

What it does

wmux bridge exposes 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.0 bind is now checked, not assumed

#166 asked what the blast radius of --wsl binding 0.0.0.0 was. The honest answer is that it depends on a WSL setting the code never looked at:

  • NAT mode (WSL2's default, and what the old comment described). The distro has its own network namespace behind a Hyper-V vSwitch. 0.0.0.0 is eth0 on 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.
  • Mirrored mode (networkingMode=mirrored in .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.0 there 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 is Set-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:

mode --wsl does
nat binds 0.0.0.0, and says why that is safe here
mirrored refuses, explains that the distro shares the host's adapters, points at --host <addr>
unknown / no wslinfo refuses — treated as unknown, not as NAT
--host given explicitly honoured; a non-loopback bind warns, and the warning names the mode

The 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/osrelease reads microsoft-standard-WSL2 while it is not a WSL2 distro and has no interop to reach a Windows host with. Matching osrelease alone would bind 0.0.0.0 in 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-v1 is an allowlist

It 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_report call sites in src/shell-integration/wmux-bash-integration.sh, so the allowlist and the integration cannot drift apart silently.

3. One transport-derived deadline, not two

wmux.ts derived its deadline from remoteTarget || usesNpiperelay(); wmux-hook.ts had its own 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 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/*.js from a clean npm run build:main on the final tree, as the last commit.

42e3cca changed what that commit means, for the better. It is now an invariant npm run verify:resources checks 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's WMUX_REMOTE branch is applied to both copies in commit 1: #169 resynced from a pre-bridge src/, so the packaged copy has the wmux shim but not the transport, and leaving it that way fails npm test.

One thing this turned up

Splitting those two modules out exposed a packaging gap. extraResources ships dist/cli/wmux.js and dist/cli/wmux-hook.js file-by-file, not the directory — harmless while each was self-contained, but a shared sibling module is MODULE_NOT_FOUND on the first line of wmux ping and of every Claude Code hook in an installed build. npm run dev cannot show it; the module is sitting right there in dist/.

That is a worse failure than the one packaging.test.ts already guards (a missing resource is a silently-absent feature behind an existsSync warning), so the check is derived the same way that file's process.resourcesPath scan is: walk the relative imports out of each shipped CLI entry point and require each to be packaged too. wsl-network.js was already missing when I added it — found by the check, not by review.

Verification

npm run typecheck clean. npm run lint reports 29 problems (16 errors, 13 warnings) — identical to master, none in files this branch touches. npm run verify:resources passes.

Full suite against a detached upstream/master worktree sharing the same node_modules:

test files tests
master (3078b48) 4 failed, 82 passed 6 failed, 1005 passed, 2 skipped
this branch 4 failed, 87 passed 6 failed, 1045 passed, 6 skipped

The failing set is identical one-for-one — the Windows-path and live-Win32_Process cases (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 siblings
  • raw-v1 definitely_not_a_verb … → exits 1, naming the six accepted verbs
  • bridge --wsl → exits 1, naming what it looked for and pointing at --host. This container is the false-positive case from §1: WSL2 kernel in osrelease, no interop vars, correctly refused
  • resources/cli/*.js byte-identical to a fresh npm run build:main

Not verified, and I am not going to imply otherwise: the mirrored-mode branch. This was developed on a NAT host. Exercising it needs networkingMode=mirrored in .wslconfig and a wsl --shutdown on a scratch machine, where wslinfo --networking-mode should print mirrored, --wsl should refuse, --host 0.0.0.0 should bind with the upgraded warning, and ip addr inside 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.

stlindenau and others added 7 commits August 14, 2026 15:05
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>
@amirlehmam

Copy link
Copy Markdown
Owner

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 points

1. The 0.0.0.0 bind. I asked what the blast radius was and said the comment should stay load-bearing. You did better than that — you went and found that the comment was only true under one of two networking modes, and that the other one is a configuration people actually run.

The mirrored-mode detail is the part I did not know: the distro has no namespace of its own, so 0.0.0.0 is the host's real interfaces including LAN and VPN, and inbound is gated by the Hyper-V firewall rather than the ordinary profile — which is why the widely-copied Set-NetFirewallHyperVVMSetting … -DefaultInboundAction Allow recipe is the thing that turns exposure into reachability. That is a much sharper answer than the one I asked for.

Treating unknown as unknown rather than folding it into the NAT default is the right call and the one that's easy to get wrong under pressure to make --wsl "just work" on WSL 1.x. And honouring an explicit --host 0.0.0.0 under mirrored mode is right too: the user named the address, so describing the choice beats overruling it.

The isWsl2() false positive is the best catch in the PR. A Linux container on a Windows host runs on the WSL2 kernel, so /proc/sys/kernel/osrelease says microsoft-standard-WSL2 while it is not a distro and has no interop to reach a host with. Matching osrelease alone — the obvious spelling — would bind 0.0.0.0 in every devcontainer on Windows, which is the precise inverse of the feature's intent. Requiring the interop vars too, and having the end-to-end test pass because CI containers are that case, is the kind of thing that only gets found by someone actually running it.

2. raw-v1 as an allowlist. Yes. My concern was the standing side door, and you named the property better than I did: every V1 command added later becomes container-reachable the day it lands, without anyone deciding it should be. Deriving the expected verbs by scanning _wmux_report call sites in the integration means the two cannot drift apart silently — same shape as the verify:resources invariant, and the right instinct.

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 verify:resources passes.

The packaging gap

This is the most valuable thing in the PR and it isn't the feature.

extraResources ships the CLI entry points file-by-file, not the directory. Harmless while each was self-contained; the moment there's a shared sibling it's MODULE_NOT_FOUND on the first line of wmux ping and of every Claude Code hook — in installed builds only, invisible to npm run dev because the module is sitting right there in dist/.

I reproduced both directions before merging, because a packaging claim deserves it:

# copy ONLY what extraResources declares, run from that dir
$ node <sim>/cli/wmux.js ping
pong                                   # all four modules resolve

# drop one sibling
$ rm <sim>/cli/transport-deadline.js && node <sim>/cli/wmux.js ping
node:internal/modules/cjs/loader:1424
  throw err;                           # exactly the failure you describe

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 wsl-network.js before review did is the whole argument.

Windows verification — your half

Native Windows 11, Core Ultra 9 275HX / RTX 5090, on the merge commit:

check result
npm run typecheck clean
npm test 1059 passed, 6 skipped (the npiperelay cases)
npm run verify:resources passes
bridge --wsl on native Windows refuses, exit 1, names what it looked for
raw-v1 definitely_not_a_verb refuses, exit 1, names the six verbs
wmux ping pong — no regression from the module split
packaged-layout simulation resolves siblings (above)

So the two process.platform branches you flagged as unexercised are now exercised.

Mirrored mode

Noted, 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 .wslconfig on this machine to find out.

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 networkingMode=mirrored, the four observations worth reporting are in the PR body and I'd take them as an issue.

Two small things, not blocking

  • report_startup_command stores per surface, and startupCommands already round-trips through surface-slice's save/restore — so a restored pane re-enters its container across a full session restore, not just a remount. Worth stating because Autobackup does its own thing #145 was exactly the case where a writer emitted a field persistence didn't know about.
  • The --cap/RAM tuning story for the bridge path isn't documented in DEVCONTAINER.md, and the round-trip cost makes cache temperature matter more over the bridge than locally. Not a blocker, and possibly better as a tuning.md cross-reference than anything here.

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.

@stlindenau
stlindenau deleted the feat/devcontainer-bridge branch August 14, 2026 15:28
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