2de1f8e3
fix: remote ~/.local/bin is a PATH fallback, not a shadow
a73x 2026-08-18 19:14
Commit message
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 |