a73x

688b64f3

fix: mux HOST finds a ~/.local/bin muxd on the remote

a73x   2026-08-18 18:43

Commit message
fix: mux HOST finds a ~/.local/bin muxd on the remote

sshd runs the endpoint spawn through a non-login shell that never
sources the profile putting ~/.local/bin — make install's target — on
PATH, so a muxd the user can run by hand was invisible to the attach.
The recipe now prefixes PATH itself, single-quoted so the remote shell
expands it; muxweb's HOST tiles share recipeFor and inherit the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

.gitignore
Old New
@@ -9,3 +9,4 @@ dist/
9 9
10 # Cross-version gate artifacts: two versions built static, per version. 10 # Cross-version gate artifacts: two versions built static, per version.
11 .xversion/ 11 .xversion/
12 .release/
README.md
Old New
@@ -53,7 +53,9 @@ mux HOST # attach; Ctrl-\ detaches, running it again reattaches
53 `muxd start` spawns the daemon detached and waits until it answers, so it 53 `muxd start` spawns the daemon detached and waits until it answers, so it
54 is safe to run every time — if one is already up it says so and exits 0. 54 is safe to run every time — if one is already up it says so and exits 0.
55 55
56 `mux HOST` runs `ssh HOST muxd endpoint` — ssh config (aliases, ports, 56 `mux HOST` runs `muxd endpoint` on HOST over ssh (finding a muxd in
57 `~/.local/bin` even though ssh's non-login shell leaves it off PATH) —
58 ssh config (aliases, ports,
57 ProxyJump) all keeps working, since `mux` never parses HOST. That ssh 59 ProxyJump) all keeps working, since `mux` never parses HOST. That ssh
58 fetches the daemon's QUIC port and key once and the session moves onto 60 fetches the daemon's QUIC port and key once and the session moves onto
59 QUIC, leaving no ssh process behind; the coordinates are cached under 61 QUIC, leaving no ssh process behind; the coordinates are cached under
src/handoff.zig
Old New
@@ -187,7 +187,15 @@ pub const Recipe = struct {
187 /// commands. Building it here (rather than in client.zig) is what keeps 187 /// commands. Building it here (rather than in client.zig) is what keeps
188 /// the client free of XDG and of allocating a command line. 188 /// the client free of XDG and of allocating a command line.
189 pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8) !Recipe { 189 pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8) !Recipe {
190 const cmd = try std.fmt.allocPrint(alloc, "ssh {s} muxd endpoint", .{host}); 190 // The PATH prefix, single-quoted so the REMOTE shell expands it: sshd
191 // runs this through a non-login, non-interactive shell that never
192 // sources the profile putting ~/.local/bin (make install's target) on
193 // PATH — without it a muxd the user can run by hand is invisible here.
194 const cmd = try std.fmt.allocPrint(
195 alloc,
196 "ssh {s} 'PATH=\"$HOME/.local/bin:$PATH\" muxd endpoint'",
197 .{host},
198 );
191 errdefer alloc.free(cmd); 199 errdefer alloc.free(cmd);
192 return .{ .ssh_cmd = cmd, .cache_path = xdg.hostCachePath(alloc, host) catch null }; 200 return .{ .ssh_cmd = cmd, .cache_path = xdg.hostCachePath(alloc, host) catch null };
193 } 201 }
@@ -390,6 +398,15 @@ test "announce: every shape of junk is a named error" {
390 } 398 }
391 } 399 }
392 400
401 test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" {
402 const r = try recipeFor(std.testing.allocator, "user@box");
403 defer r.deinit(std.testing.allocator);
404 try std.testing.expectEqualStrings(
405 "ssh user@box 'PATH=\"$HOME/.local/bin:$PATH\" muxd endpoint'",
406 r.ssh_cmd,
407 );
408 }
409
393 test "dialHost: the LAST @ wins, which is where ssh splits" { 410 test "dialHost: the LAST @ wins, which is where ssh splits" {
394 try std.testing.expectEqualStrings("box", dialHost("ubuntu@box")); 411 try std.testing.expectEqualStrings("box", dialHost("ubuntu@box"));
395 try std.testing.expectEqualStrings("box", dialHost("box")); 412 try std.testing.expectEqualStrings("box", dialHost("box"));