a73x

1e157a6f

feat: mux askpass is a mode named by the variable ssh hands it

a73x   2026-08-30 10:59

Commit message
feat: mux askpass is a mode named by the variable ssh hands it

There is no word to give it: ssh execs $SSH_ASKPASS with the prompt as
argv[1] and nothing else. So the variable is the word — and it exists in
exactly one process tree, the ssh a wall dial spawned. A mode word still
wins over it, and the arity is the other half of the guard: ssh passes one
argument and never two.

helperMain takes an fd rather than a File, which is this repo's spelling
for "the code under test does not own stdout".

build.zig
Old New
@@ -308,7 +308,7 @@ const mod_table = [_]ModSpec{
308 // ---- layer 6: the one binary ---- 308 // ---- layer 6: the one binary ----
309 // Four words, one image. Nothing but the mode letter lives here, which 309 // Four words, one image. Nothing but the mode letter lives here, which
310 // is why it may sit above every other main without owning any of them. 310 // is why it may sit above every other main without owning any of them.
311 .{ .name = "mux", .path = "src/cli/mux.zig", .layer = 6, .link_libc = true, .imports = &.{ "daemon_main", "agent_main", "hub_main", "client_main" } }, 311 .{ .name = "mux", .path = "src/cli/mux.zig", .layer = 6, .link_libc = true, .imports = &.{ "daemon_main", "agent_main", "hub_main", "client_main", "askpass" } },
312 }; 312 };
313 313
314 /// Comptime row lookup. Every hand-written module name in this file goes 314 /// Comptime row lookup. Every hand-written module name in this file goes
src/cli/mux.zig
Old New
@@ -5,21 +5,31 @@
5 //! second flag grammar here, no argv[0] dispatch and no alias — the four 5 //! second flag grammar here, no argv[0] dispatch and no alias — the four
6 //! programs became four words, not four names for one file. A word that is 6 //! programs became four words, not four names for one file. A word that is
7 //! not a mode is a TARGET: `mux run` names a host called "run". 7 //! not a mode is a TARGET: `mux run` names a host called "run".
8 //!
9 //! The fifth mode has no word, because ssh gives it none: `SSH_ASKPASS` is
10 //! exec'd with the prompt as argv[1] and nothing else. Its variable is the
11 //! word instead, and it is set in exactly one process tree.
8 const std = @import("std"); 12 const std = @import("std");
9 const daemon = @import("daemon_main"); 13 const daemon = @import("daemon_main");
10 const agent = @import("agent_main"); 14 const agent = @import("agent_main");
11 const hub = @import("hub_main"); 15 const hub = @import("hub_main");
12 const client = @import("client_main"); 16 const client = @import("client_main");
17 const askpass = @import("askpass");
13 18
14 /// The whole grammar, as a value, so the one decision this file makes can be 19 /// The whole grammar, as a value, so the one decision this file makes can be
15 /// asked without a process to exit from. 20 /// asked without a process to exit from.
16 const Mode = enum { daemon, agent, hub, client }; 21 const Mode = enum { daemon, agent, hub, client, askpass };
17 22
18 fn modeOf(args: []const [:0]const u8) Mode { 23 fn modeOf(args: []const [:0]const u8, ask_sock: ?[]const u8) Mode {
19 if (args.len < 2) return .client; 24 if (args.len < 2) return .client;
20 if (std.mem.eql(u8, args[1], "d")) return .daemon; 25 if (std.mem.eql(u8, args[1], "d")) return .daemon;
21 if (std.mem.eql(u8, args[1], "a")) return .agent; 26 if (std.mem.eql(u8, args[1], "a")) return .agent;
22 if (std.mem.eql(u8, args[1], "web")) return .hub; 27 if (std.mem.eql(u8, args[1], "web")) return .hub;
28 // A mode WORD wins over the variable: the ssh a wall dial spawned runs
29 // a remote `mux d endpoint`, and a hand-typed `mux d` inside that tree
30 // must not turn into somebody's password prompt. The arity is the rest
31 // of it — ssh execs its helper with exactly one argument.
32 if (ask_sock != null and args.len == 2) return .askpass;
23 return .client; 33 return .client;
24 } 34 }
25 35
@@ -36,20 +46,47 @@ pub fn main() !u8 {
36 // and whose [1..] is its own line — the shape every one of these parsers 46 // and whose [1..] is its own line — the shape every one of these parsers
37 // already reads, from back when [0] was the program name. The client 47 // already reads, from back when [0] was the program name. The client
38 // gets argv whole, because it has no word of its own to skip. 48 // gets argv whole, because it has no word of its own to skip.
39 return switch (modeOf(args)) { 49 const ask_sock = std.posix.getenv(askpass.sock_env);
50 const ask_kind = askpass.Kind.of(std.posix.getenv(askpass.prompt_env));
51 return switch (modeOf(args, ask_sock)) {
40 .daemon => daemon.main(args[1..]), 52 .daemon => daemon.main(args[1..]),
41 .agent => agent.main(args[1..]), 53 .agent => agent.main(args[1..]),
42 .hub => hub.main(args[1..]), 54 .hub => hub.main(args[1..]),
43 .client => client.main(args), 55 .client => client.main(args),
56 // ssh reads the answer off this fd and logs in with it, so nothing
57 // else may ever be written there. The second variable is ssh's own
58 // word for WHAT it is asking, which decides whether the wall stars
59 // the answer; both are read here, where the environment is the
60 // subject.
61 .askpass => askpass.helperMain(args[1], ask_sock.?, ask_kind, std.posix.STDOUT_FILENO),
44 }; 62 };
45 } 63 }
46 64
47 test "modeOf: the three mode words, and nothing else" { 65 test "modeOf: the three mode words, and nothing else" {
48 try std.testing.expectEqual(Mode.daemon, modeOf(&.{ "mux", "d", "start" })); 66 try std.testing.expectEqual(Mode.daemon, modeOf(&.{ "mux", "d", "start" }, null));
49 try std.testing.expectEqual(Mode.agent, modeOf(&.{ "mux", "a", "status" })); 67 try std.testing.expectEqual(Mode.agent, modeOf(&.{ "mux", "a", "status" }, null));
50 try std.testing.expectEqual(Mode.hub, modeOf(&.{ "mux", "web" })); 68 try std.testing.expectEqual(Mode.hub, modeOf(&.{ "mux", "web" }, null));
51 try std.testing.expectEqual(Mode.client, modeOf(&.{"mux"})); 69 try std.testing.expectEqual(Mode.client, modeOf(&.{"mux"}, null));
52 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box" })); 70 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box" }, null));
71 }
72
73 test "modeOf: the helper is named by ssh's variable, and a mode word still wins" {
74 const sock = "/run/user/1000/mux-ask-7.sock";
75 // The whole of what ssh hands a helper: one argument, the prompt.
76 try std.testing.expectEqual(Mode.askpass, modeOf(&.{ "mux", "box's password: " }, sock));
77 // Without the variable the SAME argv is a host called "box's password: ",
78 // which is what makes a hand-typed `mux HOST` unreachable from here.
79 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box's password: " }, null));
80 // Inside the tree, a mode word is still a mode: the remote command the
81 // wall's ssh runs is `mux d endpoint`, and a user typing one at that
82 // ssh's far end would otherwise get a prompt helper.
83 try std.testing.expectEqual(Mode.daemon, modeOf(&.{ "mux", "d", "endpoint" }, sock));
84 try std.testing.expectEqual(Mode.agent, modeOf(&.{ "mux", "a", "status" }, sock));
85 try std.testing.expectEqual(Mode.hub, modeOf(&.{ "mux", "web" }, sock));
86 // Arity: ssh passes one argument and never two, so a longer line in a
87 // tree that happens to carry the variable is the client it looks like.
88 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box", "-A" }, sock));
89 try std.testing.expectEqual(Mode.client, modeOf(&.{"mux"}, sock));
53 } 90 }
54 91
55 test "modeOf: `run` is a host, and no daemon verb has a top-level alias" { 92 test "modeOf: `run` is a host, and no daemon verb has a top-level alias" {
@@ -61,8 +98,8 @@ test "modeOf: `run` is a host, and no daemon verb has a top-level alias" {
61 // What is left is the rule with no exception — a word that is not a mode 98 // What is left is the rule with no exception — a word that is not a mode
62 // is a transport the user named, and `mux run --resume-fd 5` is refused 99 // is a transport the user named, and `mux run --resume-fd 5` is refused
63 // by the client's own parser with the page that lists the modes. 100 // by the client's own parser with the page that lists the modes.
64 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run" })); 101 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run" }, null));
65 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run", "--resume-fd", "5" })); 102 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run", "--resume-fd", "5" }, null));
66 } 103 }
67 104
68 test { 105 test {