a73x

e99d4a1f

refactor: a prompt is attributed to the ssh mux spawned, however many forks sit between

a73x   2026-08-30 10:59

Commit message
refactor: a prompt is attributed to the ssh mux spawned, however many forks sit between

ssh execs its helper directly, so the parent is ssh in production — but a
shell can sit in between (a stand-in's command substitution, a real ssh
whose ProxyCommand is one), and an attribution that breaks on one extra
fork answers 0 exactly where a wall needs a name. The invariant is the far
end: the ssh a dial spawned is a child of THIS process, and nothing else on
the path is. So the walk climbs until it finds our own child.

Driven by a table rather than by forks, so shapes no test could arrange —
two shells deep, a peer that is nobody's child of ours, a tree that loops —
are all pinned; the reader itself is pinned against the OS's own ppid.

The hub's header says what it does about prompts: nothing, deliberately.

src/client/askpass.zig
Old New
@@ -93,10 +93,10 @@ pub const Prompt = struct {
93 text: [prompt_max]u8 = undefined, 93 text: [prompt_max]u8 = undefined,
94 text_len: usize = 0, 94 text_len: usize = 0,
95 kind: Kind = .secret, 95 kind: Kind = .secret,
96 /// pid of the ssh whose helper connected: `SO_PEERCRED` names the 96 /// The dial this prompt belongs to: the ancestor of the connecting
97 /// helper, and the helper's parent IS ssh, which execs it directly. 97 /// helper that is THIS process's child, which is the ssh a wall dial
98 /// 0 when the walk failed — attribution is a convenience, never a 98 /// spawned (`dialOwner`). 0 when the walk found none — attribution is
99 /// condition for answering. 99 /// a convenience, never a condition for answering.
100 ssh_pid: std.posix.pid_t = 0, 100 ssh_pid: std.posix.pid_t = 0,
101 101
102 pub fn slice(self: *const Prompt) []const u8 { 102 pub fn slice(self: *const Prompt) []const u8 {
@@ -505,12 +505,14 @@ fn dialOwner(
505 parent: *const fn (std.posix.pid_t) std.posix.pid_t, 505 parent: *const fn (std.posix.pid_t) std.posix.pid_t,
506 ) std.posix.pid_t { 506 ) std.posix.pid_t {
507 // Not simply the helper's parent, which is what ssh execing its helper 507 // Not simply the helper's parent, which is what ssh execing its helper
508 // directly makes it. A shell can sit in between — a test's stand-in 508 // directly makes it. More can sit in between: under ProxyJump (and its
509 // does, and so does a real ssh whose ProxyCommand is one — and an 509 // `ProxyCommand ssh -W` spelling) the INNER ssh inherits `SSH_ASKPASS`
510 // attribution that breaks on one extra fork returns 0 exactly when a 510 // and prompts for the jump host through it, so the chain is helper →
511 // wall needs a name. What is INVARIANT is the other end: the ssh a 511 // inner ssh → outer ssh → us; a test's stand-in adds a shell the same
512 // dial spawned is a child of this process, and nothing else on the 512 // way. An attribution that breaks on one extra fork returns 0 exactly
513 // path is. 513 // when a wall needs a name. What is INVARIANT is the other end: the
514 // ssh a dial spawned is a child of this process, and nothing else on
515 // the path is.
514 var at = peer; 516 var at = peer;
515 var steps: usize = 0; 517 var steps: usize = 0;
516 while (at > 0 and steps < ancestor_max) : (steps += 1) { 518 while (at > 0 and steps < ancestor_max) : (steps += 1) {
@@ -729,11 +731,12 @@ const FakeTree = struct {
729 }; 731 };
730 732
731 test "askpass: a helper two shells below the ssh we spawned is still that ssh's" { 733 test "askpass: a helper two shells below the ssh we spawned is still that ssh's" {
732 // ssh execs its helper directly, so production is one step. A stand-in 734 // ssh execs its helper directly, so the plain case is one step. A
733 // — and a real ssh whose ProxyCommand is a shell — puts more in 735 // ProxyJump is two — the inner ssh inherits `SSH_ASKPASS` and prompts
734 // between, and an attribution that breaks on one extra fork answers 0 736 // for the jump host through it — and a test's stand-in adds a shell.
735 // exactly where a wall needs a name. The invariant is the far end: the 737 // An attribution that breaks on one extra fork answers 0 exactly where
736 // ssh a dial spawned is OUR child, and nothing else on the path is. 738 // a wall needs a name. The invariant is the far end: the ssh a dial
739 // spawned is OUR child, and nothing else on the path is.
737 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(100, 7, FakeTree.parent)); 740 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(100, 7, FakeTree.parent));
738 // The direct case, unchanged. 741 // The direct case, unchanged.
739 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(97, 7, FakeTree.parent)); 742 try std.testing.expectEqual(@as(std.posix.pid_t, 97), dialOwner(97, 7, FakeTree.parent));
src/client/webhub.zig
Old New
@@ -8,6 +8,10 @@
8 //! Localhost only, by construction: the hub binds 127.0.0.1 and there is 8 //! Localhost only, by construction: the hub binds 127.0.0.1 and there is
9 //! no flag to change that in v1 — remote viewing is `ssh -L`, 9 //! no flag to change that in v1 — remote viewing is `ssh -L`,
10 //! authenticated by ssh like everything else in this project. 10 //! authenticated by ssh like everything else in this project.
11 //!
12 //! No popup, so no prompts: every target here keeps `ask_sock` null and a
13 //! birth on a host that wants a password fails with ssh's own reason on
14 //! `/tiles`. A browser is not somewhere to type one.
11 15
12 const std = @import("std"); 16 const std = @import("std");
13 const proto = @import("protocol"); 17 const proto = @import("protocol");
src/tui/wallview.zig
Old New
@@ -1694,8 +1694,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1694 } else |_| {} 1694 } else |_| {}
1695 } 1695 }
1696 } 1696 }
1697 // Before the raw-mode teardown below, so the socket is gone whichever 1697 // The ERROR returns between here and the keys loop, and those only:
1698 // way this function leaves. 1698 // this function ends in `std.posix.exit`, which reaches no defer at
1699 // all. The normal way out is `retire` at the tail, and the two are not
1700 // interchangeable — see there.
1699 defer if (shared.prompts) |l| l.stop(); 1701 defer if (shared.prompts) |l| l.stop();
1700 1702
1701 // Allocated at CAPACITY, not at length: the pump threads are detached 1703 // Allocated at CAPACITY, not at length: the pump threads are detached