a73x

2de1f8e3

fix: remote ~/.local/bin is a PATH fallback, not a shadow

a73x   2026-08-18 19:14

Commit message
fix: remote ~/.local/bin is a PATH fallback, not a shadow

Two corrections to the v0.0.1-8 recipe change. The prefix form let a
stale ~/.local/bin/muxd shadow whatever the remote PATH already
resolves — appended, it is only a new place to look. And the e2e ssh
shim now models ssh's real contract (args joined and handed to the
remote shell) instead of exec'ing argv: the quoted recipe execs a
program literally named 'PATH=... muxd endpoint' under the old shim,
which is exactly the failure its comment predicted. The cold-handoff
leg was red from the recipe change until this; v0.0.1-8 shipped on
unit+check alone, which is on the process, not the suite.

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

src/handoff.zig
Old New
@@ -187,13 +187,16 @@ 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 // The PATH prefix, single-quoted so the REMOTE shell expands it: sshd 190 // The PATH suffix, single-quoted so the REMOTE shell expands it: sshd
191 // runs this through a non-login, non-interactive shell that never 191 // runs this through a non-login, non-interactive shell that never
192 // sources the profile putting ~/.local/bin (make install's target) on 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. 193 // PATH — without it a muxd the user can run by hand is invisible here.
194 // APPENDED, deliberately: a fallback place to look, never a shadow over
195 // whatever muxd the remote PATH already resolves (or, under the e2e ssh
196 // shim, over the binary under test).
194 const cmd = try std.fmt.allocPrint( 197 const cmd = try std.fmt.allocPrint(
195 alloc, 198 alloc,
196 "ssh {s} 'PATH=\"$HOME/.local/bin:$PATH\" muxd endpoint'", 199 "ssh {s} 'PATH=\"$PATH:$HOME/.local/bin\" muxd endpoint'",
197 .{host}, 200 .{host},
198 ); 201 );
199 errdefer alloc.free(cmd); 202 errdefer alloc.free(cmd);
@@ -401,8 +404,11 @@ test "announce: every shape of junk is a named error" {
401 test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" { 404 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"); 405 const r = try recipeFor(std.testing.allocator, "user@box");
403 defer r.deinit(std.testing.allocator); 406 defer r.deinit(std.testing.allocator);
407 // APPENDED, not prepended: this adds a place to look when muxd is
408 // nowhere on the remote PATH; it must never let a stale ~/.local/bin
409 // shadow a muxd the PATH already resolves.
404 try std.testing.expectEqualStrings( 410 try std.testing.expectEqualStrings(
405 "ssh user@box 'PATH=\"$HOME/.local/bin:$PATH\" muxd endpoint'", 411 "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" muxd endpoint'",
406 r.ssh_cmd, 412 r.ssh_cmd,
407 ); 413 );
408 } 414 }
test/e2e.sh
Old New
@@ -3180,11 +3180,13 @@ ok "a burst of bells in one chunk reaches the host as exactly one"
3180 # ssh`: this box belongs to someone who is probably ssh'd into something, 3180 # ssh`: this box belongs to someone who is probably ssh'd into something,
3181 # and a warm attach's entire claim is that no ssh existed. 3181 # and a warm attach's entire claim is that no ssh existed.
3182 # 3182 #
3183 # The contract it depends on: the client spawns `ssh HOST CMD...` as 3183 # The contract it depends on — real ssh's own: everything after HOST is
3184 # separate shell words, so dropping HOST is a `shift` and the rest is a 3184 # joined with spaces and handed to the REMOTE user's shell. The shim
3185 # command. A future client that quoted the remote command instead would 3185 # models that with `sh -c "$*"`, which is what lets the client's quoted
3186 # arrive as two arguments whose second is one word, and `exec` would fail 3186 # remote command (the PATH-suffix form recipeFor builds) run here the
3187 # to find a program by that name — loudly, at 127. 3187 # same way sshd would run it. A bare `exec "$@"` was enough while the
3188 # command was plain argv; it execs a program literally named
3189 # `PATH=... muxd endpoint` now, and dies at 127 without an announce.
3188 # 3190 #
3189 # The arity guard covers the case that would NOT be loud: `ssh HOST` with 3191 # The arity guard covers the case that would NOT be loud: `ssh HOST` with
3190 # no command at all. There, `shift` empties "$@" and a bare `exec` is a 3192 # no command at all. There, `shift` empties "$@" and a bare `exec` is a
@@ -3200,7 +3202,7 @@ cat > "$SSHIM_DIR/ssh" <<'SHIM'
3200 [ $# -ge 2 ] || exit 97 3202 [ $# -ge 2 ] || exit 97
3201 echo $$ >> "${SSHIM_PIDLOG:?}" 3203 echo $$ >> "${SSHIM_PIDLOG:?}"
3202 shift 3204 shift
3203 exec "$@" 3205 exec /bin/sh -c "$*"
3204 SHIM 3206 SHIM
3205 chmod +x "$SSHIM_DIR/ssh" 3207 chmod +x "$SSHIM_DIR/ssh"
3206 # The PATH every scenario below runs the client under: the shim shadows any 3208 # The PATH every scenario below runs the client under: the shim shadows any