# Ntilde SSH Roadmap _Last reviewed: 2026-04-27._ Ntilde supports two SSH backends: - **OpenSSH** (production) — drives `ssh` in a PTY with a Ntilde-generated config file. - **Native SSH** (default for new profiles) — an in-process Rust SSH crate with a poll-based ABI, bypassing external `ssh` entirely. --- ## Native SSH status (current) _Source date: 2026-04-08, updated through 2026-04-27._ The native SSH initiative is implemented behind conservative rollout controls. ### Completed native SSH capabilities - Backend split between OpenSSH and native SSH - Native Rust SSH crate with poll-based ABI - Avalonia host-key and auth dialogs - SSH agent authentication (SSH_AUTH_SOCK on Unix; the OpenSSH service pipe, then Pageant, on Windows) — agent identities are offered after a configured identity file (which keeps first position, so an agent full of unrelated keys cannot exhaust the server's auth tries before the key that used to connect the profile) and before anything that prompts; a profile explicitly pinned to an identity file skips the agent entirely. No agent, an empty agent, and refused keys all fall through, and every agent protocol operation is time-bounded (signing gets a longer allowance for hardware-token touch prompts), so a wedged agent reads as no agent rather than a hung session. Jump hops and SFTP transfers take the same path, including the file-then-agent fallback. - App-managed native known-host trust store - Native SFTP file and folder upload/download - Local port forwarding - Direct-host dynamic port forwarding (SOCKS5) - Jump-host support, single hop or a chain of any length (nested direct-tcpip, as OpenSSH treats `-J`) - Remote port forwarding (server-side `tcpip-forward` listeners, as OpenSSH treats `-R`) - Rollout controls, backend selector, and stable failure classification - Resize coalescing for fullscreen TUIs (vim, htop, tmux) - Keepalive honoring user settings - Disconnect state surfaced in the terminal pane - Runtime (session-scoped) password memory, opt-in - Connection sharing, the native ControlMaster (`nova_ssh_connect_shared`, `nova_ssh_open_shell`): with the profile's multiplexing on, its tabs share one connection from `NativeSshConnectionPool`, keyed by the profile and every connection setting (destination, credentials, jump chain, keepalive, forwards). The connection owns the prompts and the port forwards; a local or dynamic forward whose port cannot be bound is left out with a warning to every tab on it (OpenSSH's `ExitOnForwardFailure no`), where an unshared native tab still fails; each tab gets its own shell channel and event queue. The connection closes with its last tab, or after the profile's ControlPersist seconds once it is up. When the transport goes, every shell on it gets Closed and the next tab reconnects. Exec channels (`nova_ssh_open_exec`: persistent tabs' `ntilde-mux proxy`, the installer's commands) join or open the same connection, forwards included. SFTP transfers and listings (`nova_ssh_sftp_transfer_shared`, `nova_ssh_sftp_list_directory_shared`) run on it when it is up and logged in, and connect on their own otherwise: they cannot answer a prompt, so they never open one. - Exec mode (`nova_ssh_exec`): a command with no PTY, with stdin EOF, stderr and the exit status as events — the channel persistent remote tabs and the `ntilde-mux` installer run over (see *Remote session persistence* below) ### Rollout guidance - `Native` is the default backend for NEW profiles, wherever the global native toggle (`TerminalSettings.ExperimentalNativeSshEnabled`, on by default, Settings > SSH) is enabled; with the toggle off, new profiles default to `OpenSsh`, so the default can never point at a backend that refuses to connect. Existing profiles keep their stored backend, and the model-level defaults stay `OpenSsh` on purpose: a profile store whose JSON predates the backend field must load as the backend it was actually using, never silently migrate. - Native SSH does **not** silently fall back to OpenSSH on failure. - Settings the native backend cannot honor are warned about, not silently dropped: a native profile with extra SSH arguments, which drive the OpenSSH client and have no native equivalent, gets a notice in the profile editor and a warning line in the terminal at connect time. The arguments stay stored, so switching the profile back to OpenSSH restores them intact. Multiplexing is honored natively (connection sharing, above); only a profile with `ControlMasterAuto` off, which the editor never writes, gets a terminal warning and a connection per tab. - Whether native *can* serve a given profile is answered in one place, `NativeSshCapability.Evaluate`. Its one refusal today is a remote forward with source port 0 (a server-allocated listen port, OpenSSH's `-R 0:`): the native backend matches incoming connections back to their rule by the requested port, so a server-picked port has no rule to land on yet. Everything else — jump-hop chains, local/dynamic/remote forwards — is served natively. The gate's call sites (editor at save time, factory and session at connect time) all enforce the same verdict. - Beyond that one shape, the only native refusal left is the global experimental toggle — reversible under Settings > SSH. - Native SSH file transfers use the built-in native SFTP path. - Native SSH transfer dialogs can autocomplete remote paths when an active session for that profile already exists. - Native SSH single-file transfers show live byte progress when the total size is known; folder transfers report per-file progress only. - OpenSSH file transfers still use the system `scp` path. - Native SSH supports local and dynamic forwards, on direct connections and through jump hops alike — the forward channels ride the target session, however that session was reached. - Native SSH supports jump chains of any length. Each hop is a full SSH session (its own host-key verification and authentication) nested over a direct-tcpip channel of the previous hop; SFTP transfers and remote browsing take the same chain. - Native SSH supports remote forwards: a `tcpip-forward` listener is requested on the server per rule once the session is established (the Connected event, so no thread waits out the handshake), and incoming connections ride the same forward-channel machinery as the outgoing kinds. A request the server refuses is loud but not fatal, matching `ssh -R`: the warning is printed into the terminal and the session survives. Forwarded-tcpip channels the client never asked for are refused at the Rust handler, so an unsolicited open from a hostile server is never registered. ### Verification - See [`docs/native-ssh/Native_SSH_Test_Matrix.md`](native-ssh/Native_SSH_Test_Matrix.md) for the automated verification set and the remaining manual checks. ### Open question: routing a profile that expressed no preference `NativeSshCapability` answers "can native serve this profile?", which is what a future default flip needs — but it is deliberately *not* wired to a routing decision yet, and `SshBackendKind` still has only `OpenSsh` and `Native`. Adding an `Auto` value looks like the obvious next step and is a trap in its current form. Roughly eight sites decide native-only behaviour by comparing the **persisted** backend kind to `SshBackendKind.Native` — the SFTP path (`SftpService`), the remote file browser and path autocomplete (`RemoteDirectoryBrowserService`), session-scoped password memory (`ActiveSshSessionRegistry`), replay/agent-host descriptors, and the pane's own checks. An `Auto` profile that resolved to native at connect time would silently take the *OpenSSH* path in all of them, so file transfers and remote browsing would quietly use `scp`/no-op paths against a native session. The backend combo in the editor also binds straight to `Enum.GetValues()`, so a new value appears in the UI unlabelled. So `Auto` needs a resolved-backend concept threaded through those call sites first — a session-scoped answer rather than a profile field. That is its own change, not a rider on the capability gate. --- ## Remote session persistence _Source date: 2026-10-06 (multiplexer Phase 4, `docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md`)._ An SSH tab whose profile opts in (`SshMuxOptions.PersistRemoteSessions`, the editor's **Keep remote sessions running (ntilde-mux)**, with `TerminalSettings.SessionPersistence` on) runs its shell inside `ntilde-mux`, Ntilde's multiplexer daemon, on the remote host. The GUI reaches the daemon through an SSH exec channel running `ntilde-mux proxy --stdio`, so the shell survives a network drop, a closed window and an app restart, and a reconnect reattaches with a snapshot. The user-facing description is `docs/user/persistent-sessions.md` ("Remote SSH tabs that keep running"); the design is `docs/ARCHITECTURE.md` §8.2. ### Both backends - **OpenSSH** (`Ntilde.Platform.Ssh.Exec.OpenSshExecTransport`) runs `ssh` from the profile's generated config with `-T -o ClearAllForwardings=yes -o ControlMaster=no`, never a PTY. It reads the profile's extra arguments with ssh's own option rules (clusters such as `-tv` included) and drops what would stop the proxy from running on the channel: `-t`, `-T`, `-N`, `-f`, `-n`, `-s`, `-G`, `-V`, `-M`, `-W`/`-O`/`-Q` with their argument, and the `-o` keywords `RequestTTY`, `SessionType`, `ForkAfterAuthentication`, `StdinNull`, `RemoteCommand` and `PermitLocalCommand`. A user-started connect uses Ntilde as `SSH_ASKPASS`, so prompts appear as Ntilde dialogs (the vault password is offered only to a prompt that names the target's `user@host`, and not through a jump host it cannot tell from the target). An automatic reconnect never prompts: it runs in batch mode, or, when the profile's password is saved in the vault and ssh is 8.4 or newer, with the askpass helper in its vault-only mode, which answers the target's password once and refuses everything else. - **Native** (`NativeSshExecTransport`) uses rusty_ssh's exec mode: `nova_ssh_exec(args, command)` takes the same hop, auth and prompt path as a shell session (jump chains, identity files, agent, known hosts, keepalive), then opens a session channel and `exec`s the command with no PTY and no shell detection. Stdout arrives as `Data` events, stderr as `ExtendedData`, the command's EOF as `Eof` (kind 15; `NativeSshExecTransport` ends stdout there, without waiting for the command to exit), the exit status as `ExitStatus` before `Closed`, and `nova_ssh_send_eof` ends stdin. (The login-shell detection of a shell session runs a short exec through `run_exec_collect`, of which exec mode is the streaming counterpart.) Without multiplexing, an exec connection is its own connection: it lives exactly as long as its channel and never shares a pane's. With the profile's multiplexing on, the exec leases the profile's shared connection instead (see *Connection sharing* above), which closes with its last lease, at once or after ControlPersist. - **Automatic reconnects never prompt**, on either backend. A native remote host remembers, in memory, a password or passphrase that got a user-started connect in, and replays only that (never a password through jump hops, since the native prompt does not say which hop asks); host keys must already be trusted. When a password would be needed, the attempt closes the session instead of sending an empty answer, and the retries stop, so a drop never turns into a stream of failed logins. ### Install The connection editor's **Install ntilde-mux on this host…** probes the host over an exec channel (`uname`, the C library, `$HOME`), takes the matching `ntilde-mux-` from the GitHub release (SHA-256-verified and cached) or from a file the user picks, and streams it over another exec into `~/.local/share/ntilde/bin/ntilde-mux`, or under `$XDG_DATA_HOME` when the host sets it (upload by `cat` to a temp file, size check, trial run, then rename: this replaces a running binary safely, answers prompts, and sets the mode, none of which `RunSftpTransfer` does). The release's hashes are built into the app, so a download must match them. Supported hosts: Linux x64/arm64 with glibc 2.34 or newer, macOS arm64. musl, BSD, Intel Macs and Windows hosts are refused with the reason, and the tab stays plain SSH. On the host, `ntilde-mux` keeps its descriptor, socket, lock and log under a root of its own, `~/.local/share/ntilde/ntilde-mux` (under `$XDG_DATA_HOME` when set; `~/Library/Application Support/ntilde/ntilde-mux` on macOS), never the Ntilde app's: on a host that also runs the app, the app's daemon and `ntilde-mux` stay apart, so neither serves (or adopts, or shuts down) the other's shells. ### Deferred - Port forwards on a persistent remote tab: without multiplexing the exec channel carries no forwards (Phase 5 spec R8: they would share the mux link's event queue, drop on every reconnect and collide with plain tabs of the profile). A native profile with multiplexing on avoids all three: its exec rides the profile's shared connection, which owns the forwards, serves them on the connection's own queue, and is the same connection its plain tabs use. Since Phase 5 the SFTP sidebar and transfers work there for native profiles: the pane registers in `ActiveSshSessionRegistry` with its remote host's password scope. OpenSSH persisted tabs get the palette's transfers (scp on its own connection), not the sidebar. - Adoption of remote orphans: a remote shell no saved tab names is listed by "Attach to session…" (Phase 5) but not reopened on its own. - Password memory keyed by (kind, host, user), so a jump-chain profile with passwords can reconnect on its own; this needs the native `PasswordPrompt` to carry the host and user. Done in Phase 5 (`docs/superpowers/specs/2026-10-08-ntilde-mux-phase5.md`): remote hosts in the "Attach to session…" picker and in `ntilde mux ls --all`; a stable `SSH_AUTH_SOCK` link in daemon shells that every reconnect repoints; the native exec channel waits for events instead of a 10 ms sleep; the release's `ntilde-mux` SHA-256s built into the app. --- ## Suggested "Pro" gating (optional, future) Kept deliberately simple early on. Clean monetizable levers if needed later: - Advanced multiplex profiles (shared connection pools) - Team-shared profile packs (file-based export/import, no SaaS) - Connection templates + compliance checks - Enhanced diagnostics bundles --- ## Historical milestones (OpenSSH backend) _Preserved for context. All items below are shipped._ ### M4.0 — Foundations (Profiles + Session Type) **Outcome:** Create a profile and connect via OpenSSH reliably. - `Ntilde.Core.Ssh` project/module - Domain models: `SshProfile`, `PortForward`, `SshJumpHop`, `SshMuxOptions` - `ISshProfileStore` with JSON persistence + schema version - `SshSession` implements `ITerminalSession` (spawns `ssh` in PTY) - Basic "New SSH Connection" dialog - Smoke test: connect to a simple host ### M4.1 — OpenSSH Config Compiler **Outcome:** Profile → compiled config → stable alias launch. - `IOpenSshConfigCompiler` (compiles all profiles into `ssh_config.generated`) - Atomic writes + file locking strategy - Alias convention: `ntilde_` - Launch uses `ssh -F ntilde_` - Diagnostics: show resolved ssh path + "copy launch command" - Unit tests: compiler golden tests ### M4.2 — Termius-like Management UI **Outcome:** Connection manager feels real. - SSH Manager view (list + search) - Favorites, tags, group path - Open in: current pane / new tab / split H/V - Profile editor UI (Basic/Auth/Jump/Forwards) - Validation UI (bad host, missing identity file) ### M4.3 — Port Forward Presets + Jump Host Graph **Outcome:** Power-user workflows are fast. - Port forward editor table (add/clone/enable) - Forward presets ("Postgres", "Redis", "SOCKS proxy") - Jump host reorder + graph preview - Export/import profile (sanitized) ### M4.4 — Multiplexing (ControlMaster) + Reliability Hardening **Outcome:** Multi-pane SSH feels snappy. - `SshMuxOptions` UI + config emission - ControlPath strategy (compact per-profile and connection identity; explicit custom templates retained) - Keepalive defaults + UI - Failure classifier + friendly error surface ### M4.5 — Host Key UX Polish + Telemetry Hooks **Outcome:** Less scary prompts, better supportability. - Detect host key prompt output patterns and show a dialog - Known hosts isolation option (app-managed file) - Diagnostics mode per launch (`-v`/`-vv`) with redaction - Metrics: time to first output, session duration, exit code histogram ### M4.6 — QA & Regression Suite **Outcome:** Release-ready SSH feature set. - Integration tests (`ssh -G` config sanity checks) - Manual test checklist & scripted setup - User-facing SSH profiles guide + troubleshooting