Skip to content

mux: release hardening before dev-mux → main (8 fixes) - #512

Merged
benyblack merged 12 commits into
dev-muxfrom
claude/eager-cori-ey02wa
Oct 10, 2026
Merged

benyblack merged 12 commits into
dev-muxfrom
claude/eager-cori-ey02wa

Conversation

@benyblack

Copy link
Copy Markdown
Owner

What this changes

Eight defects found reviewing the merged dev-mux tree (ee75a32), fixed one commit per item. Each has a test that failed first. There are no new settings and no new verbs. MuxProtocol stays 1–2. The rulings are recorded as H1–H8 at the end of docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md.

# Commit Fix Tests (seen failing first)
8 8722d67 The kill path (MuxDaemonStop.Terminate) needs a recorded start time that matches the process's readable one. The liveness probe stays lenient. MuxDaemonStopTests.A_descriptor_without_a_start_time_is_not_terminated (failed). The differing-start-time case already held and is now pinned.
4 d4ef73c A build with pins (a release build) refuses a RID that has no usable pin. A malformed pin resource is kept as unusable, not dropped. CI checks each .sha256 against ^[0-9a-f]{64} ntilde-mux-<rid>$ (scripts/ci/check-mux-sha256.sh, self-tested in ci.yml). A_release_build_without_a_pin_for_the_rid_refuses_the_download (replaces A_pin_for_another_rid_changes_nothing), …_refuses_a_self_consistent_cache_too, An_unusable_embedded_pin_refuses_the_download, MuxAssetPinsTests.Foreign_resources_are_ignored_and_a_malformed_pin_is_kept_as_unusable, ReleaseWorkflowTests.Every_app_build_that_embeds_the_ntilde_mux_pins_checks_their_format_first, scripts/tests/mux_sha256_check_tests.py. All failed first.
6 1fc0b1b Workflow test: every vpk pack passes --releaseNotes artifacts/velopack-release-notes.md, and an earlier step writes it from mux-protocol-range.sh. The runtime stays fail-open, but a release build logs [Warning] when the marker is missing. ReleaseWorkflowTests.Every_vpk_pack_ships_the_multiplexer_protocol_marker (mutation-checked: fails when one --releaseNotes is dropped or the notes step stops running the script), MuxUpdateCompatibilityTests.A_release_build_warns_when_the_marker_is_missing
7 6ff76d1 proxy --stdio --no-spawn exits 5 (NotRunning) and starts nothing. ls --all uses it and shows "no multiplexer is running". An older ntilde-mux rejects the option with its usage error (exit 2), which is shown as "update it". The picker's Connect row says it starts the multiplexer. Reverses R28(a). MuxProxyTests.A_no_spawn_proxy_with_no_daemon_exits_not_running_and_starts_nothing, Proxy_parses_stdio_and_an_optional_no_spawn, MuxCommandLsAllTests.A_host_with_no_multiplexer_running_says_so_and_none_is_started, …predates_no_spawn…, MuxSessionPickerTests.A_connect_row_offers_to_connect_to_its_host. All failed first.
3 30dbe76 A listing's OpenSSH run gets -o ForwardAgent=no and drops -A. Native never forwards an agent. A --no-spawn proxy also leaves the agent link alone. MuxProxyTests.A_listing_proxy_leaves_an_existing_agent_link_untouched, OpenSshExecCommandLineTests.A_listing_*, RemoteMuxHostFactoryTests.An_OpenSSH_listing_forwards_no_agent…, MuxCommandLsAllTests.Every_listing_transport_is_asked_to_forward_no_agent. All failed first. Plus a real ssh -G check (A_listings_ssh_forwards_no_agent_whatever_the_config_and_the_extra_arguments_say).
5 ccc07de rusty_ssh reports a refused transfer or listing password with status auth-failed (same result code), which becomes NativeSshAuthenticationRefusedException. The sidebar listing, path completion and native transfer then compare-and-remove the target password they took from the host scope. RemoteDirectoryBrowserServiceTests.A_refused_listing_drops_the_scope_password_it_offered (failed), A_late_refusal_of_the_old_password_does_not_clear_a_newly_typed_one, SftpServiceTests.A_refused_transfer_drops_…(false) (failed), Rust classify_sftp_transfer_error_marks_a_refused_password_auth_failed (failed: did not compile without the variant)
1 90c4eb9 Persistence turned Off during the window's life: closing the window ends the local shells, as "Close them" does. MainWindowMuxLifecycleTests.Turning_persistence_off_then_closing_ends_the_open_shells, MainWindowFirstCloseTests.With_persistence_turned_off_* (5 cases). All failed first.
2 a5156f4 On macOS, ShutdownRequested is cancelled when the window would hold a close. The close is settled first, then desktop.Shutdown() runs. Logout, restart and shutdown are told apart by the quit Apple event's 'why?', because Avalonia's IsOSShutdown is internal. MainWindowFirstCloseTests.Cmd_Q_* covers Keep (± Don't ask again), Close them, Cancel, a remembered answer and a remembered close with pending tabs. 5 failed against the stub. Also An_OS_shutdown_request_is_never_held and MacQuitReasonTests.

Optional items:

  • Done (1f6dcfa): a once-per-launch notice when the daemon runs from the install folder.
  • Not done: the windowless alt-screen flag in sessionInfo, and busy for a second in-flight readScreen. Both would add a protocol field or error code, which the brief rules out.
  • Not done: relaxing vault reuse for jump-hop target prompts. It is a credential-path change (R7), and the brief says credential rules stay unchanged.

Invariant and ownership

  • What invariant does this change affect? Daemon kill safety, remote install verification, the R1/R19 close semantics, scope password lifetime (R8/R30), and listings having no side effects.
  • Which module owns that invariant? Ntilde.Mux / Ntilde.Mux.Contracts (proxy, stop), Ntilde.App (close path, lister, asset source, SFTP), Ntilde.Platform (OpenSSH argv, native interop), rusty_ssh.

Tests

  • What tests cover this change? See the table.
  • Which categories did you run locally? (Linux only. The .NET SDK was 10.0.112: 10.0.400 cannot be downloaded in this environment, so global.json was relaxed locally and not committed.)
    • full unfiltered run (scripts/build.sh test): each project was run separately instead:
      • Architecture: 117 passed
      • Mux: 993 passed, 18 skipped
      • Platform: 644 passed, 35 skipped
      • McpServer: 202 passed
      • VT: 860 passed
      • Rendering: 70 passed, 44 skipped
    • Category=Replay
    • Category=RenderMetrics
    • Category=PtySmoke
    • tests/Ntilde.App.Tests, run in two lanes:
      • Lane!=PlatformBoot: 6171 passed, 69 skipped
      • Lane=PlatformBoot: 59 passed, 4 skipped
    • full local CI rehearsal
    • Also run: rusty_ssh cargo test --release (133 + 5 passed), and the scripts/tests/*.py script tests (pass; aot_smoke_verdict_tests.py needs pwsh, which is not installed here).
  • dotnet format whitespace --verify-no-changes flags only src/Ntilde.VT/TerminalBuffer.WritePath.cs, which this PR does not touch. The 10.0.112 formatter may differ from the pinned SDK's.

Impact

  • Does this affect cross-platform behavior? Yes. Tested on Linux only. Item 2 still needs the manual macOS check:

    • Cmd+Q with a live shell asks the question.
    • Keep, Close them and Cancel each behave as chosen.
    • "Don't ask again" sticks.
    • Logging out with Ntilde open does not stop on the question.

    The objc_msgSend quit-reason read is not exercised on a Mac here; its macOS test only checks that it does not crash.

  • Does this change renderer metrics? No.

  • Does this change VT coverage? No.

🤖 Generated with Claude Code

https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ


Generated by Claude Code

claude added 10 commits October 9, 2026 15:13
MuxDaemonStop.Terminate re-verified the pid by name and start time, but
StartTimeMatches answered true when the descriptor had no start time or
the process's could not be read. The kill path is now reachable from the
GUI restart and the uninstall hook, and the GUI shares the daemon's
process name, so a name-only match could kill the wrong ntilde.

The kill path now requires a readable, matching start time
(requireStartTime: true); the liveness probe keeps its leniency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
GitHubReleaseMuxAssetSource accepted a download on the served checksum
whenever the embedded pin set had no entry for the RID, and MuxAssetPins
dropped a malformed pin silently, so a release build missing one pin
quietly fell back to trusting the file that sits beside the binary.

- With any pins embedded (a release build), a RID without a usable pin
  is refused with InvalidDataException before the cache or the network
  is touched. No pins at all (a dev build) keeps the .sha256 fallback.
- MuxAssetPins keeps a well-named but unreadable resource as Unusable
  instead of dropping it.
- release.yml checks each checksum is exactly
  '<64 lowercase hex>  ntilde-mux-<rid>' (scripts/ci/check-mux-sha256.sh,
  self-tested in ci.yml) before embedding, and a workflow test pins that
  every App build embedding the pins runs it first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
…t is absent

The installed app reads the new build's multiplexer protocol range from
the staged update's release notes, and a missing marker reads as
compatible (R10). Nothing pinned the workflow side.

- ReleaseWorkflowTests: every vpk pack passes --releaseNotes
  artifacts/velopack-release-notes.md, and an earlier step of the same
  job writes it from scripts/ci/mux-protocol-range.sh.
- The runtime stays fail-open, but a release build (one that embeds the
  ntilde-mux pins) logs a [Warning] line when the staged notes carry no
  readable marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
A listing must have no side effects, but ls --all connected through
'ntilde-mux proxy --stdio', which spawns a daemon on demand: every
listed host without one got a daemon (idle for 10 minutes).

- ntilde-mux: 'proxy --stdio --no-spawn' connects only to a running
  daemon; with none it starts nothing and exits 5
  (MuxProxyExitCodes.NotRunning).
- The lister's connector runs that command (ForListing) and reports
  'no multiplexer is running' as a fixed-cause row. An ntilde-mux older
  than the option refuses it with its usage (exit 2), which is reported
  as 'its ntilde-mux is older than this app; update it to list its
  sessions' rather than as a failed connection.
- The picker's Connect row is a connect and may keep spawning; its text
  now says so.

Reverses R28(a). User manual updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
The proxy points the daemon's stable agent link at its own connection's
SSH_AUTH_SOCK on every connect. ls --all connects through it for a few
seconds and goes, so with 'ForwardAgent yes' in the user's ssh config
every shell in that daemon was left holding a dead agent until a window
reconnected. ClearAllForwardings=yes does not clear ForwardAgent.

- A listing's OpenSSH exec passes -o ForwardAgent=no ahead of the plan,
  and drops a -A from the profile's extra arguments (ssh applies -A
  whatever an earlier -o said); checked against the real ssh -G. The
  native transport never requests agent forwarding.
- The request carries Listing (set by the lister's connector), and the
  OpenSSH transport exposes NoAgentForwarding.
- Belt and braces on the host: a --no-spawn proxy (only listings pass
  it) leaves an existing agent link untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
… refused

A persisted remote tab's SFTP, sidebar listing and path completion read
the host's password scope before the vault, but only the mux link's own
connect ever removed a refused value, and that link is long-lived and
never signs in again. After a server-side rotation every click offered
the stale password again, for the host's whole life.

- rusty_ssh: a transfer's or listing's refused password is now a
  structured AuthenticationFailed error, reported with status
  'auth-failed' and the same result code as before.
- NativeSshInterop turns that status (never the message) into
  NativeSshAuthenticationRefusedException, an InvalidOperationException
  as before.
- NativeHopPasswords records the host scope the target password came
  from (not a plain session's own, not the vault, never with jump
  hops); on a refusal the sidebar listing, path completion and native
  transfer call RemoveRuntimePassword with the value offered, so a
  password typed since is kept and a gone host's scope stays gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
ApplySessionPersistenceSetting swaps the factory but leaves the mux
panes connected; IsMuxPersistenceActive was then false, so the close
neither asked nor ended anything and every open daemon shell was
stranded, while the Settings row says 'With Off, a shell ends when its
window closes'.

When persistence was turned Off during the window's life (the setting
is Off, the factory swapped away, the hosts still there), the close now
ends the local shells as a remembered 'Close them' does:
- shares, and shells another client shows, detach;
- shells of tabs not shown yet (pending ids, placeholder trees) end once
  the daemon says no other client shows them, the close held meanwhile;
- a shutdown cannot be held: it ends the live shells only, and
  PerformAppTeardown ends them on every teardown route before the save.
A window Off from the start has no hosts, so nothing changes for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
On macOS Cmd+Q is the normal way to quit, but it reached the window as
an ApplicationShutdown close, which applies a remembered answer and
otherwise keeps the shells silently: the first-close explanation and
'Don't ask again' were unreachable there (R19).

- App subscribes the lifetime's ShutdownRequested on macOS. When the
  main window would hold a window close (the same decision, run dry),
  the request is cancelled, the window closes the way its close button
  does - asked, or a remembered 'Close them' waiting for the daemon -
  and once that close went through, desktop.Shutdown() quits. Cancel
  keeps Ntilde running.
- A logout, restart or shutdown is never held: Avalonia's IsOSShutdown
  is internal, so the quit Apple event's 'why?' reason is read through
  the Objective-C runtime (MacQuitReason). Windows and X11 raise
  ShutdownRequested only for the session ending, so they are not
  subscribed.
- The held close's posted work is tracked (_heldCloseSettled) so the
  quit follows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
…folder

When MuxDaemonImage.Resolve cannot stage the daemon's own copy on a
Windows Velopack install, the daemon runs from the install folder: every
update then stops it and its shells, and every startup update is held.
That was only in debug.log. The real resolver now marks the fallback,
and the window shows a fixed-text notice once per launch when the local
daemon connects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1a4f35f4-40ae-4671-aec3-848e65b14d82

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High impact] The PR appears safe to merge; no blocking finding remains from this review.

Summary

This PR hardens shell close behavior, remote listings, password cleanup, release checksums, and daemon stop safety.

  • Turning persistence off makes a window close end its local shells.
  • macOS Cmd+Q now follows the window’s first-close choice.
  • Remote listings check hosts without starting a multiplexer or forwarding an agent.
  • Release builds refuse remote daemon downloads without a usable platform pin.
  • Release updates include the mux protocol marker, and missing markers are logged.
  • Refused host passwords are cleared without erasing a newer password.
  • Daemon termination now requires a readable, matching process start time.
  • Ntilde shows a once-per-launch notice when its daemon runs from the install folder.

Reviews (3) · Last reviewed commit: "test(ssh): a refused SFTP password is Na..." · Reviewed by Greptile

Comment thread scripts/ci/check-mux-sha256.sh Outdated
Comment thread src/Ntilde.App/MainWindow.MuxRestart.cs Outdated
claude added 2 commits October 9, 2026 16:57
…ly when it can be shown

- check-mux-sha256.sh pinned only that one line matched, which Git Bash's
  grep (it drops a CR) and a trailing unterminated line both slipped
  past: the Windows CI leg failed its CRLF case. The file's byte length
  is now pinned to the line plus at most one LF. Greptile on PR 512.
- The install-folder notice was taken before the posted callback checked
  the teardown, so a window closing first lost it for the launch. It is
  taken inside the callback now. Greptile on PR 512.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
…xception

The Docker E2E NativeSftp_BadPassword_ReturnsAuthenticationFailed
asserted the exact type InvalidOperationException; release hardening
item 5 raises its new subclass for the native auth-failed status. The
test now pins the specific type, and that it is still an
InvalidOperationException to other callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RmDfMhhR8MKG4ejF3h6QQ
@sonarqubecloud

sonarqubecloud Bot commented Oct 9, 2026

Copy link
Copy Markdown

Copy link
Copy Markdown
Owner Author

ntilde-mux AOT (osx-arm64) failed on b368c03, but not because of this PR. The binary built fine (5,938,264 bytes). The job then failed in actions/upload-artifact with getaddrinfo ENOTFOUND productionresultssa12.blob.core.windows.net: the runner could not resolve GitHub's artifact storage. b368c03 changes only a test assertion, and the same job passed on dda5d26. No code fix applies. I'll re-run the failed job once when this workflow run finishes (GitHub refuses a re-run while the run is still going).


Generated by Claude Code

@behnam-malinode

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@benyblack
benyblack merged commit d1509f4 into dev-mux Oct 10, 2026
63 of 64 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants