a514d95d
feat: a wall dial's ssh sends its prompts to mux, not to /dev/tty
a73x 2026-08-30 10:59
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -269,7 +269,7 @@ const mod_table = [_]ModSpec{ | |||
| 269 | // and `resolveHost` sits here rather than in either front so the CLI | 269 | // and `resolveHost` sits here rather than in either front so the CLI |
| 270 | // wall and the browser hub resolve a host line the same way. Nothing | 270 | // wall and the browser hub resolve a host line the same way. Nothing |
| 271 | // here WRITES that file — `wall_host.recordHost` and `hub_main` do. | 271 | // here WRITES that file — `wall_host.recordHost` and `hub_main` do. |
| 272 | .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "hosts", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 272 | .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "hosts", "xdg", "sockpath", "askpass" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 273 | // The daemon entrypoint loads the key and constructs the listener, so | 273 | // The daemon entrypoint loads the key and constructs the listener, so |
| 274 | // it needs quic/quic_server directly rather than through the server. | 274 | // it needs quic/quic_server directly rather than through the server. |
| 275 | // `muxd endpoint` prints the announce line handoff spells; sockpath is | 275 | // `muxd endpoint` prints the announce line handoff spells; sockpath is |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,7 @@ const proto = @import("protocol"); | |||
| 22 | const TmpDir = @import("testtmp").TmpDir; | 22 | const TmpDir = @import("testtmp").TmpDir; |
| 23 | const quic = @import("quic"); | 23 | const quic = @import("quic"); |
| 24 | const handoff = @import("handoff"); | 24 | const handoff = @import("handoff"); |
| 25 | const askpass = @import("askpass"); | ||
| 25 | const hosts = @import("hosts"); | 26 | const hosts = @import("hosts"); |
| 26 | const xdg = @import("xdg"); | 27 | const xdg = @import("xdg"); |
| 27 | const sockpath = @import("sockpath"); | 28 | const sockpath = @import("sockpath"); |
| @@ -184,6 +185,8 @@ pub const QuicTarget = struct { | |||
| 184 | /// mux_main, precisely so the reconnect loop re-runs the WHOLE flow — a | 185 | /// mux_main, precisely so the reconnect loop re-runs the WHOLE flow — a |
| 185 | /// daemon restart invalidates the cached port, and only a fresh ssh fetch | 186 | /// daemon restart invalidates the cached port, and only a fresh ssh fetch |
| 186 | /// can heal that. | 187 | /// can heal that. |
| 188 | pub const AskPass = struct { sock: []const u8, exe: []const u8 }; | ||
| 189 | |||
| 187 | pub const HandoffTarget = struct { | 190 | pub const HandoffTarget = struct { |
| 188 | /// The word the user typed. ssh's business entirely (aliases, `user@`, | 191 | /// The word the user typed. ssh's business entirely (aliases, `user@`, |
| 189 | /// ProxyJump); the QUIC dial uses `handoff.dialHost(host)`. | 192 | /// ProxyJump); the QUIC dial uses `handoff.dialHost(host)`. |
| @@ -248,6 +251,29 @@ pub const HandoffTarget = struct { | |||
| 248 | /// missing line is a wait that says nothing while a spurious one | 251 | /// missing line is a wait that says nothing while a spurious one |
| 249 | /// corrupts a paint nobody can repair from. | 252 | /// corrupts a paint nobody can repair from. |
| 250 | narrate: bool = false, | 253 | narrate: bool = false, |
| 254 | /// Where this dial's ssh sends its prompts, and what carries them. | ||
| 255 | /// | ||
| 256 | /// Null is "ssh keeps its own", which is the OLD behaviour and a | ||
| 257 | /// visible one: the entry dial's user is at a bare prompt with a | ||
| 258 | /// /dev/tty right there, and a poll spells `BatchMode` and asks | ||
| 259 | /// nothing. A wall dial that forgets these two reads /dev/tty under | ||
| 260 | /// the alternate screen — a wait nobody can see, which is loud in the | ||
| 261 | /// way a wrong default has to be. | ||
| 262 | /// | ||
| 263 | /// Two fields because `spawn.selfExe` lives under `src/cli/`, which a | ||
| 264 | /// client module may not name (folder rule 1). The wall is tui and may, | ||
| 265 | /// so it fills both, together, in one place. | ||
| 266 | ask_sock: ?[]const u8 = null, | ||
| 267 | ask_exe: []const u8 = "", | ||
| 268 | |||
| 269 | /// Both or neither. `SSH_ASKPASS` pointing at nothing makes ssh FAIL | ||
| 270 | /// every prompt rather than ask one, which is worse than the tty read | ||
| 271 | /// this replaces. | ||
| 272 | pub fn askpassFor(self: HandoffTarget) ?AskPass { | ||
| 273 | const sock = self.ask_sock orelse return null; | ||
| 274 | if (self.ask_exe.len == 0) return null; | ||
| 275 | return .{ .sock = sock, .exe = self.ask_exe }; | ||
| 276 | } | ||
| 251 | 277 | ||
| 252 | /// The recipe→target literal: a field added above is added here, not | 278 | /// The recipe→target literal: a field added above is added here, not |
| 253 | /// at every dial. `asked` is a parameter with NO default though | 279 | /// at every dial. `asked` is a parameter with NO default though |
| @@ -373,14 +399,15 @@ pub const Transport = struct { | |||
| 373 | /// remote host.` would be exactly the foreign writer this removed. | 399 | /// remote host.` would be exactly the foreign writer this removed. |
| 374 | narrate: bool = false, | 400 | narrate: bool = false, |
| 375 | 401 | ||
| 376 | /// The handoff's coordination ssh, stderr included. | 402 | /// The handoff's coordination ssh, stderr included. `ask` non-null is |
| 377 | fn spawnPipe(alloc: std.mem.Allocator, argv: []const []const u8) !std.process.Child { | 403 | /// what turns its prompts into frames on a socket. |
| 378 | return spawnWithStderr(alloc, argv, .Pipe); | 404 | fn spawnPipe(alloc: std.mem.Allocator, argv: []const []const u8, ask: ?AskPass) !std.process.Child { |
| 405 | return spawnWithStderr(alloc, argv, .Pipe, ask); | ||
| 379 | } | 406 | } |
| 380 | 407 | ||
| 381 | /// `--via CMD` is the user's OWN program in the user's own terminal. | 408 | /// `--via CMD` is the user's OWN program in the user's own terminal. |
| 382 | fn spawnVia(alloc: std.mem.Allocator, argv: []const []const u8) !std.process.Child { | 409 | fn spawnVia(alloc: std.mem.Allocator, argv: []const []const u8) !std.process.Child { |
| 383 | return spawnWithStderr(alloc, argv, .Inherit); | 410 | return spawnWithStderr(alloc, argv, .Inherit, null); |
| 384 | } | 411 | } |
| 385 | 412 | ||
| 386 | /// One exec'd child, argv and never a shell line. | 413 | /// One exec'd child, argv and never a shell line. |
| @@ -388,6 +415,7 @@ pub const Transport = struct { | |||
| 388 | alloc: std.mem.Allocator, | 415 | alloc: std.mem.Allocator, |
| 389 | argv: []const []const u8, | 416 | argv: []const []const u8, |
| 390 | stderr_behavior: std.process.Child.StdIo, | 417 | stderr_behavior: std.process.Child.StdIo, |
| 418 | ask: ?AskPass, | ||
| 391 | ) !std.process.Child { | 419 | ) !std.process.Child { |
| 392 | // The product runs `ssh` and the user's own `--via` program, and | 420 | // The product runs `ssh` and the user's own `--via` program, and |
| 393 | // neither is worth a shell's expansions between us and it. | 421 | // neither is worth a shell's expansions between us and it. |
| @@ -403,6 +431,17 @@ pub const Transport = struct { | |||
| 403 | // row quotes — and a wall's alternate screen admits no foreign | 431 | // row quotes — and a wall's alternate screen admits no foreign |
| 404 | // writer. `--via`'s stderr stays the user's, inherited. | 432 | // writer. `--via`'s stderr stays the user's, inherited. |
| 405 | child.stderr_behavior = stderr_behavior; | 433 | child.stderr_behavior = stderr_behavior; |
| 434 | // `REQUIRE=force` is the whole trick: without it ssh uses its helper | ||
| 435 | // only when there is no tty, and a wall has one. The map lives to | ||
| 436 | // the `spawn` below and no longer — the child's envp is built there. | ||
| 437 | var env: ?std.process.EnvMap = if (ask == null) null else try std.process.getEnvMap(alloc); | ||
| 438 | defer if (env) |*e| e.deinit(); | ||
| 439 | if (ask) |a| { | ||
| 440 | try env.?.put("SSH_ASKPASS", a.exe); | ||
| 441 | try env.?.put("SSH_ASKPASS_REQUIRE", "force"); | ||
| 442 | try env.?.put(askpass.sock_env, a.sock); | ||
| 443 | child.env_map = &env.?; | ||
| 444 | } | ||
| 406 | try child.spawn(); | 445 | try child.spawn(); |
| 407 | return child; | 446 | return child; |
| 408 | } | 447 | } |
| @@ -572,7 +611,7 @@ pub const Transport = struct { | |||
| 572 | // such a target a dial that starts nothing, which is what | 611 | // such a target a dial that starts nothing, which is what |
| 573 | // the field says it is. | 612 | // the field says it is. |
| 574 | const argv = if (h.asked and h.asked_argv.len > 0) h.asked_argv else h.ssh_argv; | 613 | const argv = if (h.asked and h.asked_argv.len > 0) h.asked_argv else h.ssh_argv; |
| 575 | child = spawnPipe(alloc, argv) catch |err| { | 614 | child = spawnPipe(alloc, argv, h.askpassFor()) catch |err| { |
| 576 | last_err = err; | 615 | last_err = err; |
| 577 | break :blk .failed; | 616 | break :blk .failed; |
| 578 | }; | 617 | }; |
| @@ -1845,7 +1884,7 @@ test "spawnPipe: the child is exec'd from a copy — an argv freed after spawn s | |||
| 1845 | const argv = try alloc.alloc([]const u8, 2); | 1884 | const argv = try alloc.alloc([]const u8, 2); |
| 1846 | argv[0] = try alloc.dupe(u8, "/bin/echo"); | 1885 | argv[0] = try alloc.dupe(u8, "/bin/echo"); |
| 1847 | argv[1] = try alloc.dupe(u8, "copied"); | 1886 | argv[1] = try alloc.dupe(u8, "copied"); |
| 1848 | var child = try Transport.spawnPipe(alloc, argv); | 1887 | var child = try Transport.spawnPipe(alloc, argv, null); |
| 1849 | for (argv) |w| alloc.free(w); | 1888 | for (argv) |w| alloc.free(w); |
| 1850 | alloc.free(argv); | 1889 | alloc.free(argv); |
| 1851 | // Reuse the freed pages before reading, so a std that kept the pointer | 1890 | // Reuse the freed pages before reading, so a std that kept the pointer |
| @@ -1863,6 +1902,51 @@ test "spawnPipe: the child is exec'd from a copy — an argv freed after spawn s | |||
| 1863 | _ = try child.kill(); | 1902 | _ = try child.kill(); |
| 1864 | } | 1903 | } |
| 1865 | 1904 | ||
| 1905 | test "spawnPipe: an askpass dial hands ssh the three variables, and a plain dial hands it none" { | ||
| 1906 | const alloc = std.testing.allocator; | ||
| 1907 | var tmp = try TmpDir.make(); | ||
| 1908 | defer tmp.cleanup(); | ||
| 1909 | |||
| 1910 | // The oracle is the CHILD's own environment, read out of the process | ||
| 1911 | // that was actually exec'd. Asserting on the EnvMap we built would pin | ||
| 1912 | // the map and say nothing about whether the child ever saw it. | ||
| 1913 | const prog = try std.fmt.allocPrint(alloc, "{s}/dumpenv", .{tmp.path()}); | ||
| 1914 | defer alloc.free(prog); | ||
| 1915 | var body_buf: [512]u8 = undefined; | ||
| 1916 | const body = try std.fmt.bufPrint(&body_buf, "#!/bin/sh\nenv > {s}/$1\n", .{tmp.path()}); | ||
| 1917 | try std.fs.cwd().writeFile(.{ .sub_path = prog, .data = body, .flags = .{ .mode = 0o755 } }); | ||
| 1918 | |||
| 1919 | { | ||
| 1920 | var c = try Transport.spawnPipe(alloc, &.{ prog, "with" }, .{ | ||
| 1921 | .sock = "/run/ask.sock", | ||
| 1922 | .exe = "/opt/mux", | ||
| 1923 | }); | ||
| 1924 | _ = try c.wait(); | ||
| 1925 | } | ||
| 1926 | var with_buf: [8192]u8 = undefined; | ||
| 1927 | const with = try shimSaid(tmp.path(), "with", &with_buf); | ||
| 1928 | // `force` and not merely a helper: without it ssh uses SSH_ASKPASS only | ||
| 1929 | // when there is no tty, and the wall this runs under has one. | ||
| 1930 | try std.testing.expect(std.mem.indexOf(u8, with, "SSH_ASKPASS_REQUIRE=force") != null); | ||
| 1931 | try std.testing.expect(std.mem.indexOf(u8, with, "SSH_ASKPASS=/opt/mux") != null); | ||
| 1932 | try std.testing.expect(std.mem.indexOf(u8, with, "MUX_ASKPASS_SOCK=/run/ask.sock") != null); | ||
| 1933 | // The rest of the environment is still the parent's: ssh reads | ||
| 1934 | // SSH_AUTH_SOCK, HOME and TERM out of it, and a map built from nothing | ||
| 1935 | // would break the agent forwarding this dial also carries. | ||
| 1936 | try std.testing.expect(std.mem.indexOf(u8, with, "PATH=") != null); | ||
| 1937 | |||
| 1938 | { | ||
| 1939 | var c = try Transport.spawnPipe(alloc, &.{ prog, "without" }, null); | ||
| 1940 | _ = try c.wait(); | ||
| 1941 | } | ||
| 1942 | var out_buf: [8192]u8 = undefined; | ||
| 1943 | const without = try shimSaid(tmp.path(), "without", &out_buf); | ||
| 1944 | // A poll and the entry dial come through here too, and either one | ||
| 1945 | // carrying a socket would be a prompt raised where no popup can appear. | ||
| 1946 | try std.testing.expect(std.mem.indexOf(u8, without, "MUX_ASKPASS_SOCK") == null); | ||
| 1947 | try std.testing.expect(std.mem.indexOf(u8, without, "SSH_ASKPASS_REQUIRE") == null); | ||
| 1948 | } | ||
| 1949 | |||
| 1866 | test "the announce reader consumes the newline and NOT the byte after it" { | 1950 | test "the announce reader consumes the newline and NOT the byte after it" { |
| 1867 | // The property the announce-then-frames protocol stands on, pinned on | 1951 | // The property the announce-then-frames protocol stands on, pinned on |
| 1868 | // the one reader the product still has. A buffered read here would take | 1952 | // the one reader the product still has. A buffered read here would take |