a73x

d1c39002

feat: the wall answers ssh's prompts in a popup

a73x   2026-08-30 10:59

Commit message
feat: the wall answers ssh's prompts in a popup

`asking` is a layer of the prefix filter over EVERYTHING, the picker
included: a prompt is not a mode the user chose, it arrived, and it can
arrive while the picker's spelling editor holds a half-typed host. It gets
its own buffer for exactly that, and every byte is the popup's — the same
invariant `picking` holds, for a sharper reason: the bytes being typed at a
password box are a password, and one that reached a shell would be in that
shell's history in the clear.

The box is `paintAsk`, the picker's own chrome: ssh's question wrapped to
the width, then `> ****_`. The stars are per byte because ssh will not echo
and a box that showed nothing reads as one that is not listening; the
host-key question is shown in the clear, because the user is comparing a
fingerprint.

`askOn` is the ONE place a dial is pointed at the popup, and it is inside
`wall_pump.dial`. Both exclusions then hold by construction rather than by
a call somebody has to remember: the entry attach opens on the main thread
before any listener exists, and a poller never comes through there at all.

The listener starts on a TERMINAL only, under $XDG_RUNTIME_DIR and nowhere
else: a prompt socket in a shared /tmp is one any local user could answer.

build.zig
Old New
@@ -245,7 +245,7 @@ const mod_table = [_]ModSpec{
245 // must be drivable by anything holding a transport — the CLI client 245 // must be drivable by anything holding a transport — the CLI client
246 // today, a wall tile from the wall's phase-3 convergence on — and it 246 // today, a wall tile from the wall's phase-3 convergence on — and it
247 // names no transport type for exactly that reason (see its header). 247 // names no transport type for exactly that reason (see its header).
248 .{ .name = "interact", .path = "src/tui/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "keymap", "paint", "select" } }, 248 .{ .name = "interact", .path = "src/tui/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "keymap", "paint", "select", "askpass" } },
249 // quic and quic_server both: the listener it owns, and the vocabulary 249 // quic and quic_server both: the listener it owns, and the vocabulary
250 // it names directly (the key it loads, the idle default it falls back 250 // it names directly (the key it loads, the idle default it falls back
251 // to). xdg is for endpoint_req's lazy bind — the default key path, 251 // to). xdg is for endpoint_req's lazy bind — the default key path,
@@ -291,7 +291,7 @@ const mod_table = [_]ModSpec{
291 // typed-at session; the overlay machinery itself is interact's, shared — 291 // typed-at session; the overlay machinery itself is interact's, shared —
292 // and phase 3 promotes the tile into that core rather than growing a 292 // and phase 3 promotes the tile into that core rather than growing a
293 // second copy of it. 293 // second copy of it.
294 .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 294 .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout", "askpass", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
295 // ---- layer 5 ---- 295 // ---- layer 5 ----
296 // hosts owns the host grammar and the state file, so argv is parsed by 296 // hosts owns the host grammar and the state file, so argv is parsed by
297 // the SAME rules the CLI's own `mux hosts add` is — one grammar, not 297 // the SAME rules the CLI's own `mux hosts add` is — one grammar, not
src/client/askpass.zig
Old New
@@ -374,10 +374,10 @@ pub const Listener = struct {
374 } 374 }
375 } 375 }
376 376
377 /// Whether the box on screen still has an ssh behind it. The keyboard 377 /// Whether the box on screen still has an ssh behind it.
378 /// asks every pass: `serve` sets the phase back to idle on a hangup,
379 /// and the popup has to follow.
380 pub fn showing(self: *Listener) bool { 378 pub fn showing(self: *Listener) bool {
379 // The keyboard asks every pass: `serve` puts the phase back to idle
380 // on a hangup, and the popup has to follow it.
381 self.mu.lock(); 381 self.mu.lock();
382 defer self.mu.unlock(); 382 defer self.mu.unlock();
383 return self.phase == .pending or self.phase == .shown; 383 return self.phase == .pending or self.phase == .shown;
@@ -417,19 +417,18 @@ pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.
417 417
418 /// One prompt's bytes, made safe to paint: every control byte becomes a 418 /// One prompt's bytes, made safe to paint: every control byte becomes a
419 /// space. Returns how many were written. 419 /// space. Returns how many were written.
420 ///
421 /// The rule `handoff.Reason` already states for ssh's stderr, applied to
422 /// the other channel ssh has: a prompt is painted INSIDE a wall's
423 /// alternate screen with the box's own attribute, so an escape sequence in
424 /// one moves a cursor, sets a mode or fakes a row in somebody's tile. And
425 /// the text is not always ssh's: a keyboard-interactive prompt is the
426 /// SERVER's wording handed to the helper as argv[1], which OpenSSH does
427 /// not sanitize on this path.
428 ///
429 /// Run on BOTH ends — the helper before it sends, the listener on what it
430 /// received — because the wire is a socket any same-uid peer can reach,
431 /// and the end that paints is the end that must not trust.
432 pub fn foldControl(dst: []u8, src: []const u8) usize { 420 pub fn foldControl(dst: []u8, src: []const u8) usize {
421 // The rule `handoff.Reason` already states for ssh's stderr, applied to
422 // the other channel ssh has: a prompt is painted INSIDE a wall's
423 // alternate screen with the box's own attribute, so an escape sequence
424 // in one moves a cursor, sets a mode or fakes a row in somebody's tile.
425 // And the text is not always ssh's: a keyboard-interactive prompt is
426 // the SERVER's wording handed to the helper as argv[1], which OpenSSH
427 // does not sanitize on this path.
428 //
429 // Run on BOTH ends — the helper before it sends, the listener on what
430 // it received — because the wire is a socket any same-uid peer can
431 // reach, and the end that paints is the end that must not trust.
433 const n = @min(src.len, dst.len); 432 const n = @min(src.len, dst.len);
434 for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch; 433 for (src[0..n], 0..) |ch, i| dst[i] = if (ch < 0x20 or ch == 0x7f) ' ' else ch;
435 return n; 434 return n;
src/tui/interact.zig
Old New
@@ -28,6 +28,7 @@ const client_core = @import("client_core");
28 // Named `paint_mod` because paintOverlay holds a local ArrayList called 28 // Named `paint_mod` because paintOverlay holds a local ArrayList called
29 // `paint`, which a container-level `paint` would collide with. 29 // `paint`, which a container-level `paint` would collide with.
30 const paint_mod = @import("paint"); 30 const paint_mod = @import("paint");
31 const askpass = @import("askpass");
31 const select = @import("select"); 32 const select = @import("select");
32 // The command prefix byte. Owned by keymap because the dialler in client.zig 33 // The command prefix byte. Owned by keymap because the dialler in client.zig
33 // watches for it with no session and no terminal in the picture. 34 // watches for it with no session and no terminal in the picture.
@@ -122,6 +123,13 @@ pub const PrefixFilter = struct {
122 pick_forget, 123 pick_forget,
123 pick_add_open, 124 pick_add_open,
124 pick_close, 125 pick_close,
126 /// ssh asked something and the user answered. Borrows the filter's
127 /// buffer until the next feed, as `add_tile` does. An EMPTY answer
128 /// is an answer: a key whose passphrase is empty is a real key.
129 ask_answer: []const u8,
130 /// Esc or Ctrl-C on a prompt. ssh reads it as a refusal and gives
131 /// up, which is what the user just said.
132 ask_decline,
125 }; 133 };
126 134
127 pub const Out = struct { forward: []const u8, action: Action }; 135 pub const Out = struct { forward: []const u8, action: Action };
@@ -162,6 +170,47 @@ pub const PrefixFilter = struct {
162 /// shell. The driver paints; this owns which key means what. 170 /// shell. The driver paints; this owns which key means what.
163 picking: bool = false, 171 picking: bool = false,
164 172
173 /// ssh is waiting on an answer. Over EVERYTHING, `picking` and its
174 /// editor included: a prompt is not a mode the user chose, it arrived,
175 /// and it arrives while whatever was on screen is still on screen. Every
176 /// byte is the popup's, which is `picking`'s rule for `picking`'s
177 /// reason — a `j` typed at a password box must not reach a shell.
178 asking: bool = false,
179 /// What ssh said it was asking for. The filter carries it to the
180 /// painter and decides nothing: a secret is starred, a confirmation is
181 /// read back by the user, and a notice takes no answer at all.
182 kind: askpass.Kind = .secret,
183 /// Its OWN buffer, not `line`: a prompt can arrive while the picker's
184 /// spelling editor holds a half-typed host, and sharing one buffer
185 /// would eat that line to answer a question about another machine.
186 ask_line: [askpass.answer_max]u8 = undefined,
187 ask_len: usize = 0,
188
189 pub fn askLine(self: *const PrefixFilter) []const u8 {
190 return self.ask_line[0..self.ask_len];
191 }
192
193 /// Whether the answer is painted behind stars.
194 pub fn masks(self: *const PrefixFilter) bool {
195 return self.kind == .secret;
196 }
197
198 pub fn askOpen(self: *PrefixFilter, kind: askpass.Kind) void {
199 self.asking = true;
200 self.kind = kind;
201 self.ask_len = 0;
202 @memset(&self.ask_line, 0);
203 }
204
205 /// Called by the driver once the answer has been handed on.
206 pub fn askClose(self: *PrefixFilter) void {
207 // Which is what makes the zeroing a definite point: `feed` cannot
208 // do it — the action it returns borrows this buffer.
209 self.asking = false;
210 self.ask_len = 0;
211 @memset(&self.ask_line, 0);
212 }
213
165 pub fn promptLine(self: *const PrefixFilter) []const u8 { 214 pub fn promptLine(self: *const PrefixFilter) []const u8 {
166 return self.line[0..self.line_len]; 215 return self.line[0..self.line_len];
167 } 216 }
@@ -202,6 +251,39 @@ pub const PrefixFilter = struct {
202 var opened = false; 251 var opened = false;
203 var editing = false; 252 var editing = false;
204 for (buf, 0..) |b, i| { 253 for (buf, 0..) |b, i| {
254 // FIRST, and above the picker's own layer: ssh is blocked on
255 // this answer, and a byte that reached a session while a
256 // password box was up would be typed into a shell in the clear.
257 if (self.asking) {
258 switch (b) {
259 '\r', '\n' => {
260 self.asking = false;
261 return .{ .forward = buf[0..kept], .action = .{ .ask_answer = self.ask_line[0..self.ask_len] } };
262 },
263 // The Esc that heads an arrow key takes its tail with
264 // it, the way the spelling editor's does: the chunk
265 // ends here, so `[A` never reaches a shell. A prompt is
266 // not a popup you browse, so there is no key it has to
267 // stay open for.
268 0x1b, 0x03 => {
269 self.asking = false;
270 return .{ .forward = buf[0..kept], .action = .ask_decline };
271 },
272 0x7f, 0x08 => self.ask_len -|= 1,
273 // High bytes too, unlike the spelling editor's line: a
274 // password is bytes and ssh takes any of them, so a
275 // UTF-8 one must be typeable. They are never an Esc
276 // tail. The star count stays per byte, which is a
277 // character count only for ASCII — and a star count is
278 // feedback, not an inventory.
279 0x20...0x7e, 0x80...0xff => if (self.ask_len < askpass.answer_max) {
280 self.ask_line[self.ask_len] = b;
281 self.ask_len += 1;
282 },
283 else => {},
284 }
285 continue;
286 }
205 if (self.prompting) { 287 if (self.prompting) {
206 switch (b) { 288 switch (b) {
207 // Submit and cancel both end the read: an Esc that is 289 // Submit and cancel both end the read: an Esc that is
@@ -2235,6 +2317,112 @@ test "interact: an unknown command key is swallowed with its prefix" {
2235 try std.testing.expectEqualStrings("d", next.forward); 2317 try std.testing.expectEqualStrings("d", next.forward);
2236 } 2318 }
2237 2319
2320 test "asking: printable bytes accumulate into the answer" {
2321 var f: PrefixFilter = .{};
2322 f.askOpen(.secret);
2323 var chunk = "hunter2".*;
2324 const out = f.feed(&chunk);
2325 try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
2326 try std.testing.expectEqualStrings("hunter2", f.askLine());
2327 }
2328
2329 test "asking: Enter answers with the line, and backspace unspells it" {
2330 var f: PrefixFilter = .{};
2331 f.askOpen(.secret);
2332 var typed = "hunterX\x7f2\r".*;
2333 const out = f.feed(&typed);
2334 switch (out.action) {
2335 .ask_answer => |a| try std.testing.expectEqualStrings("hunter2", a),
2336 else => {
2337 std.debug.print("Enter on a prompt gave .{s}, not the line\n", .{@tagName(out.action)});
2338 return error.TestUnexpectedResult;
2339 },
2340 }
2341 // Closed on the answer: the next byte is the session's again.
2342 try std.testing.expect(!f.asking);
2343 }
2344
2345 test "asking: an EMPTY Enter is an answer, not a cancel" {
2346 // A key with no passphrase asks anyway, and ssh takes "" as the try.
2347 // Reporting `.none` here would leave ssh blocked on a socket forever
2348 // with the popup already gone.
2349 var f: PrefixFilter = .{};
2350 f.askOpen(.secret);
2351 var enter = "\r".*;
2352 const out = f.feed(&enter);
2353 switch (out.action) {
2354 .ask_answer => |a| try std.testing.expectEqualStrings("", a),
2355 else => {
2356 std.debug.print("an empty Enter gave .{s}, not an empty answer\n", .{@tagName(out.action)});
2357 return error.TestUnexpectedResult;
2358 },
2359 }
2360 }
2361
2362 test "asking: Esc and Ctrl-C both decline, and Esc eats an arrow's tail" {
2363 var f: PrefixFilter = .{};
2364 f.askOpen(.confirm);
2365 var esc = "\x1b[A".*;
2366 const out = f.feed(&esc);
2367 try std.testing.expectEqual(PrefixFilter.Action.ask_decline, out.action);
2368 // The `[A` left with the Esc. Forwarded, it would arrive at whatever
2369 // session had the focus as a cursor key nobody pressed.
2370 try std.testing.expectEqualStrings("", out.forward);
2371
2372 f.askOpen(.confirm);
2373 var ctrlc = "\x03".*;
2374 try std.testing.expectEqual(PrefixFilter.Action.ask_decline, f.feed(&ctrlc).action);
2375 try std.testing.expect(!f.asking);
2376 }
2377
2378 test "asking: NO byte reaches a session while ssh is waiting" {
2379 // The whole reason the mode eats prose: the bytes being typed at a
2380 // password box are a password, and one that reached a shell would be
2381 // in that shell's history in the clear.
2382 var f: PrefixFilter = .{};
2383 f.askOpen(.secret);
2384 var chunk = "abc\x03".*;
2385 const out = f.feed(&chunk);
2386 try std.testing.expectEqual(PrefixFilter.Action.ask_decline, out.action);
2387 try std.testing.expectEqual(@as(usize, 0), out.forward.len);
2388 }
2389
2390 test "asking: a prompt over the picker's editor keeps the half-typed spelling" {
2391 // ssh does not wait for the picker to close, so the two layers are live
2392 // at once. Sharing one buffer would answer the password prompt and lose
2393 // the host the user was halfway through naming.
2394 var f: PrefixFilter = .{};
2395 var open_editor = "\x1csa--sock /run/a".*;
2396 _ = f.feed(&open_editor);
2397 try std.testing.expectEqualStrings("--sock /run/a", f.promptLine());
2398
2399 f.askOpen(.secret);
2400 var pw = "s3cret\r".*;
2401 const out = f.feed(&pw);
2402 switch (out.action) {
2403 .ask_answer => |a| try std.testing.expectEqualStrings("s3cret", a),
2404 else => return error.TestUnexpectedResult,
2405 }
2406 // ...and the editor is still open, still holding what was typed at it.
2407 try std.testing.expect(f.prompting);
2408 try std.testing.expectEqualStrings("--sock /run/a", f.promptLine());
2409 var rest = ".sock\r".*;
2410 const done = f.feed(&rest);
2411 switch (done.action) {
2412 .add_tile => |t| try std.testing.expectEqualStrings("--sock /run/a.sock", t),
2413 else => return error.TestUnexpectedResult,
2414 }
2415 }
2416
2417 test "asking: an answer longer than the cap is cut, never overruns" {
2418 var f: PrefixFilter = .{};
2419 f.askOpen(.secret);
2420 var long: [askpass.answer_max + 8]u8 = @splat('x');
2421 const out = f.feed(&long);
2422 try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
2423 try std.testing.expectEqual(askpass.answer_max, f.askLine().len);
2424 }
2425
2238 test "interact: bytes with no prefix pass through untouched" { 2426 test "interact: bytes with no prefix pass through untouched" {
2239 var f: PrefixFilter = .{}; 2427 var f: PrefixFilter = .{};
2240 var chunk = "hello\x1b[A".*; 2428 var chunk = "hello\x1b[A".*;
src/tui/wall_picker.zig
Old New
@@ -10,6 +10,7 @@ const client = @import("client");
10 const hosts = @import("hosts"); 10 const hosts = @import("hosts");
11 const handoff = @import("handoff"); 11 const handoff = @import("handoff");
12 const interact = @import("interact"); 12 const interact = @import("interact");
13 const askpass = @import("askpass");
13 const wall_host = @import("wall_host.zig"); 14 const wall_host = @import("wall_host.zig");
14 const wall_layout = @import("wall_layout.zig"); 15 const wall_layout = @import("wall_layout.zig");
15 const wv = @import("wallview.zig"); 16 const wv = @import("wallview.zig");
@@ -177,6 +178,115 @@ pub const PickerAuto = struct {
177 } 178 }
178 }; 179 };
179 180
181 /// How many rows of ssh's question the box will show. Past it the question
182 /// is cut: a prompt that filled the terminal would leave nowhere to type.
183 pub const ask_rows_max: usize = 4;
184
185 /// The prompt box's rendered rows: the question, wrapped, and the answer
186 /// line under it. Rendered once and read twice — by the writer and by the
187 /// tests — so the box on the screen is the box the claims are about.
188 pub const AskBody = struct {
189 text: [ask_rows_max + 1][picker_row_max]u8 = undefined,
190 lens: [ask_rows_max + 1]usize = [_]usize{0} ** (ask_rows_max + 1),
191 n: usize = 0,
192
193 pub fn row(self: *const AskBody, i: usize) []const u8 {
194 return self.text[i][0..self.lens[i]];
195 }
196 };
197
198 /// Pure, so the whole box — the wrap, the cut, the stars — is assertable
199 /// with no terminal anywhere near it.
200 pub fn askRows(body: *AskBody, prompt: []const u8, answer: []const u8, kind: askpass.Kind, cols: u16) void {
201 const w = @min(@as(usize, cols), picker_row_max);
202 // One space of margin each side, the picker's own inset.
203 const room = w -| 2;
204 body.n = 0;
205 var rest = prompt;
206 while (body.n < ask_rows_max and rest.len > 0 and room > 0) {
207 const take = @min(rest.len, room);
208 body.text[body.n][0] = ' ';
209 @memcpy(body.text[body.n][1..][0..take], rest[0..take]);
210 body.lens[body.n] = take + 1;
211 body.n += 1;
212 rest = rest[take..];
213 }
214 // A notice is not a question — "Confirm user presence for key ..." —
215 // and ssh takes no answer for it: the box goes away when the touch
216 // lands and the helper is killed. An input line under it would invite
217 // an answer nothing would ever read.
218 if (kind == .notice) return;
219 // The cursor is drawn, not placed: the terminal's own caret is hidden
220 // for as long as a popup owns the screen, and a box with no visible
221 // insertion point reads as one that is not listening.
222 var line: [picker_row_max]u8 = undefined;
223 var n: usize = 0;
224 for ("> ") |c| {
225 line[n] = c;
226 n += 1;
227 }
228 // Stars per BYTE, and the count is deliberate: it is the only feedback
229 // a typist gets that the box took the key, and ssh is not going to
230 // echo it back.
231 const shown = @min(answer.len, w -| 4);
232 const mask = kind == .secret;
233 for (answer[answer.len - shown ..]) |c| {
234 line[n] = if (mask) '*' else c;
235 n += 1;
236 }
237 line[n] = '_';
238 n += 1;
239 const at = @min(body.n, ask_rows_max);
240 @memcpy(body.text[at][0..n], line[0..n]);
241 body.lens[at] = n;
242 body.n = at + 1;
243 }
244
245 /// ssh's question, over everything: the picker included, because a prompt
246 /// ARRIVES and the box that owns the keyboard is the one that came last.
247 pub fn paintAsk(shared: *Shared, prompt: []const u8, prefix: *const interact.PrefixFilter) void {
248 if (!shared.is_tty) return;
249 // Set before the lock and read under it, `paintPicker`'s ordering.
250 shared.ask_open.store(true, .release);
251 shared.paint_mu.lock();
252 defer shared.paint_mu.unlock();
253 const cols = shared.size.cols;
254 const rows = shared.size.rows;
255 const w: u16 = @min(cols, @as(u16, picker_row_max));
256 const left: u16 = (cols -| w) / 2;
257 var body: AskBody = .{};
258 askRows(&body, prompt, prefix.askLine(), prefix.kind, w);
259 const cramped = cols < picker_min_cols or rows < picker_min_rows;
260 const want: u16 = @intCast(@min(@as(usize, rows), body.n + 1));
261 const height: u16 = if (cramped) 1 else want;
262 const top: u16 = (rows -| height) / 2;
263 var out: [picker_frame_max]u8 = undefined;
264 var fbs = std.io.fixedBufferStream(&out);
265 const wr = fbs.writer();
266 wr.writeAll("\x1b[?25l") catch return;
267 // No host on the header: this client cannot say which tile's ssh is
268 // asking without reading a pump's own transport, and ssh's prompts
269 // name the box themselves (`user@host's password:`).
270 pickerLine(wr, top, left, w, " ssh");
271 if (!cramped) {
272 var r: u16 = 0;
273 while (r < body.n and top + 1 + r < rows) : (r += 1)
274 pickerLine(wr, top + 1 + r, left, w, body.row(r));
275 }
276 // The picker's screen is gone under this box, so its next paint owes a
277 // frame however unchanged its rows are.
278 shared.picker_stamp = 0;
279 proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {};
280 }
281
282 /// Whether this action is the prompt box's.
283 pub fn isAskAction(a: interact.PrefixFilter.Action) bool {
284 return switch (a) {
285 .ask_answer, .ask_decline => true,
286 else => false,
287 };
288 }
289
180 /// Whether this action is the picker's. The popup answers every one of 290 /// Whether this action is the picker's. The popup answers every one of
181 /// them, so the arms below it in `run` name them only to stay exhaustive. 291 /// them, so the arms below it in `run` name them only to stay exhaustive.
182 pub fn isPickAction(a: interact.PrefixFilter.Action) bool { 292 pub fn isPickAction(a: interact.PrefixFilter.Action) bool {
src/tui/wall_pump.zig
Old New
@@ -6,6 +6,7 @@
6 const std = @import("std"); 6 const std = @import("std");
7 const proto = @import("protocol"); 7 const proto = @import("protocol");
8 const client = @import("client"); 8 const client = @import("client");
9 const handoff = @import("handoff");
9 const interact = @import("interact"); 10 const interact = @import("interact");
10 const wv = @import("wallview.zig"); 11 const wv = @import("wallview.zig");
11 const EndReason = wv.EndReason; 12 const EndReason = wv.EndReason;
@@ -56,7 +57,7 @@ pub fn tilePaintBegin(ctx: ?*anyopaque) bool {
56 // box the keyboard had just drawn — and `picker_stamp` suppresses the 57 // box the keyboard had just drawn — and `picker_stamp` suppresses the
57 // identical repaint that would have repaired it, so the damage sticks 58 // identical repaint that would have repaired it, so the damage sticks
58 // until a key changes the frame. 59 // until a key changes the frame.
59 if (t.shared.picker_open.load(.acquire)) { 60 if (wv.popupOpen(t.shared)) {
60 t.shared.paint_mu.unlock(); 61 t.shared.paint_mu.unlock();
61 return false; 62 return false;
62 } 63 }
@@ -247,19 +248,46 @@ fn drainWake(t: *const Tile) void {
247 wv.drainBell(t.wake_r); 248 wv.drainBell(t.wake_r);
248 } 249 }
249 250
251 /// Every wall dial's ssh sends its prompts to this client's popup.
252 pub fn askOn(target: client.Target, shared: *const Shared) client.Target {
253 // ONE place, so the two exclusions hold by construction rather than by
254 // a call somebody has to remember: the entry attach opens on the main
255 // thread before any listener exists, and a poller never comes through
256 // here at all — it spells `BatchMode` and asks nothing.
257 var out = target;
258 if (out != .hand) return out;
259 const l = shared.prompts orelse return out;
260 out.hand.ask_sock = l.path;
261 out.hand.ask_exe = shared.prompt_exe;
262 return out;
263 }
264
250 fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Transport { 265 fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Transport {
251 var target = target_in; 266 var target = askOn(target_in, t.shared);
252 var backoff_ms: u64 = 0; 267 var backoff_ms: u64 = 0;
253 // `gone` as well as `running`: a tile forgotten while it is retrying a 268 // `gone` as well as `running`: a tile forgotten while it is retrying a
254 // dead host must stop retrying, not keep a thread and a backoff alive 269 // dead host must stop retrying, not keep a thread and a backoff alive
255 // for a tile that is no longer on the wall. 270 // for a tile that is no longer on the wall.
256 while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { 271 while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) {
257 // No reason kept: a tile that cannot dial says `connecting` and 272 // The reason is not kept — a tile that cannot dial says
258 // keeps trying, and the sentence ssh gave belongs to the picker 273 // `connecting`, and the sentence ssh gave belongs to the picker
259 // row, which polls the same host on its own interval — ten seconds 274 // row, which polls the same host on its own interval — ten seconds
260 // for exactly this host, since a failed `.hand` poll answers 275 // for exactly this host, since a failed `.hand` poll answers
261 // `.pipe` and `pollDelayMs` stretches those. 276 // `.pipe` and `pollDelayMs` stretches those. The PID is: it is the
262 if (client.Transport.open(alloc, target, null, -1, null)) |tr| return tr else |_| {} 277 // only thing that tells a dial the user refused from a box that is
278 // merely down.
279 var d: handoff.Dial = .{};
280 if (client.Transport.open(alloc, target, null, -1, &d)) |tr| return tr else |_| {}
281 // A refused prompt is an answer, and retrying is arguing with it:
282 // without this the user who pressed Esc is asked again every two
283 // seconds for as long as the wall is up. Another go is a birth
284 // from the picker, which is a thing they can choose to do.
285 if (t.shared.prompts) |l| {
286 if (l.declined(d.ssh_pid)) {
287 endWith(t, .declined, 1);
288 return null;
289 }
290 }
263 // An ask buys ONE attempt. Every retry below is the wall's own 291 // An ask buys ONE attempt. Every retry below is the wall's own
264 // idea: the asking word per backoff would restart a daemon for as 292 // idea: the asking word per backoff would restart a daemon for as
265 // long as the tile lives, and a fallback line per backoff would 293 // long as the tile lives, and a fallback line per backoff would
src/tui/wall_test_picker.zig
Old New
@@ -3,6 +3,7 @@ const std = @import("std");
3 const proto = @import("protocol"); 3 const proto = @import("protocol");
4 const client = @import("client"); 4 const client = @import("client");
5 const interact = @import("interact"); 5 const interact = @import("interact");
6 const layout = @import("layout");
6 const fixture = @import("wall_test_harness.zig"); 7 const fixture = @import("wall_test_harness.zig");
7 const wall_host = @import("wall_host.zig"); 8 const wall_host = @import("wall_host.zig");
8 const wall_picker = @import("wall_picker.zig"); 9 const wall_picker = @import("wall_picker.zig");
@@ -591,3 +592,163 @@ test "hostState: an unreachable host says what ssh said, cut to the row's buffer
591 h.poll.reachable.store(true, .release); 592 h.poll.reachable.store(true, .release);
592 try std.testing.expectEqualStrings("no sessions", wall_picker.hostState(&wide, &h)); 593 try std.testing.expectEqualStrings("no sessions", wall_picker.hostState(&wide, &h));
593 } 594 }
595
596 test "askRows: a masked answer shows one star per byte and never the bytes" {
597 var body: wall_picker.AskBody = .{};
598 wall_picker.askRows(&body, "e2e@box's password: ", "hunter2", .secret, 60);
599 try std.testing.expectEqual(@as(usize, 2), body.n);
600 try std.testing.expectEqualStrings(" e2e@box's password: ", body.row(0));
601 // The count is the feedback: ssh will not echo, and a box that showed
602 // nothing at all reads as one that is not listening.
603 try std.testing.expectEqualStrings("> *******_", body.row(1));
604 // The claim this whole box exists to keep.
605 try std.testing.expect(std.mem.indexOf(u8, body.row(1), "hunter2") == null);
606 }
607
608 test "askRows: a host-key answer is shown, because the user is comparing it" {
609 var body: wall_picker.AskBody = .{};
610 wall_picker.askRows(&body, "Are you sure you want to continue connecting (yes/no)? ", "yes", .confirm, 60);
611 try std.testing.expectEqualStrings("> yes_", body.row(body.n - 1));
612 }
613
614 test "askRows: a NOTICE has no input line, because ssh is not asking anything" {
615 // "Confirm user presence for key ..." is ssh telling the user to touch
616 // a key, and it takes no answer: ssh kills the helper when the touch
617 // lands. An input line under it would invite one nothing would read.
618 var body: wall_picker.AskBody = .{};
619 wall_picker.askRows(&body, "Confirm user presence for key ED25519-SK SHA256:xyz", "", .notice, 60);
620 try std.testing.expectEqual(@as(usize, 1), body.n);
621 try std.testing.expectEqualStrings(" Confirm user presence for key ED25519-SK SHA256:xyz", body.row(0));
622 }
623
624 test "askRows: a long question wraps to the width and stops at the cap" {
625 var body: wall_picker.AskBody = .{};
626 var long: [600]u8 = @splat('q');
627 wall_picker.askRows(&body, &long, "", .confirm, 42);
628 // Four rows of question plus the answer row: a prompt that filled the
629 // terminal would leave nowhere to type.
630 try std.testing.expectEqual(wall_picker.ask_rows_max + 1, body.n);
631 for (0..wall_picker.ask_rows_max) |i| {
632 // One space of margin, 40 of question: the row never exceeds the
633 // box, and `pickerLine` pads the rest.
634 try std.testing.expectEqual(@as(usize, 41), body.row(i).len);
635 }
636 try std.testing.expectEqualStrings("> _", body.row(wall_picker.ask_rows_max));
637 }
638
639 test "paintAsk: replayed into an engine, the box is centred and the answer is stars" {
640 const alloc = std.testing.allocator;
641 const cols: u16 = 160;
642 const rows: u16 = 20;
643 var screen = try WallScreen.init(alloc, cols, rows);
644 defer screen.deinit();
645 // Off-origin in both axes, `paintPicker`'s reason: a fixture on a
646 // terminal the box happens to fill is blind to every centring mistake.
647 var bg: [cols]u8 = undefined;
648 @memset(&bg, '.');
649 var r: u16 = 0;
650 while (r < rows) : (r += 1) {
651 var cup: [16]u8 = undefined;
652 screen.eng.feed(std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{r + 1}) catch unreachable);
653 screen.eng.feed(&bg);
654 }
655 var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true };
656 var prefix: interact.PrefixFilter = .{};
657 prefix.askOpen(.secret);
658 var typed = "hunter2".*;
659 _ = prefix.feed(&typed);
660 wall_picker.paintAsk(&shared, "e2e@box's password: ", &prefix);
661 screen.drain();
662 const dump = try screen.eng.dumpPlain(alloc);
663 defer alloc.free(dump);
664
665 // The GRID, not the frame: a paint that reached a cell by another CUP
666 // form or a wider pad is invisible to a grep over the bytes.
667 try std.testing.expect(std.mem.indexOf(u8, dump, "e2e@box's password:") != null);
668 try std.testing.expect(std.mem.indexOf(u8, dump, "*******_") != null);
669 // The one thing that must never be on a screen anyone can scroll back.
670 try std.testing.expect(std.mem.indexOf(u8, dump, "hunter2") == null);
671 // ...and every tile is held off the terminal while it is up.
672 try std.testing.expect(wv.popupOpen(&shared));
673 const w: u16 = @intCast(wall_picker.picker_row_max);
674 const left: u16 = (cols - w) / 2;
675 const height: u16 = 3;
676 const top: u16 = (rows - height) / 2;
677 var i: u16 = 0;
678 while (i < rows) : (i += 1) {
679 const l = WallScreen.line(dump, i) orelse return error.NoSuchRow;
680 if (i >= top and i < top + height) {
681 try std.testing.expectEqualStrings(bg[0..left], l[0..left]);
682 try std.testing.expectEqualStrings(bg[0..left], l[left + w ..]);
683 } else {
684 try std.testing.expectEqualStrings(&bg, l);
685 }
686 }
687 }
688
689 test "closeAsk: the picker comes back when the prompt box leaves, whichever end ended it" {
690 // The claim n1 found missing on one of the two ends. `relayout` CLEARS
691 // the screen, so a picker still open under the box owes a repaint —
692 // and a prompt CAN arrive over an open picker, which is why the answer
693 // has a buffer of its own. Without the repaint, `picking` is true on a
694 // blank screen until the next key.
695 const alloc = std.testing.allocator;
696 const cols: u16 = 100;
697 const rows: u16 = 30;
698 var screen = try WallScreen.init(alloc, cols, rows);
699 defer screen.deinit();
700 var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true };
701 shared.tree = layout.Tree.init(alloc);
702 shared.flat_alloc = alloc;
703 defer shared.tree.deinit();
704 defer if (shared.last_flat) |*f| f.deinit(alloc);
705 defer if (shared.base_flat) |*f| f.deinit(alloc);
706
707 var table = [_]Host{
708 fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
709 fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"),
710 };
711 for (&table) |*h| {
712 fixture.setList(h, "");
713 h.applied = true;
714 }
715 var tiles: [1]Tile = undefined;
716 var present = [_]bool{false};
717 var live: usize = 0;
718 const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
719
720 // A notice over an open picker, which is exactly the pair that hangs
721 // up on its own: ssh kills the notifier helper when the touch lands.
722 var prefix: interact.PrefixFilter = .{};
723 prefix.picking = true;
724 prefix.askOpen(.notice);
725 shared.ask_open.store(true, .release);
726 screen.drain();
727
728 wv.closeAsk(w, &shared, &prefix, 0);
729 screen.drain();
730 const dump = try screen.eng.dumpPlain(alloc);
731 defer alloc.free(dump);
732 try std.testing.expect(!prefix.asking);
733 try std.testing.expect(!shared.ask_open.load(.acquire));
734 // The GRID, not the frame: the box is gone and the rows the user was
735 // choosing from are back on it.
736 try std.testing.expect(std.mem.indexOf(u8, dump, "hosts") != null);
737 try std.testing.expect(std.mem.indexOf(u8, dump, "/tmp/b.sock") != null);
738
739 // ...and with no picker under it, nothing is painted in its place: the
740 // wall is what the screen goes back to. `closeAsk` leaves `picking`
741 // alone — the picker is not the box's to close — so the test says
742 // which wall this second half is.
743 prefix.picking = false;
744 prefix.askOpen(.secret);
745 shared.ask_open.store(true, .release);
746 wall_picker.paintAsk(&shared, "box's password: ", &prefix);
747 screen.drain();
748 wv.closeAsk(w, &shared, &prefix, 0);
749 screen.drain();
750 const after = try screen.eng.dumpPlain(alloc);
751 defer alloc.free(after);
752 try std.testing.expect(std.mem.indexOf(u8, after, "/tmp/b.sock") == null);
753 try std.testing.expect(std.mem.indexOf(u8, after, "password") == null);
754 }
src/tui/wall_test_pump.zig
Old New
@@ -4,6 +4,7 @@ const proto = @import("protocol");
4 const client = @import("client"); 4 const client = @import("client");
5 const interact = @import("interact"); 5 const interact = @import("interact");
6 const TmpDir = @import("testtmp").TmpDir; 6 const TmpDir = @import("testtmp").TmpDir;
7 const askpass = @import("askpass");
7 const fixture = @import("wall_test_harness.zig"); 8 const fixture = @import("wall_test_harness.zig");
8 const wall_pump = @import("wall_pump.zig"); 9 const wall_pump = @import("wall_pump.zig");
9 const wv = @import("wallview.zig"); 10 const wv = @import("wallview.zig");
@@ -568,3 +569,53 @@ test "the cursor sleeps in the focused tile, whoever painted last" {
568 wall_pump.tilePaintEnd(&tiles[0]); 569 wall_pump.tilePaintEnd(&tiles[0]);
569 try std.testing.expectEqual(@as(usize, 0), fixture.readAvail(p[0], &buf).len); 570 try std.testing.expectEqual(@as(usize, 0), fixture.readAvail(p[0], &buf).len);
570 } 571 }
572
573 test "askOn: a wall dial carries the popup's socket, and every other target is untouched" {
574 const alloc = std.testing.allocator;
575 var tmp = try TmpDir.make();
576 defer tmp.cleanup();
577 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
578 defer std.posix.close(pipe[0]);
579 defer std.posix.close(pipe[1]);
580 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
581 shared.kb_w = pipe[1];
582
583 const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
584 // No listener: the OLD behaviour, and the visible one — ssh reads
585 // /dev/tty. A piped `mux` and a box with no runtime dir are both here.
586 const bare = wall_pump.askOn(hand, &shared);
587 try std.testing.expect(bare.hand.ask_sock == null);
588 try std.testing.expect(bare.hand.askpassFor() == null);
589
590 const l = try askpass.Listener.start(alloc, tmp.path(), .{
591 .ctx = &shared,
592 .wake = struct {
593 fn nop(_: *anyopaque) void {}
594 }.nop,
595 });
596 defer l.stop();
597 shared.prompts = l;
598 shared.prompt_exe = "/opt/mux";
599 const armed = wall_pump.askOn(hand, &shared);
600 const ask = armed.hand.askpassFor() orelse return error.TestUnexpectedResult;
601 try std.testing.expectEqualStrings(l.path, ask.sock);
602 try std.testing.expectEqualStrings("/opt/mux", ask.exe);
603
604 // A socket or a `--via` dial spawns no ssh, so there is nothing to
605 // point anywhere — and a target arm added later must not silently
606 // acquire a field this one cannot use.
607 const sock = wall_pump.askOn(.{ .sock = "/tmp/d.sock" }, &shared);
608 try std.testing.expect(sock == .sock);
609 }
610
611 test "askpassFor: a socket with no binary to carry it is no askpass at all" {
612 // SSH_ASKPASS pointing at nothing makes ssh FAIL every prompt rather
613 // than ask one, which is worse than the tty read this replaces.
614 const half = client.HandoffTarget{
615 .host = "box",
616 .ssh_argv = &.{"ssh"},
617 .cache_path = null,
618 .ask_sock = "/run/ask.sock",
619 };
620 try std.testing.expect(half.askpassFor() == null);
621 }
src/tui/wallview.zig
Old New
@@ -22,6 +22,8 @@ const proto = @import("protocol");
22 const client = @import("client"); 22 const client = @import("client");
23 const hosts = @import("hosts"); 23 const hosts = @import("hosts");
24 const handoff = @import("handoff"); 24 const handoff = @import("handoff");
25 const askpass = @import("askpass");
26 const spawn = @import("spawn");
25 const proxy = @import("proxy"); 27 const proxy = @import("proxy");
26 const Engine = @import("engine").Engine; 28 const Engine = @import("engine").Engine;
27 const paint = @import("paint"); 29 const paint = @import("paint");
@@ -62,6 +64,10 @@ pub const State = enum {
62 reconnecting, 64 reconnecting,
63 exited, 65 exited,
64 refused, 66 refused,
67 /// The user pressed Esc at ssh's prompt. A state and not a notice: the
68 /// tile stays on the wall wearing the reason, the way a lost one does,
69 /// and the wall it is on is still there to birth from again.
70 declined,
65 71
66 pub fn word(s: State) []const u8 { 72 pub fn word(s: State) []const u8 {
67 return switch (s) { 73 return switch (s) {
@@ -69,6 +75,7 @@ pub const State = enum {
69 .up => "up", 75 .up => "up",
70 .reconnecting => "reconnecting", 76 .reconnecting => "reconnecting",
71 .exited => "exited", 77 .exited => "exited",
78 .declined => "prompt declined",
72 .refused => "refused", 79 .refused => "refused",
73 }; 80 };
74 } 81 }
@@ -95,7 +102,7 @@ pub const max_tiles: usize = 32;
95 /// means for the WALL. `State` narrates the same events on a label bar; 102 /// means for the WALL. `State` narrates the same events on a label bar;
96 /// this is the machine-readable half, and they are separate because a cold 103 /// this is the machine-readable half, and they are separate because a cold
97 /// loss has no label word of its own and an exit carries a code. 104 /// loss has no label word of its own and an exit carries a code.
98 pub const EndReason = enum(u8) { none, exited, taken, refused, lost, no_thread }; 105 pub const EndReason = enum(u8) { none, exited, taken, refused, lost, no_thread, declined };
99 106
100 pub const Shared = struct { 107 pub const Shared = struct {
101 running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), 108 running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
@@ -200,8 +207,69 @@ pub const Shared = struct {
200 /// The popup's last frame, so an unchanged one is not rewritten. 207 /// The popup's last frame, so an unchanged one is not rewritten.
201 /// Keyboard-thread only, like the picker itself. 208 /// Keyboard-thread only, like the picker itself.
202 picker_stamp: u64 = 0, 209 picker_stamp: u64 = 0,
210 /// True while ssh's prompt box is on the terminal. `picker_open`'s
211 /// twin, for its reason and at the same gates — and a second flag
212 /// rather than a mode of the first, because a prompt ARRIVES and can
213 /// arrive with the picker already up.
214 ask_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
215 /// Where this wall answers ssh's prompts, and the binary ssh execs to
216 /// ask. Null is "ssh keeps its own prompts": a piped `mux` has no
217 /// popup to answer with, and a box with no `$XDG_RUNTIME_DIR` has
218 /// nowhere private to put the socket — a prompt on a shared /tmp is
219 /// one any local user could answer.
220 ///
221 /// Named for what arrives on it, not `ask`: `Tile.ask` is the end-key's
222 /// own arm, and two fields called `ask` in one file are a reader's trap.
223 prompts: ?*askpass.Listener = null,
224 prompt_exe: []const u8 = "",
203 }; 225 };
204 226
227 /// Opens the prompt box on whatever ssh is asking, if anything is. False
228 /// when there is nothing to show or a box is already up: `take` is the
229 /// transition, so a doorbell rung twice opens one popup.
230 fn takeAsk(shared: *Shared, prefix: *interact.PrefixFilter, out: *askpass.Prompt) bool {
231 if (prefix.asking) return false;
232 const l = shared.prompts orelse return false;
233 if (!l.take(out)) return false;
234 prefix.askOpen(out.kind);
235 return true;
236 }
237
238 /// The prompt box gives the terminal back — whichever end ended it.
239 pub fn closeAsk(
240 w: Wall,
241 shared: *Shared,
242 prefix: *interact.PrefixFilter,
243 picker_sel: usize,
244 ) void {
245 // ONE function because the two ends had drifted: `relayout` CLEARS the
246 // screen, so a picker still open under the box owes a repaint, and the
247 // hangup path did not know it. A prompt can arrive over an open picker
248 // — that is why the answer has a buffer of its own — so a notice whose
249 // helper hung up left `picking` true on a blank screen until the next
250 // key.
251 //
252 // The answer buffer goes here and nowhere else: `feed` cannot zero it,
253 // because the action it returned borrows it.
254 prefix.askClose();
255 shared.ask_open.store(false, .release);
256 // `relayout` bumps `repaint_gen`, which is the only thing that redraws
257 // a tile whose session said nothing while the box was up.
258 wall_layout.relayout(w, shared.sel);
259 if (!prefix.picking) return;
260 var foot: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
261 const back = wall_picker.pickerRepaint(&foot, prefix, true);
262 wall_picker.paintPicker(shared, w.hosts, picker_sel, back.line);
263 }
264
265 /// Whether a popup owns the terminal.
266 pub fn popupOpen(shared: *const Shared) bool {
267 // Every tile paint asks: a rect redrawn under either box would erase
268 // what the user is reading, and the close bumps `repaint_gen` to bring
269 // the rects back.
270 return shared.picker_open.load(.acquire) or shared.ask_open.load(.acquire);
271 }
272
205 /// The rect a paint used, in the only terms a click can be resolved in. 273 /// The rect a paint used, in the only terms a click can be resolved in.
206 /// 274 ///
207 /// Absolute rows are counted from the oldest row the DAEMON still retains 275 /// Absolute rows are counted from the oldest row the DAEMON still retains
@@ -555,7 +623,7 @@ pub fn labelText(
555 /// when `label_rows` is nonzero: a one-tile wall owns every row and has no 623 /// when `label_rows` is nonzero: a one-tile wall owns every row and has no
556 /// bar to draw. 624 /// bar to draw.
557 pub fn paintLabelLocked(t: *Tile) void { 625 pub fn paintLabelLocked(t: *Tile) void {
558 if (t.shared.picker_open.load(.acquire)) return; 626 if (popupOpen(t.shared)) return;
559 // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in 627 // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in
560 // a capture, and must not depend on a font. The unfocused marker is 628 // a capture, and must not depend on a font. The unfocused marker is
561 // the same width, so labels do not shift as the focus moves. 629 // the same width, so labels do not shift as the focus moves.
@@ -622,7 +690,7 @@ pub fn tileBanner(t: *Tile, text: []const u8) void {
622 // Under the lock, for `tilePaintBegin`'s reason: read before it, a 690 // Under the lock, for `tilePaintBegin`'s reason: read before it, a
623 // banner that lost the race for `paint_mu` lands on a box row, and 691 // banner that lost the race for `paint_mu` lands on a box row, and
624 // `picker_stamp` suppresses the repaint that would repair it. 692 // `picker_stamp` suppresses the repaint that would repair it.
625 if (t.shared.picker_open.load(.acquire)) return; 693 if (popupOpen(t.shared)) return;
626 paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left); 694 paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left);
627 } 695 }
628 696
@@ -654,6 +722,11 @@ pub fn ringKeyboard(shared: *const Shared) void {
654 _ = std.posix.write(shared.kb_w, "\x00") catch {}; 722 _ = std.posix.write(shared.kb_w, "\x00") catch {};
655 } 723 }
656 724
725 /// The same bell behind `askpass.Hooks`' runtime pair.
726 fn ringKeyboardCtx(ctx: *anyopaque) void {
727 ringKeyboard(@as(*const Shared, @ptrCast(@alignCast(ctx))));
728 }
729
657 /// Both ends of both bells are non-blocking, which is what makes this safe 730 /// Both ends of both bells are non-blocking, which is what makes this safe
658 /// on a pipe nobody has rung. 731 /// on a pipe nobody has rung.
659 pub fn drainBell(fd: std.posix.fd_t) void { 732 pub fn drainBell(fd: std.posix.fd_t) void {
@@ -1220,6 +1293,10 @@ pub fn endAction(
1220 // the local daemon, so a wall reaching for a new session on its own 1293 // the local daemon, so a wall reaching for a new session on its own
1221 // machine is exactly the second case. 1294 // machine is exactly the second case.
1222 const gone_msg: ?[]const u8 = switch (reason) { 1295 const gone_msg: ?[]const u8 = switch (reason) {
1296 // Whatever the tile had been: the user REFUSED this dial, and a
1297 // wall that retried anyway would ask the same question every two
1298 // seconds for as long as it stayed up.
1299 .declined => "mux: prompt declined",
1223 .refused => "mux: cannot create a new session (daemon full?)", 1300 .refused => "mux: cannot create a new session (daemon full?)",
1224 .lost => if (t.ever_up.load(.acquire)) 1301 .lost => if (t.ever_up.load(.acquire))
1225 null 1302 null
@@ -1281,6 +1358,9 @@ pub fn endAction(
1281 // ...and the one cause that IS knowable, which no daemon had a 1358 // ...and the one cause that IS knowable, which no daemon had a
1282 // part in. 1359 // part in.
1283 .no_thread => .{ .code = 1, .msg = "mux: could not start a thread for this session" }, 1360 .no_thread => .{ .code = 1, .msg = "mux: could not start a thread for this session" },
1361 // Said in the user's own terms: they pressed Esc at a password
1362 // box, and no ssh diagnostic describes that better than they do.
1363 .declined => .{ .code = 1, .msg = "mux: prompt declined" },
1284 }, 1364 },
1285 }; 1365 };
1286 // A session the PICKER started on a wall that is still there ends as a 1366 // A session the PICKER started on a wall that is still there ends as a
@@ -1590,6 +1670,34 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1590 shared.kb_w = kb[1]; 1670 shared.kb_w = kb[1];
1591 shared.sel = 0; 1671 shared.sel = 0;
1592 1672
1673 // ssh's prompts get somewhere to go, on a TERMINAL only: without one
1674 // there is no popup to answer with, so ssh keeps its own prompts and
1675 // fails them onto the row with its reason — half one's path, and a
1676 // visible one rather than a wait nobody can see.
1677 //
1678 // `$XDG_RUNTIME_DIR` and no fallback, for `sockpath`'s reason turned up
1679 // one notch: a guess lands in a shared /tmp, and a socket there is a
1680 // password prompt any local user could answer.
1681 var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined;
1682 if (is_tty) {
1683 if (std.posix.getenv("XDG_RUNTIME_DIR")) |rt| {
1684 if (askpass.Listener.start(alloc, rt, .{
1685 .ctx = &shared,
1686 .wake = ringKeyboardCtx,
1687 })) |l| {
1688 shared.prompts = l;
1689 // The image that is RUNNING, resolved through /proc, and
1690 // never a PATH walk: ssh execs this as its helper, and a
1691 // helper found by name is whichever mux the environment
1692 // happens to have (`spawn.selfExe`).
1693 shared.prompt_exe = spawn.selfExe(&ask_exe_buf);
1694 } else |_| {}
1695 }
1696 }
1697 // Before the raw-mode teardown below, so the socket is gone whichever
1698 // way this function leaves.
1699 defer if (shared.prompts) |l| l.stop();
1700
1593 // Allocated at CAPACITY, not at length: the pump threads are detached 1701 // Allocated at CAPACITY, not at length: the pump threads are detached
1594 // and hold `*Tile` for the wall's whole life, so the array may never 1702 // and hold `*Tile` for the wall's whole life, so the array may never
1595 // move. `live` is how much of it is real. 1703 // move. `live` is how much of it is real.
@@ -1702,6 +1810,9 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1702 // popup reopened by reflex is where it was left. Keyboard-thread 1810 // popup reopened by reflex is where it was left. Keyboard-thread
1703 // state, like the focus. 1811 // state, like the focus.
1704 var picker_sel: usize = 0; 1812 var picker_sel: usize = 0;
1813 // The prompt on screen, kept because the box is repainted on every
1814 // keystroke and the question does not come round again.
1815 var ask_prompt: askpass.Prompt = .{};
1705 // Whether the picker on screen is one the WALL opened, not the user: 1816 // Whether the picker on screen is one the WALL opened, not the user:
1706 // an empty wall opens it once, and an Esc there has to be able to 1817 // an empty wall opens it once, and an Esc there has to be able to
1707 // leave the empty line showing rather than be re-opened over. 1818 // leave the empty line showing rather than be re-opened over.
@@ -1832,6 +1943,22 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1832 }, 1943 },
1833 } 1944 }
1834 } 1945 }
1946 // A box whose ssh has GONE, before a box that is arriving: the
1947 // FIDO notifier's helper is SIGTERMed the moment the touch lands,
1948 // and what is on screen then is a question nobody is waiting on.
1949 // Closing it here rather than on a key is the whole point — the
1950 // user should not have to dismiss it, and dismissing it would put
1951 // a live dial's pid in the decline ring.
1952 if (input.prefix.asking) {
1953 if (shared.prompts) |l| {
1954 if (!l.showing()) closeAsk(w, &shared, &input.prefix, picker_sel);
1955 }
1956 }
1957 // Every pass, not only on the bell: `endedTile`'s reason twice
1958 // over — one drained ring can carry two pumps' news — and a prompt
1959 // is an ssh already blocked on the answer.
1960 if (takeAsk(&shared, &input.prefix, &ask_prompt))
1961 wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix);
1835 // Ends first, lists second: a session that exited is its pump's 1962 // Ends first, lists second: a session that exited is its pump's
1836 // news and arrives at once, while a list is up to a poll behind. 1963 // news and arrives at once, while a list is up to a poll behind.
1837 // Reading the list first would vanish the tile the exit code is on. 1964 // Reading the list first would vanish the tile the exit code is on.
@@ -1919,6 +2046,41 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1919 2046
1920 const z = shared.sel; 2047 const z = shared.sel;
1921 const cmd = input.prefix.feed(b[0..n]); 2048 const cmd = input.prefix.feed(b[0..n]);
2049 // Over EVERYTHING, the picker included: ssh is blocked on this
2050 // answer, and the box that owns the keyboard is the one that
2051 // arrived last. Nothing is forwarded — the filter keeps every byte
2052 // while `asking`, which is what stops a password reaching a shell.
2053 if (input.prefix.asking or wall_picker.isAskAction(cmd.action)) {
2054 // A NOTICE takes no answer, whichever key ended it: ssh asked
2055 // nothing — "Confirm user presence for key ..." — and a decline
2056 // recorded here would park a tile whose dial is still going.
2057 // The box just leaves; the helper is ssh's to kill.
2058 const answerable = ask_prompt.kind != .notice;
2059 switch (cmd.action) {
2060 .ask_answer => |line| if (answerable) {
2061 if (shared.prompts) |l| l.answer(line);
2062 },
2063 .ask_decline => if (answerable) {
2064 if (shared.prompts) |l| l.decline();
2065 },
2066 else => {},
2067 }
2068 if (input.prefix.asking) {
2069 wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix);
2070 continue;
2071 }
2072 // ssh asks in sequence — a host key, then a password — and the
2073 // next helper is already waiting in the backlog. Reopening
2074 // BEFORE the close saves the user a screen that flashes back to
2075 // the wall between two halves of one login; `askOpen` takes the
2076 // answer buffer back the way `closeAsk` would have.
2077 if (takeAsk(&shared, &input.prefix, &ask_prompt)) {
2078 wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix);
2079 continue;
2080 }
2081 closeAsk(w, &shared, &input.prefix, picker_sel);
2082 continue;
2083 }
1922 // The picker is a MODE: while it is open every key is the popup's, 2084 // The picker is a MODE: while it is open every key is the popup's,
1923 // so neither the empty wall's arms below nor the tiles' ever see 2085 // so neither the empty wall's arms below nor the tiles' ever see
1924 // one — and the popup is the one thing that works on a wall with 2086 // one — and the popup is the one thing that works on a wall with
@@ -2086,6 +2248,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2086 .pick_add_open, 2248 .pick_add_open,
2087 .pick_close, 2249 .pick_close,
2088 => {}, 2250 => {},
2251 // ssh's own keys, answered above this switch for the reason
2252 // the picker's are: the box is the only thing on the screen
2253 // that a keystroke can be for.
2254 .ask_answer, .ask_decline => {},
2089 .detach => { 2255 .detach => {
2090 // The slot goes back to the daemon before this process 2256 // The slot goes back to the daemon before this process
2091 // does — the pump writes the frame, because a Transport 2257 // does — the pump writes the frame, because a Transport
@@ -2200,6 +2366,11 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2200 } 2366 }
2201 2367
2202 shared.running.store(false, .release); 2368 shared.running.store(false, .release);
2369 // The socket leaves the filesystem here rather than in the defer above,
2370 // which this function's `exit` never reaches. `retire` and not `stop`:
2371 // a detached pump may be inside `declined` on it, and a free in the
2372 // window before the exit is a use-after-free.
2373 if (shared.prompts) |l| l.retire();
2203 // Taken and never released: no tile paints across the restore. The 2374 // Taken and never released: no tile paints across the restore. The
2204 // pump threads are detached and die with the process; joining them 2375 // pump threads are detached and die with the process; joining them
2205 // could wait on a blocked readFrame forever. 2376 // could wait on a blocked readFrame forever.