a73x

a514d95d

feat: a wall dial's ssh sends its prompts to mux, not to /dev/tty

a73x   2026-08-30 10:59

Commit message
feat: a wall dial's ssh sends its prompts to mux, not to /dev/tty

SSH_ASKPASS_REQUIRE=force is the whole trick: without it ssh reaches for a
helper only when there is no tty, and a wall has one. `HandoffTarget` gains
the socket and the binary that carries it, and both default to "ssh keeps
its own prompts" — the entry dial has a person at a bare prompt, a poll
spells BatchMode and asks nothing, and a wall dial that forgets them reads
/dev/tty under the alternate screen, which is loud in the way a wrong
default has to be.

Two fields rather than one because `spawn.selfExe` lives under src/cli/,
which a client module may not name; the wall fills both together.

The test's oracle is the child's own environment, read out of the process
that was actually exec'd — asserting on the map we built would pin the map
and say nothing about what the child saw.

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