a73x

3bf0fc41

fix: stop native reconnect on SSH authentication refusal

a73x   2026-09-05 14:01

Commit message
fix: stop native reconnect on SSH authentication refusal

RETRO.md
Old New
@@ -421,8 +421,83 @@ this milestone.
421 - [x] Record final Sprint 5 demo acceptance and any requested ergonomic adjustment; 421 - [x] Record final Sprint 5 demo acceptance and any requested ergonomic adjustment;
422 distinguish recorded approval from any hands-on trial. Accepted on 2026-09-05 422 distinguish recorded approval from any hands-on trial. Accepted on 2026-09-05
423 with no requested changes; recorded-demo approval only. 423 with no requested changes; recorded-demo approval only.
424 - [ ] Start with available Luna/Terra slots and short briefs containing the pinned 424 - [x] Start with available Luna/Terra slots and short briefs containing the pinned
425 compiler/cache command; record model selection, review yield and available cost 425 compiler/cache command; record model selection, review yield and available cost
426 evidence without estimating missing token totals. 426 evidence without estimating missing token totals.
427 - [ ] When shared handoff errors are changed, separate unreachable-host EOF from 427 Completed in the SSH follow-up below; local session metadata confirms both models.
428 - [x] When shared handoff errors are changed, separate unreachable-host EOF from
428 authentication refusal and verify bounded reconnect and join-only restore. 429 authentication refusal and verify bounded reconnect and join-only restore.
430 Implemented and validated in the SSH follow-up; recorded-demo acceptance pending.
431
432 ## SSH failure classification — follow-up sprint, 2026-09-05
433
434 The user authorized this bounded sprint after accepting the native tiling milestone.
435 Implementation, adversarial review, closing cleanup assessment, and validation are
436 complete. The [delivery record](docs/superpowers/plans/2026-09-05-ssh-error-classification.md)
437 contains commands and evidence. The new recorded trial is awaiting user acceptance.
438
439 ### Results and review
440
441 - Canonical OpenSSH authentication refusal now stops native automatic reconnect
442 during both restore and post-attachment redial. The pane retains its target and
443 diagnostic; correcting credentials requires explicit Retry. Unknown EOF and
444 unavailable-host recovery retain their existing behavior. End, creation, and
445 terminal layout policy were not changed.
446 - Opening review found the existing handoff/Reason/pump boundaries sufficient;
447 no separate structural cleanup was warranted. Closing review removed an unused
448 classification category and a bespoke ASCII scan, and extracted the retry-policy
449 predicate to verify existing non-auth behavior explicitly.
450 - Review tightened banner/identity parsing and authentication-method coverage,
451 required real execution counts for both retry paths, replaced a timing-dependent
452 reconnect test with an attached-then-release handshake, and fixed test-server
453 teardown so a stop failure cannot skip GUI/daemon cleanup. Root's final finding
454 preserved retries for post-attachment non-auth errors such as connection reset.
455 - The permanent real OpenSSH/native test failed on the accepted previous release:
456 a rejected key left the pane reconnecting and caused repeated SSH attempts.
457 It passes on the final release, including manual recovery, independent neighbour
458 input, unavailable-host return, missing-session preservation, and shutdown.
459 - `make ci` passed. After the final pump-policy correction, `make check`, native
460 unit/integration gates, real SSH acceptance, and the actual GUI recording were
461 rerun. Wayland 200%/100%/150%/200% transitions and raw-output stress passed.
462 Fixtures and the owned compositor were stopped.
463
464 ### Model economy and workflow lessons
465
466 Both agents started with short briefs and no conversation fork. Local session
467 `turn_context.model` records confirm Luna for implementation and Terra for review.
468 An earlier reviewer claim that it used Astra was an unsupported self-description;
469 the reviewer retracted it. Verify metadata at startup rather than after delivery.
470
471 The local cumulative counters below include cached input, repeated context, and
472 all focused follow-up turns; they are not unique context sizes or monetary bills.
473 The evidence snapshot is retained with the demo logs.
474
475 | Agent | Input tokens | Cached input (included) | Output tokens |
476 | --- | ---: | ---: | ---: |
477 | Luna implementer | 5,424,261 | 5,296,640 | 17,644 |
478 | Terra reviewer | 6,733,586 | 6,560,000 | 13,092 |
479
480 The first/last token events span about 17 minutes for implementation and 16 minutes
481 for review, including follow-up waits; active model time and monetary cost are not
482 available. Review produced concrete correctness and test-quality improvements,
483 but repeated socket-denied unit runs and large failure output expanded context.
484 Retain Luna/Terra defaults; reduce redundant validation output and route socket
485 checks to the authorized environment after the first denial. No measured cost
486 saving is claimed.
487
488 ### Retained limits and next actions
489
490 The classifier in `src/client/handoff.zig` recognizes complete OpenSSH diagnostic
491 forms, not every SSH implementation, localization, or truncated message. `Reason`
492 retains its existing 120-byte ASCII line limit. Unrecognized text stays unknown
493 and therefore recoverable rather than being guessed to mean auth refusal. The
494 next shared diagnostic-format change owns expanding classification and must retain
495 negative-banner, fragmented-line, reconnect, and shutdown checks.
496
497 - [ ] Record this sprint's demo acceptance separately from any hands-on trial.
498 - [ ] Obtain explicit approval before exposing this demo page/video at the proposed
499 private `/ssh-followup/` Tailscale route. Automatic approval review rejected that
500 publication because the specific payload/destination were not explicitly authorized.
501 The video is retained locally; existing private demo routes were left unchanged.
502 - [ ] In the next authorized sprint, verify agent model metadata immediately after
503 startup and avoid rerunning a socket-denied suite in the same sandbox.
docs/native-sprint-workflow.md
Old New
@@ -29,6 +29,13 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan
29 of inheriting the full conversation. Escalate a concrete unresolved problem 29 of inheriting the full conversation. Escalate a concrete unresolved problem
30 to a stronger model when needed. Root coordinates and integrates; assign each 30 to a stronger model when needed. Root coordinates and integrates; assign each
31 investigation one owner so agents do not repeat it independently by accident. 31 investigation one owner so agents do not repeat it independently by accident.
32
33 Check each agent's model immediately after startup. Prefer recorded session
34 `turn_context.model` metadata when available; distinguish it from the requested
35 spawn setting, and do not rely on an agent's self-description. Retain available
36 token counters with cached input identified separately; counters are not a
37 monetary bill. After a sandbox denial, use the authorized validation route
38 instead of repeating the same blocked suite or streaming its full failure log.
32 4. **Build a functional slice.** Establish the smallest model/interface contract, 39 4. **Build a functional slice.** Establish the smallest model/interface contract,
33 then connect actual input, rendering, and daemon behavior. Keep changes small 40 then connect actual input, rendering, and daemon behavior. Keep changes small
34 enough to review. Avoid expanding into later sprint features. 41 enough to review. Avoid expanding into later sprint features.
@@ -66,6 +73,13 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan
66 its original target. For persistence, retain original bytes across real 73 its original target. For persistence, retain original bytes across real
67 permission failures, reopen from a different working directory and scale, and 74 permission failures, reopen from a different working directory and scale, and
68 keep a sentinel in the terminal client's layout file. 75 keep a sentinel in the terminal client's layout file.
76
77 For SSH handoff or reconnect changes on Linux, also run the real OpenSSH gate
78 from the [SSH follow-up plan](superpowers/plans/2026-09-05-ssh-error-classification.md).
79 `test/native_ssh.py` accepts the release daemon and GUI paths and uses its own
80 loopback sshd, keys, state, and sessions. Verify authentication refusal on both
81 restoration and reconnect, explicit Retry after correcting credentials,
82 unavailable-host recovery, join-only restoration, and bounded shutdown.
69 7. **Refactor the integrated result.** Reserve this as work, even after the opening 83 7. **Refactor the integrated result.** Reserve this as work, even after the opening
70 cleanup. Remove duplication, superseded paths, unnecessary state, and temporary 84 cleanup. Remove duplication, superseded paths, unnecessary state, and temporary
71 scaffolding exposed by implementation. Review the result again. For retained 85 scaffolding exposed by implementation. Review the result again. For retained
docs/superpowers/plans/2026-09-05-native-tiling.md
Old New
@@ -6,6 +6,10 @@ validated, and accepted by the user on 2026-09-05.
6 Sprint 4 is implemented, reviewed, and validated; separate recorded-demo 6 Sprint 4 is implemented, reviewed, and validated; separate recorded-demo
7 acceptance was not given. Sprint 3 acceptance passed. 7 acceptance was not given. Sprint 3 acceptance passed.
8 8
9 The retained SSH error-classification debt is addressed in the separately
10 authorized [follow-up sprint](2026-09-05-ssh-error-classification.md), whose
11 implementation and validation are complete and recorded-demo acceptance is pending.
12
9 ## Working agreement 13 ## Working agreement
10 14
11 Follow the reusable [sprint workflow](../../native-sprint-workflow.md), including 15 Follow the reusable [sprint workflow](../../native-sprint-workflow.md), including
docs/superpowers/plans/2026-09-05-ssh-error-classification.md
Old New
@@ -0,0 +1,108 @@
1 # SSH failure classification — follow-up sprint
2
3 The user authorized this bounded follow-up after accepting native tiling Sprint 5.
4 The native tiling milestone remains complete. This sprint closes the shared
5 handoff debt recorded in [RETRO.md](../../../RETRO.md), using the
6 [native sprint workflow](../../native-sprint-workflow.md).
7
8 ## Scope and acceptance
9
10 Recognize SSH authentication refusal separately from an unavailable host when
11 the endpoint announcement fails. A native pane must stop automatic reconnect on
12 a recognized authentication refusal, retain its identity and diagnostic, and
13 offer explicit Retry after credentials are corrected. Apply this both during
14 initial workspace restoration and after a previously attached session disconnects.
15
16 An unavailable host or an unclassified announcement EOF remains eligible for
17 bounded-backoff reconnect. Unknown diagnostics must not be guessed to mean
18 authentication refusal. Preserve cancellation and bounded shutdown, join-only
19 restoration, and the prohibition on automatically retrying End or creation.
20 Terminal layout policy, tabs, pane rearrangement, and credential UI are outside
21 this sprint.
22
23 Acceptance uses actual SSH authentication and real daemon sessions, with an
24 independent attempt counter, session catalogue, rendered output, and saved pane
25 identity. Cover failed credentials on restore and reconnect, corrected credentials
26 with explicit Retry, unavailable-host recovery, missing-session preservation,
27 an independently usable neighbouring pane, and shutdown during retries. Focused
28 tests cover diagnostic fragmentation and ambiguous/non-authentication failures.
29
30 ## Delivery
31
32 - Implementation: GPT-5.6 Luna (`handoff_impl`). Adversarial review: GPT-5.6 Terra
33 (`handoff_review`), with independent integration and acceptance by root.
34 - Briefs specify `deps/zig/zig` and `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache`.
35 - Opening and closing cleanup assessments, final frozen-source gates, real GUI
36 demonstration, and the retrospective are required before handoff. Recorded-demo
37 acceptance remains pending until the user gives it.
38 - Model selection and concrete review yield will be recorded. Token/cost figures
39 will be reported only if available.
40
41 The optional Linux real-SSH gate requires OpenSSH client, server, and keygen:
42
43 ```sh
44 export ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache
45 deps/zig/zig build install native -Doptimize=ReleaseSafe -p dist/native-ssh-release
46 python3 test/native_ssh.py dist/native-ssh-release/bin/mux dist/native-ssh-release/bin/muxg
47 ```
48
49 This complements the standard gates in the sprint workflow. It starts an owned
50 unprivileged loopback SSH server, with temporary keys and isolated state; local
51 socket/PTY permissions are required. `MUXG_VIDEODRIVER=wayland` selects a real
52 compositor for the same checks.
53
54 ## Delivery evidence
55
56 Status: implemented, reviewed, and validated. Recorded-demo acceptance is pending.
57 The native tiling milestone remains accepted; this follow-up has its own demo gate.
58
59 Opening review found no justified standalone cleanup. Closing review kept the
60 classifier at the shared handoff boundary, removed an unused result category and
61 manual ASCII scan, and extracted the pump retry predicate. Review covered banner
62 false positives, authentication-method lists, initial and post-attachment retries,
63 deterministic test ordering, and cleanup of owned fixtures. A final correction
64 preserves all previous non-auth post-attachment retry behavior; the initial-open
65 whitelist was not extended to established connections.
66
67 Validation logs are under `/tmp/muxg-ssh-followup/`, with retained copies under
68 `/home/xanderle/.codex/visualizations/2026/09/05/01a071c2-5767-7790-a335-1a84536c9ca5/ssh-evidence/`:
69
70 - `before.log`: real-SSH negative control against the accepted prior release.
71 Rejected keys repeatedly spawned SSH while the pane remained reconnecting.
72 - `ci.log`: complete `make ci` passed. Following the final pump-only correction,
73 `make check` passed again (`check-final.log`); all affected native checks below
74 were rerun on the rebuilt final release.
75 - `build-final.log`: paired ReleaseSafe daemon/GUI and native unit tests passed.
76 `native-e2e-final.log`: the complete standard native integration gate passed.
77 - `real-ssh-final.log`: all five grouped real OpenSSH acceptance checks passed
78 offscreen. `demo-final.log`: the same checks passed on real Sway at 200%.
79 These use a real loopback SSH server and real daemon sessions, not an SSH stub.
80 Existing repository gates also passed; no new separate-VM validation is claimed.
81 - `scale.log`: real Wayland 200% → 100% → 150% → 200%, drag cancellation,
82 independently queried PTY dimensions, and framebuffer checks passed.
83 - `stress.log`, `stress-result.json`: `make native-stress` passed alone on the
84 isolated GPU compositor (NVIDIA GeForce RTX 3080, GLES2). Both binaries came
85 from the ReleaseSafe build graph. About 103.7 MB ran over 30.2 seconds;
86 sampled input-to-painted maxima were 55.4 ms on the shared daemon and 65.2 ms
87 on the separate daemon. Frame p99 was 18.1 ms. Input observations include
88 5 ms polling and are upper bounds; the recording is not a latency measurement.
89
90 The 30.4-second actual GUI demo is retained at
91 `/home/xanderle/.codex/visualizations/2026/09/05/01a071c2-5767-7790-a335-1a84536c9ca5/native-ssh-trial.mp4`.
92 It is 1100×700 H.264 with no audio, joining three continuous clips across window
93 restarts. It shows auth refusal with explicit Retry, correction and recovery,
94 post-attachment refusal, unavailable-host recovery, and missing-session refusal.
95 Use Ctrl+\\ then `p` to open recovery actions. No new ergonomic bindings were added.
96
97 Only a loopback demo server remains, serving the prepared page and video on 18771.
98 The proposed private route `https://charizard.folk-amberjack.ts.net/ssh-followup/`
99 was rejected by automatic approval review because this payload/destination lacks
100 explicit authorization. It has not been published. Existing routes were preserved.
101 The server PID is recorded in `/tmp/muxg-ssh-followup/server.pid`; stop that owned
102 server when the demo is no longer needed. Scripts, clip metadata, and server source
103 remain in `/tmp/muxg-ssh-followup/`. Test daemons, SSH fixtures, and compositor were
104 stopped; final process checks found no remaining final-test fixture processes.
105
106 Canonical diagnostic parsing deliberately leaves unknown/localized/truncated text
107 recoverable. The shared `Reason` line limit remains 120 ASCII bytes. Model evidence,
108 available token counters, lessons, and the follow-up trigger are in RETRO.md.
docs/superpowers/specs/2026-09-05-native-tiling-design.md
Old New
@@ -222,6 +222,13 @@ or exited sessions remain visible; restoration must not silently replace a missi
222 session with a newly created shell. Check the existing attach/create semantics 222 session with a newly created shell. Check the existing attach/create semantics
223 before implementing this guarantee and add a narrow protocol capability if needed. 223 before implementing this guarantee and add a narrow protocol capability if needed.
224 224
225 The authorized [SSH follow-up](../plans/2026-09-05-ssh-error-classification.md)
226 adds a stop condition to automatic reconnect: a recognized SSH authentication
227 refusal retains the pane and its diagnostic but requires explicit Retry after
228 credentials are corrected. This applies during restoration and after connection
229 loss. An unavailable host or unclassified announcement EOF remains recoverable
230 through bounded-backoff reconnect; EOF alone never proves authentication refusal.
231
225 Confirmed: GUI and terminal clients can deliberately select the same daemon 232 Confirmed: GUI and terminal clients can deliberately select the same daemon
226 session from a shared catalogue, while their layouts and automatic pane choices 233 session from a shared catalogue, while their layouts and automatic pane choices
227 remain independent. Sharing a session retains the daemon's existing resize and 234 remain independent. Sharing a session retains the daemon's existing resize and
src/client/client.zig
Old New
@@ -1046,7 +1046,11 @@ pub fn openFailure(buf: []u8, target: Target, err: anyerror, reason: []const u8)
1046 // ssh, or the remote, said why in a whole sentence. It beats 1046 // ssh, or the remote, said why in a whole sentence. It beats
1047 // both of the lines below: `UnterminatedLine` names what mux 1047 // both of the lines below: `UnterminatedLine` names what mux
1048 // observed, while `No route to host` names what happened. 1048 // observed, while `No route to host` names what happened.
1049 else => if (reason.len > 0) failedMsg( 1049 else => if (handoff.classifyReason(reason) == .authentication_refused) failedMsg(
1050 buf,
1051 "mux: authentication refused by {s} over ssh: {s}\n",
1052 .{ h.host, reason },
1053 ) else if (reason.len > 0) failedMsg(
1050 buf, 1054 buf,
1051 "mux: {s} over ssh: {s}\n", 1055 "mux: {s} over ssh: {s}\n",
1052 .{ h.host, reason }, 1056 .{ h.host, reason },
@@ -2749,6 +2753,15 @@ test "openFailure: a reason replaces the error name for a hand target" {
2749 ); 2753 );
2750 } 2754 }
2751 2755
2756 test "openFailure: an OpenSSH authentication refusal is visible as terminal" {
2757 var buf: [open_err_len]u8 = undefined;
2758 const h: Target = .{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
2759 try std.testing.expectEqualStrings(
2760 "mux: authentication refused by box over ssh: Permission denied (publickey).\n",
2761 openFailure(&buf, h, error.UnterminatedLine, "Permission denied (publickey).").msg,
2762 );
2763 }
2764
2752 test "openFailure: --via and --sock say what they know and nothing more" { 2765 test "openFailure: --via and --sock say what they know and nothing more" {
2753 var buf: [open_err_len]u8 = undefined; 2766 var buf: [open_err_len]u8 = undefined;
2754 2767
src/client/handoff.zig
Old New
@@ -65,6 +65,51 @@ pub const ReadLineError = error{
65 /// host`) and narrow enough to sit on a picker row beside a spelling. 65 /// host`) and narrow enough to sit on a picker row beside a spelling.
66 pub const reason_max = 120; 66 pub const reason_max = 120;
67 67
68 /// The small set of outcomes an SSH announce can have for an automatic
69 /// reconnect. A missing diagnostic is deliberately `unknown`: an exit code
70 /// or EOF does not prove that authentication was refused.
71 pub const FailureClass = enum {
72 authentication_refused,
73 unknown,
74 };
75
76 /// Classify only diagnostics whose wording is characteristic of OpenSSH
77 /// refusing authentication. Keep this conservative: in particular, exit
78 /// status 255 and a bare announce EOF are not authentication evidence.
79 pub fn classifyReason(reason: []const u8) FailureClass {
80 const line = std.mem.trim(u8, reason, " \t");
81 const denial = "permission denied (";
82 if (startsWithIgnoreCase(line, denial) and completeDenial(line[denial.len..])) return .authentication_refused;
83 if (std.ascii.indexOfIgnoreCase(line, ": " ++ denial)) |colon| {
84 const prefix = std.mem.trim(u8, line[0..colon], " \t");
85 if (validUserHostPrefix(prefix) and completeDenial(line[colon + 2 + denial.len ..])) return .authentication_refused;
86 }
87 // These server-side messages are only trusted with OpenSSH's framing;
88 // the phrase by itself could have come from a remote banner or command.
89 if (startsWithIgnoreCase(line, "received disconnect") and
90 (std.ascii.indexOfIgnoreCase(line, "too many authentication failures") != null or
91 std.ascii.indexOfIgnoreCase(line, "no more authentication methods to try") != null)) return .authentication_refused;
92 return .unknown;
93 }
94
95 fn validUserHostPrefix(prefix: []const u8) bool {
96 const at = std.mem.indexOfScalar(u8, prefix, '@') orelse return false;
97 if (at == 0 or at + 1 == prefix.len) return false;
98 const user = prefix[0..at];
99 const host = prefix[at + 1 ..];
100 for (user) |c| if (c == ':' or c == ' ' or c == '\t') return false;
101 for (host) |c| if (c == ' ' or c == '\t') return false;
102 return true;
103 }
104
105 fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool {
106 return haystack.len >= needle.len and std.ascii.indexOfIgnoreCase(haystack[0..needle.len], needle) != null;
107 }
108
109 fn completeDenial(methods: []const u8) bool {
110 return methods.len >= 2 and methods[methods.len - 2] == ')' and methods[methods.len - 1] == '.';
111 }
112
68 /// The last COMPLETE line a stderr pipe carried, kept across the pipe's 113 /// The last COMPLETE line a stderr pipe carried, kept across the pipe's
69 /// fragmentation. A struct rather than a buffer the reader appends to: ssh says 114 /// fragmentation. A struct rather than a buffer the reader appends to: ssh says
70 /// several things and dies on the last, and a caller wants one sentence. 115 /// several things and dies on the last, and a caller wants one sentence.
@@ -655,6 +700,29 @@ test "Reason: clear forgets the last line and the partial one behind it" {
655 try std.testing.expectEqualStrings(" a line", r.slice()); 700 try std.testing.expectEqualStrings(" a line", r.slice());
656 } 701 }
657 702
703 test "classifyReason: only conservative OpenSSH authentication diagnostics stop retry" {
704 try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason("Permission denied (publickey,password)."));
705 try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason("Permission denied (keyboard-interactive)."));
706 try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason("xanderle@127.0.0.1: Permission denied (gssapi-with-mic,hostbased)."));
707 try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason("Received disconnect: Too Many Authentication Failures"));
708 try std.testing.expectEqual(FailureClass.unknown, classifyReason("No supported authentication methods available"));
709 try std.testing.expectEqual(FailureClass.unknown, classifyReason("ssh: connect to host box port 22: No route to host"));
710 try std.testing.expectEqual(FailureClass.unknown, classifyReason("banner: authentication failed maintenance notice"));
711 try std.testing.expectEqual(FailureClass.unknown, classifyReason("banner: Too many authentication failures"));
712 try std.testing.expectEqual(FailureClass.unknown, classifyReason("banner: Permission denied (publickey)."));
713 try std.testing.expectEqual(FailureClass.unknown, classifyReason("banner: user@host: Permission denied (publickey)."));
714 try std.testing.expectEqual(FailureClass.unknown, classifyReason(""));
715 // OpenSSH's generic exit status is not enough to identify auth refusal.
716 try std.testing.expectEqual(FailureClass.unknown, classifyReason("255"));
717 }
718
719 test "classifyReason: fragmented stderr keeps a canonical denial terminal" {
720 var reason: Reason = .{};
721 reason.feed("xanderle@127.0.0.1: Permission denied (public");
722 reason.feed("key,password).\n");
723 try std.testing.expectEqual(FailureClass.authentication_refused, classifyReason(reason.slice()));
724 }
725
658 test "announce: format → parse round-trip, with and without the newline" { 726 test "announce: format → parse round-trip, with and without the newline" {
659 var buf: [announce_max_len]u8 = undefined; 727 var buf: [announce_max_len]u8 = undefined;
660 728
src/client/session_pump.zig
Old New
@@ -175,7 +175,7 @@ pub const Pump = struct {
175 if (self.closing.load(.acquire)) return; 175 if (self.closing.load(.acquire)) return;
176 var buf: [1024]u8 = undefined; 176 var buf: [1024]u8 = undefined;
177 const failure = client.openFailure(&buf, self.opts.target, err, dial.reason.slice()); 177 const failure = client.openFailure(&buf, self.opts.target, err, dial.reason.slice());
178 if (first and !(self.opts.retry_initial and retryableOpen(err))) { 178 if (terminalOpenFailure(first, self.opts.retry_initial, err, dial.reason.slice())) {
179 self.publish(.dial_failed, 2, failure.msg); 179 self.publish(.dial_failed, 2, failure.msg);
180 return; 180 return;
181 } 181 }
@@ -386,13 +386,36 @@ pub const Pump = struct {
386 } 386 }
387 }; 387 };
388 388
389 fn retryableOpen(err: anyerror) bool { 389 fn retryableOpen(err: anyerror, reason: []const u8) bool {
390 if (client.handoff.classifyReason(reason) == .authentication_refused) return false;
390 return switch (err) { 391 return switch (err) {
391 error.FileNotFound, error.ConnectionRefused, error.ConnectionTimedOut, error.Timeout, error.NetworkUnreachable, error.HostUnreachable, error.QuicHandshakeFailed, error.UnknownHostName, error.UnterminatedLine => true, 392 error.FileNotFound, error.ConnectionRefused, error.ConnectionTimedOut, error.Timeout, error.NetworkUnreachable, error.HostUnreachable, error.QuicHandshakeFailed, error.UnknownHostName, error.UnterminatedLine => true,
392 else => false, 393 else => false,
393 }; 394 };
394 } 395 }
395 396
397 fn terminalOpenFailure(first: bool, retry_initial: bool, err: anyerror, reason: []const u8) bool {
398 return (first and !(retry_initial and retryableOpen(err, reason))) or
399 (!first and client.handoff.classifyReason(reason) == .authentication_refused);
400 }
401
402 test "retryableOpen: authentication diagnostics are terminal while unknown announce EOF retries" {
403 try std.testing.expect(!retryableOpen(error.UnterminatedLine, "Permission denied (publickey)."));
404 try std.testing.expect(!retryableOpen(error.UnterminatedLine, "Received disconnect from box: 2: Too many authentication failures"));
405 try std.testing.expect(retryableOpen(error.UnterminatedLine, ""));
406 try std.testing.expect(retryableOpen(error.UnterminatedLine, "ssh: connect to host box port 22: No route to host"));
407 // The status code alone does not establish an authentication refusal.
408 try std.testing.expect(retryableOpen(error.UnterminatedLine, "255"));
409 }
410
411 test "reconnect policy: non-auth transport failures retain retry behavior" {
412 try std.testing.expect(!terminalOpenFailure(false, false, error.ConnectionResetByPeer, "connection reset"));
413 try std.testing.expect(!terminalOpenFailure(false, false, error.SystemResources, "resource unavailable"));
414 try std.testing.expect(terminalOpenFailure(false, false, error.UnterminatedLine, "Permission denied (publickey)."));
415 try std.testing.expect(terminalOpenFailure(true, false, error.UnterminatedLine, ""));
416 try std.testing.expect(!terminalOpenFailure(true, true, error.UnterminatedLine, ""));
417 }
418
396 // Only a lost connection earns a redial. Resource exhaustion, poll errors, 419 // Only a lost connection earns a redial. Resource exhaustion, poll errors,
397 // and other local failures must reach entry's failure publication. 420 // and other local failures must reach entry's failure publication.
398 fn connectionFailure(err: anyerror) anyerror!bool { 421 fn connectionFailure(err: anyerror) anyerror!bool {
@@ -917,3 +940,51 @@ test "restored SSH retries an initial announce EOF and joins when the host answe
917 try std.testing.expect(pump.snapshot_ready); 940 try std.testing.expect(pump.snapshot_ready);
918 try std.testing.expectEqual(@as(u64, 37), pump.replica.last_seq); 941 try std.testing.expectEqual(@as(u64, 37), pump.replica.last_seq);
919 } 942 }
943
944 test "restored SSH authentication refusal is terminal and does not retry" {
945 const a = std.testing.allocator;
946 var tmp = std.testing.tmpDir(.{});
947 defer tmp.cleanup();
948 const path = try tmp.dir.realpathAlloc(a, ".");
949 defer a.free(path);
950 const marker = try std.fmt.allocPrint(a, "{s}/runs", .{path});
951 defer a.free(marker);
952 const script = try std.fmt.allocPrint(a, "printf x >> {s}; printf 'Permission denied (publickey).\\n' >&2; exit 255", .{marker});
953 defer a.free(script);
954 const pump = try Pump.start(a, .{ .target = .{ .hand = .{ .host = "fixture", .ssh_argv = &.{ "/bin/sh", "-c", script }, .cache_path = null, .deadline_ms = 200 } }, .session = "native-test", .cols = 11, .rows = 3, .existing_only = true, .retry_initial = true, .open_timeout_ms = 1000 });
955 defer pump.stop();
956 const state = try testPhase(pump, .dial_failed);
957 try std.testing.expectEqualStrings("mux: authentication refused by fixture over ssh: Permission denied (publickey).\n", state.reasonText());
958 const runs = try tmp.dir.readFileAlloc(a, "runs", 16);
959 defer a.free(runs);
960 try std.testing.expectEqual(@as(usize, 1), runs.len);
961 }
962
963 test "reconnecting SSH authentication refusal is terminal after an established pipe" {
964 const a = std.testing.allocator;
965 var tmp = std.testing.tmpDir(.{});
966 defer tmp.cleanup();
967 const path = try tmp.dir.realpathAlloc(a, ".");
968 defer a.free(path);
969 var bytes: std.ArrayList(u8) = .empty;
970 defer bytes.deinit(a);
971 try proto.appendFrame(&bytes, a, .snapshot, &testSnapshot());
972 try tmp.dir.writeFile(.{ .sub_path = "snapshot", .data = bytes.items });
973 const marker = try std.fmt.allocPrint(a, "{s}/runs", .{path});
974 defer a.free(marker);
975 const once = try std.fmt.allocPrint(a, "{s}/once", .{path});
976 defer a.free(once);
977 const release = try std.fmt.allocPrint(a, "{s}/release", .{path});
978 defer a.free(release);
979 const script = try std.fmt.allocPrint(a, "printf x >> {s}; if test ! -e {s}; then touch {s}; printf 'endpoint none\\n'; cat {s}/snapshot; i=0; while test ! -e {s} && test $i -lt 200; do i=$((i+1)); sleep 0.01; done; else printf 'Permission denied (publickey).\\n' >&2; exit 255; fi", .{ marker, once, once, path, release });
980 defer a.free(script);
981 const pump = try Pump.start(a, .{ .target = .{ .hand = .{ .host = "fixture", .ssh_argv = &.{ "/bin/sh", "-c", script }, .cache_path = null, .deadline_ms = 200 } }, .session = "native-test", .cols = 11, .rows = 3, .existing_only = true, .retry_initial = true, .open_timeout_ms = 1000 });
982 defer pump.stop();
983 _ = try testPhase(pump, .attached);
984 try tmp.dir.writeFile(.{ .sub_path = "release", .data = "done" });
985 const state = try testPhase(pump, .dial_failed);
986 try std.testing.expectEqualStrings("mux: authentication refused by fixture over ssh: Permission denied (publickey).\n", state.reasonText());
987 const runs = try tmp.dir.readFileAlloc(a, "runs", 16);
988 defer a.free(runs);
989 try std.testing.expectEqual(@as(usize, 2), runs.len);
990 }
test/native_ssh.py
Old New
@@ -0,0 +1,251 @@
1 #!/usr/bin/env python3
2 """Optional Linux native acceptance with an owned loopback OpenSSH server.
3
4 Run with release mux and muxg paths. Requires sshd, ssh, and ssh-keygen; all keys,
5 configuration, daemon state, sockets, and processes belong to this fixture.
6 The SSH adapter supplies fixture options and records attempts, never outcomes.
7 """
8 import os
9 from pathlib import Path
10 import pwd
11 import shlex
12 import shutil
13 import signal
14 import socket
15 import subprocess
16 import sys
17 import time
18
19 sys.dont_write_bytecode = True
20 from native_lifecycle import LifecycleRig, identity
21 from native_picker import create, end, sessions
22 from native_tiling import eventually, require
23
24
25 class SSHFixture:
26 target = 'mux-native-ssh-fixture'
27
28 def __init__(self, rig):
29 self.rig = rig
30 self.root = rig.root / 'openssh'
31 self.root.mkdir(mode=0o700)
32 self.server = None
33 self.ssh = shutil.which('ssh')
34 self.sshd = shutil.which('sshd')
35 require(self.ssh and self.sshd and shutil.which('ssh-keygen'),
36 'real OpenSSH client, server, and keygen are required')
37 self.attempt_log = self.root / 'attempts'
38 self.attempt_log.touch()
39 self.sock, _ = rig.daemon('ssh-daemon')
40 for key in ('host', 'good', 'bad'):
41 subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '',
42 '-f', str(self.root / key)], check=True)
43 (self.root / 'authorized_keys').write_bytes((self.root / 'good.pub').read_bytes())
44 self.credentials(True)
45 with socket.socket() as reserve:
46 reserve.bind(('127.0.0.1', 0))
47 self.port = reserve.getsockname()[1]
48 user = pwd.getpwuid(os.getuid()).pw_name
49 config = self.root / 'sshd_config'
50 config.write_text(f'''ListenAddress 127.0.0.1
51 Port {self.port}
52 HostKey {self.root}/host
53 PidFile {self.root}/sshd.pid
54 AuthorizedKeysFile {self.root}/authorized_keys
55 StrictModes no
56 PasswordAuthentication no
57 KbdInteractiveAuthentication no
58 UsePAM no
59 AllowUsers {user}
60 LogLevel VERBOSE
61 ''')
62 subprocess.run([self.sshd, '-t', '-f', str(config)], check=True)
63 host_key = (self.root / 'host.pub').read_text().split()
64 (self.root / 'known_hosts').write_text(
65 f'[127.0.0.1]:{self.port} {host_key[0]} {host_key[1]}\n')
66 options = ['-F', '/dev/null', '-p', str(self.port), '-i', str(self.root / 'identity'),
67 '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none',
68 '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=2',
69 '-o', 'StrictHostKeyChecking=yes',
70 '-o', 'UserKnownHostsFile=' + str(self.root / 'known_hosts'),
71 '-o', 'GlobalKnownHostsFile=/dev/null']
72 self.remote_bin = self.root / 'remote-bin'
73 self.remote_bin.mkdir()
74 env = {key: str(rig.root / 'ssh-daemon-state' / key) for key in
75 ('XDG_STATE_HOME', 'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME')}
76 env.update(SHELL='/bin/sh', MUX_KEY_FILE=str(self.root / 'absent-quic-key'),
77 MUX_SOCK='', MUX_SESSION='')
78 mux_wrapper = self.remote_bin / 'mux'
79 mux_wrapper.write_text('#!/bin/sh\nexec env ' +
80 shlex.join([key + '=' + value for key, value in env.items()]) +
81 ' ' + shlex.quote(rig.mux) + ' "$@" --sock ' +
82 shlex.quote(self.sock) + '\n')
83 mux_wrapper.chmod(0o700)
84 adapter_bin = self.root / 'client-bin'
85 adapter_bin.mkdir()
86 adapter = adapter_bin / 'ssh'
87 prefix = 'export PATH=' + shlex.quote(str(self.remote_bin)) + ':"$PATH"; '
88 adapter.write_text('#!' + sys.executable + '\nimport os,sys,time\n' +
89 'args=sys.argv[1:]\n' +
90 'i=args.index(' + repr(self.target) + ')\n' +
91 'with open(' + repr(str(self.attempt_log)) + ',"a") as f:\n' +
92 ' f.write(str(os.getpid())+" "+str(time.monotonic())+"\\n")\n' +
93 'command=' + repr(prefix) + '+" ".join(args[i+1:])\n' +
94 'os.execv(' + repr(self.ssh) + ',' + repr([self.ssh, *options]) +
95 '+args[:i]+[' + repr(user + '@127.0.0.1') + ',command])\n')
96 adapter.chmod(0o700)
97 rig.env['PATH'] = str(adapter_bin) + os.pathsep + rig.env.get('PATH', '')
98 self.start()
99
100 def credentials(self, valid):
101 shutil.copyfile(self.root / ('good' if valid else 'bad'), self.root / 'identity')
102 (self.root / 'identity').chmod(0o600)
103
104 def attempts(self):
105 return [int(line.split()[0]) for line in self.attempt_log.read_text().splitlines()]
106
107 def start(self):
108 self.server = self.rig.spawn([self.sshd, '-D', '-e', '-f',
109 str(self.root / 'sshd_config')],
110 'sshd-' + str(time.time_ns()))
111 def ready():
112 require(self.server.poll() is None, 'fixture sshd exited; inspect sshd log')
113 with socket.socket() as probe:
114 return probe.connect_ex(('127.0.0.1', self.port)) == 0
115 eventually(ready, 'fixture sshd did not listen')
116
117 def stop(self):
118 if self.server is not None and self.server.poll() is None:
119 self.server.terminate()
120 try:
121 self.server.wait(timeout=3)
122 except subprocess.TimeoutExpired:
123 self.server.kill()
124 self.server.wait(timeout=3)
125
126
127 def run(r, ssh, show=lambda label: None):
128 local, _ = r.daemon('neighbour')
129 create(local, 'neighbour')
130 create(ssh.sock, 'remote')
131 r.catalogue(['--sock ' + local, ssh.target])
132 r.launch_gui([], 'initial', attached=False)
133 r.choose('--sock ' + local)
134 r.picker('sessions')
135 r.choose('neighbour')
136 r.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
137 r.host(ssh.target, 'v')
138 r.choose('remote')
139 state = r.wait_state(lambda s: len(s['panes']) == 2 and
140 all(p['phase'] == 'attached' for p in s['panes']))
141 local_id, remote_id = [p['id'] for p in state['panes']]
142 r.mark(remote_id, ssh.sock, 'remote', 'REAL-SSH-SESSION')
143 before = identity(r.state())
144 r.quit()
145 ssh.credentials(False)
146 count = len(ssh.attempts())
147 r.launch_gui([], 'restore-auth-refused', attached=False)
148 state = r.wait_state(lambda s: any(p['id'] == remote_id and p['phase'] == 'dial_failed'
149 for p in s['panes']))
150 require(identity(state) == before, 'authentication refusal changed saved pane identity')
151 r.chord('p')
152 state = r.wait_state(lambda s: s['recovery'] is not None)
153 require('Permission denied' in state['recovery']['notice'], 'SSH refusal reason missing from recovery')
154 r.pixels()
155 show('Authentication refused: pane retained; explicit Retry available')
156 time.sleep(1.4)
157 require(len(ssh.attempts()) == count + 1, 'authentication refusal automatically retried on restore')
158 r.key('escape')
159 r.mark(local_id, local, 'neighbour', 'NEIGHBOUR-STILL-USABLE')
160 require('remote' in sessions(ssh.sock), 'authentication failure ended the remote session')
161 r.ok('real SSH auth refusal stops restore retries, retains identity/reason, and isolates neighbour')
162
163 ssh.credentials(True)
164 count = len(ssh.attempts())
165 time.sleep(.5)
166 require(len(ssh.attempts()) == count, 'correcting credentials resumed without explicit Retry')
167 r.focus(remote_id)
168 r.recover('Retry')
169 r.wait_state(lambda s: all(p['phase'] == 'attached' for p in s['panes']))
170 r.mark(remote_id, ssh.sock, 'remote', 'EXPLICIT-RETRY-RECOVERED')
171 show('Corrected key + Retry: existing SSH session resumes')
172 r.ok('explicit Retry with corrected credentials rejoins the existing real SSH session')
173
174 ssh.credentials(False)
175 count = len(ssh.attempts())
176 attached_ssh = ssh.attempts()[-1]
177 require(Path(f'/proc/{attached_ssh}/exe').resolve() == Path(ssh.ssh).resolve(),
178 'recorded attachment PID is not the actual OpenSSH client')
179 os.kill(attached_ssh, signal.SIGTERM)
180 r.wait_state(lambda s: any(p['id'] == remote_id and p['phase'] == 'dial_failed'
181 for p in s['panes']))
182 time.sleep(1.4)
183 require(len(ssh.attempts()) == count + 1, 'authentication refusal automatically retried after disconnect')
184 r.chord('p')
185 require('Permission denied' in r.wait_state(lambda s: s['recovery'] is not None)['recovery']['notice'],
186 'post-attachment auth refusal lost reason')
187 show('Connection lost, key refused: automatic reconnect stops')
188 r.key('escape')
189 ssh.credentials(True)
190 r.recover('Retry')
191 r.wait_state(lambda s: all(p['phase'] == 'attached' for p in s['panes']))
192 r.ok('real SSH authentication refusal also stops retries after a previously attached connection')
193
194 r.quit()
195 ssh.stop()
196 count = len(ssh.attempts())
197 r.launch_gui([], 'restore-unavailable', attached=False)
198 r.wait_state(lambda s: any(p['id'] == remote_id and p['phase'] == 'reconnecting'
199 for p in s['panes']))
200 eventually(lambda: len(ssh.attempts()) >= count + 2, 'unavailable SSH host did not retry')
201 require(len(ssh.attempts()) <= count + 5, 'unavailable host retries are unbounded')
202 times = [float(line.split()[1]) for line in ssh.attempt_log.read_text().splitlines()[count:]]
203 require(times[1] - times[0] >= .15, 'unavailable host skipped reconnect backoff')
204 r.mark(local_id, local, 'neighbour', 'OFFLINE-NEIGHBOUR-USABLE')
205 show('SSH host unavailable: bounded reconnect; neighbour remains usable')
206 ssh.start()
207 r.wait_state(lambda s: all(p['phase'] == 'attached' for p in s['panes']), seconds=10)
208 r.mark(remote_id, ssh.sock, 'remote', 'HOST-RETURNED-AUTOMATICALLY')
209 show('SSH host returns: session reconnects automatically')
210 r.ok('unavailable real SSH host retries and recovers automatically')
211
212 r.focus(remote_id)
213 r.quit()
214 end(ssh.sock, 'remote')
215 r.launch_gui([], 'missing-session', attached=False)
216 r.wait_state(lambda s: any(p['id'] == remote_id and p['phase'] == 'refused'
217 for p in s['panes']))
218 require('remote' not in sessions(ssh.sock), 'restore recreated a missing session')
219 show('Missing remote session stays missing: restore never creates it')
220 r.quit()
221 ssh.stop()
222 r.launch_gui([], 'shutdown-offline', attached=False)
223 r.wait_state(lambda s: any(p['phase'] == 'reconnecting' for p in s['panes']))
224 start = time.monotonic()
225 r.quit()
226 require(time.monotonic() - start < 2, 'shutdown during SSH retries exceeded two seconds')
227 r.assert_cli_untouched()
228 r.ok('SSH restoration stays join-only; shutdown during reconnect is bounded; CLI layout untouched')
229
230
231 def main():
232 require(len(sys.argv) == 3, 'usage: native_ssh.py RELEASE_MUX RELEASE_MUXG')
233 r = LifecycleRig(*sys.argv[1:])
234 ssh = None
235 try:
236 ssh = SSHFixture(r)
237 run(r, ssh)
238 except BaseException:
239 r.failure_artifacts()
240 raise
241 finally:
242 try:
243 if ssh:
244 ssh.stop()
245 finally:
246 r.close()
247 print('Real SSH native checks passed; owned fixtures stopped. Evidence:', r.root, flush=True)
248
249
250 if __name__ == '__main__':
251 main()