1e157a6f
feat: mux askpass is a mode named by the variable ssh hands it
a73x 2026-08-30 10:59
Commit message
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 { |