From 8efea021e3e341cfae607e1c188f12660b5ff617 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:35:59 +0800 Subject: [PATCH] feat(ssh): shell integration for Windows hosts whose default shell is PowerShell (#1040) The remote shell probe only understood POSIX answers, so a native SSH pane on a Windows OpenSSH host always fell back to a plain shell with no prompt marks, cwd reporting or title. The probe is now a polyglot every default shell can read: POSIX shells answer as before; PowerShell answers with its edition and $PSHOME, which name the exact executable; cmd.exe echoes the line back verbatim and is recognised, then deliberately left alone. For PowerShell the bootstrap starts a second, interactive PowerShell of the same executable with the existing integration via -NoLogo -NoExit -EncodedCommand (comments stripped, about 6.5K chars total, well inside the Win32 command-line limit), and a redialled pane's start directory (/C:/..., C:/..., UNC) is restored with Set-Location -LiteralPath inside the encoded script. --- .../tty7-core/src/daemon/shell_integration.rs | 562 +++++++++++++++++- crates/tty7-core/src/daemon/ssh/mod.rs | 9 +- 2 files changed, 566 insertions(+), 5 deletions(-) diff --git a/crates/tty7-core/src/daemon/shell_integration.rs b/crates/tty7-core/src/daemon/shell_integration.rs index fe7690b5..6fc1e299 100644 --- a/crates/tty7-core/src/daemon/shell_integration.rs +++ b/crates/tty7-core/src/daemon/shell_integration.rs @@ -1270,19 +1270,62 @@ pub fn setup(program: Option<&str>, args: &[String], has_custom_args: bool) -> O } pub mod remote { - use super::{FISH_INTEGRATION, bash_rcfile, shell_quote, zsh_redirectors}; + use super::{ + FISH_INTEGRATION, POWERSHELL_INTEGRATION, bash_rcfile, powershell_encoded_command, + shell_quote, zsh_redirectors, + }; - pub const PROBE_COMMAND: &str = "echo __tty7_shell; echo $SHELL"; + /// Asks the far host which shell it runs, and is written to be read by + /// every shell sshd might hand it to — the exec request runs through the + /// account's default shell, and on Windows OpenSSH that is `cmd.exe /c`, + /// `powershell.exe -c` or `pwsh -c`, not a POSIX `-c`. So it sticks to + /// words all of them read without complaint, and each answers differently: + /// + /// - a POSIX shell (and fish) prints the marker, `$SHELL`, and the two + /// `__tty7_ps*=` keys with nothing after the `=` (fish drops the word + /// outright and prints an empty line); + /// - PowerShell has no `$SHELL` variable, so that `echo` prints nothing, + /// and fills the keys from its own automatic variables — the edition + /// (`Desktop` is Windows PowerShell 5.1, `Core` is pwsh) and the + /// directory its executable lives in; + /// - cmd.exe has no `;` separator: it echoes the whole line back verbatim, + /// marker and all, on one line. + /// + /// The shape of each word is load-bearing. No quotes: Windows OpenSSH + /// hands the command to the shell through a Win32 command line, and `"`s + /// there are argv syntax, gone before PowerShell reads the text. No + /// parentheses or pipes: a POSIX shell parses the whole line before + /// running any of it, so one construct it rejects would cost the answer + /// it does give. `key=$VAR` expands in every dialect that expands at all + /// (PowerShell's argument mode included), the `=` ending the key. + pub const PROBE_COMMAND: &str = "echo __tty7_shell; echo $SHELL; \ + echo __tty7_psedition=$PSEdition; echo __tty7_pshome=$PSHOME"; const PROBE_MARKER: &str = "__tty7_shell"; + const PS_EDITION_KEY: &str = "__tty7_psedition="; + + const PS_HOME_KEY: &str = "__tty7_pshome="; + const HEREDOC: &str = "__TTY7_RC_EOF__"; + /// The longest bootstrap sent to a PowerShell host. Windows caps a + /// process command line at 32,767 UTF-16 units, and the bootstrap rides + /// inside two of them: sshd's ` -c `, then that shell's + /// launch of the interactive PowerShell, which is a little shorter. The + /// headroom is for sshd's part — the shell's path and switch — with room + /// to spare for any quoting it adds. cmd.exe's far smaller 8,191 never + /// applies: nothing is sent through cmd. + const POWERSHELL_COMMAND_LINE_BUDGET: usize = 30_000; + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum RemoteShell { Zsh, Bash, Fish, + /// Windows PowerShell or pwsh as a Windows account's default shell. + /// The path the probe pairs with it is the executable, `C:\…\pwsh.exe`. + PowerShell, } impl RemoteShell { @@ -1302,13 +1345,63 @@ pub mod remote { .map(|l| l.trim_end_matches('\r').trim()) .skip_while(|l| *l != PROBE_MARKER); lines.next()?; - let path = lines.find(|l| !l.is_empty())?; + let lines: Vec<&str> = lines.collect(); + if let Some(exe) = powershell_from_probe(&lines) { + return Some((RemoteShell::PowerShell, exe)); + } + let path = *lines.iter().find(|l| !l.is_empty())?; if !path.starts_with('/') { return None; } RemoteShell::from_path(path).map(|shell| (shell, path.to_string())) } + /// Whether the probe was answered by cmd.exe — Windows OpenSSH's default + /// shell out of the box. Such a host is left a plain shell, on purpose: + /// cmd has no hook that could carry the integration, and starting + /// PowerShell where the account asked for cmd would change the user's + /// shell rather than instrument it. A host whose `DefaultShell` names + /// PowerShell gets it. Only for the log, so the absence explains itself. + pub fn probe_answered_by_cmd(output: &str) -> bool { + let echoed = format!("{PROBE_MARKER};"); + output.lines().any(|l| l.trim().starts_with(&echoed)) + } + + /// The PowerShell executable a probe describes, if PowerShell answered. + /// + /// The edition names the binary — `powershell.exe` is only ever Windows + /// PowerShell, `pwsh` only ever Core — and `$PSHOME` is the directory it + /// sits in, so the path is exactly the shell that ran the probe: the one + /// the account is configured with, whatever other edition is installed + /// beside it. A Windows host with pwsh installed but Windows PowerShell as + /// its default stays on Windows PowerShell, just as a plain shell request + /// would have left it. + /// + /// Windows hosts only. pwsh as a Unix login shell answers the probe too, + /// but there the binary in `$PSHOME` is not always runnable on its own — + /// a package manager's install launches it through a wrapper that sets up + /// the .NET runtime first, and the wrapper is what `$SHELL` names, where + /// the probe cannot read it — so such a host keeps its plain shell. + fn powershell_from_probe(lines: &[&str]) -> Option { + let field = |key: &str| lines.iter().find_map(|l| l.strip_prefix(key)); + let edition = field(PS_EDITION_KEY)?; + let home = field(PS_HOME_KEY)?.trim_end_matches(['/', '\\']); + if !is_windows_path(home) || home.contains('\0') { + return None; + } + match edition { + "Desktop" => Some(format!(r"{home}\powershell.exe")), + "Core" => Some(format!(r"{home}\pwsh.exe")), + _ => None, + } + } + + /// A drive (`C:\…`, `C:/…`) or UNC (`\\server\…`) path. + fn is_windows_path(path: &str) -> bool { + let b = path.as_bytes(); + (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || path.starts_with(r"\\") + } + /// The script a native SSH session execs in place of a bare shell request. /// /// `start_dir` is a directory *on the remote host* — where the pane was the @@ -1328,6 +1421,9 @@ pub mod remote { shell_path: &str, start_dir: Option<&str>, ) -> String { + if shell == RemoteShell::PowerShell { + return powershell_bootstrap(shell_path, start_dir); + } let mut out = start_dir .and_then(usable_start_dir) .map(|dir| cd_line(shell, dir)) @@ -1336,6 +1432,7 @@ pub mod remote { RemoteShell::Zsh => zsh_bootstrap(shell_path), RemoteShell::Bash => bash_bootstrap(shell_path), RemoteShell::Fish => fish_bootstrap(shell_path), + RemoteShell::PowerShell => unreachable!("returned above"), }); out } @@ -1357,10 +1454,127 @@ pub mod remote { let quoted = match shell { RemoteShell::Zsh | RemoteShell::Bash => shell_quote(dir), RemoteShell::Fish => fish_quote(dir), + RemoteShell::PowerShell => unreachable!("PowerShell moves with Set-Location"), }; format!("builtin cd -- {quoted} 2>/dev/null\n") } + /// The command the host's default PowerShell runs: start a second, + /// interactive PowerShell — the same executable — carrying the + /// integration. + /// + /// A second process because Windows has no `exec`: the shell sshd started + /// was told `-c`, which runs one command and leaves, and nothing can turn + /// it interactive after the fact. It waits on the child and hands back its + /// exit code, so the session ends when the user's shell does. + /// + /// The child is started the way a local PowerShell pane is (`-NoLogo + /// -NoExit -EncodedCommand`), so the user's profile loads first and the + /// integration wraps whatever prompt it settled on. The start directory + /// rides inside the encoded script too — base64 carries any path through + /// the Win32 command line untouched, where a literal would be at the + /// mercy of sshd's quoting — which puts it after the profile: a profile + /// that moves somewhere is overruled for a redialled pane, which is the + /// point of asking for a directory at all. One that has gone away fails + /// quietly into wherever the profile left the session. + fn powershell_bootstrap(exe: &str, start_dir: Option<&str>) -> String { + let build = |dir: Option<&str>| { + let mut script = dir.map(set_location_line).unwrap_or_default(); + script.push_str(&compact_powershell(POWERSHELL_INTEGRATION)); + format!( + "& {} -NoLogo -NoExit -EncodedCommand {}; exit $LASTEXITCODE", + ps_quote(exe), + powershell_encoded_command(&script) + ) + }; + let dir = start_dir.and_then(powershell_start_dir); + let with_dir = build(dir.as_deref()); + // A start directory long enough to push the command line past what + // Windows will run is dropped rather than costing the session: the + // pane opens where the login leaves it, still integrated. + if dir.is_some() && with_dir.chars().count() > POWERSHELL_COMMAND_LINE_BUDGET { + return build(None); + } + with_dir + } + + /// `dir` as a path the far Windows host can only read as one place. + /// + /// The integration reports its cwd the way a `file://` URI spells a drive + /// path — `/C:/Users/ann` — and the local side may have kept it that way + /// (a Unix tty7) or already unwrapped it to `C:/Users/ann` (a Windows + /// one); both mean drive C. A UNC share comes back as `//server/share/…`. + /// Anything else is refused as ambiguous: `/Users/ann` is relative to + /// whichever drive is current, and a bare `C:` means wherever drive C + /// last was. + fn powershell_start_dir(dir: &str) -> Option { + if dir.contains('\0') { + return None; + } + let drive = |b: &[u8]| { + b.len() >= 3 + && b[0].is_ascii_alphabetic() + && b[1] == b':' + && matches!(b[2], b'/' | b'\\') + }; + if drive(dir.as_bytes()) { + return Some(dir.to_string()); + } + if let Some(rest) = dir.strip_prefix('/') + && drive(rest.as_bytes()) + { + return Some(rest.to_string()); + } + let unc = dir.replace('/', r"\"); + let (server, rest) = unc.strip_prefix(r"\\")?.split_once('\\')?; + let share = rest.split('\\').next().unwrap_or_default(); + // `\\?\` and `\\.\` are device namespaces, not a server's shares. + let named = |s: &str| !s.is_empty() && s != "?" && s != "."; + (named(server) && named(share)).then_some(unc) + } + + /// `-LiteralPath`, so brackets and `*` in a directory name are only + /// characters; a directory that is gone is caught rather than reported, + /// and `Stop` makes sure it is catchable — without it, a missing path is a + /// non-terminating error that prints straight past the `try`. + fn set_location_line(dir: &str) -> String { + format!( + "try {{ Set-Location -LiteralPath {} -ErrorAction Stop }} catch {{ }}\n", + ps_quote(dir) + ) + } + + /// PowerShell single-quoting. PowerShell takes the typographic single + /// quotes for quote marks as well, so they are doubled along with `'`. + fn ps_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for c in s.chars() { + if matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}') { + out.push(c); + } + out.push(c); + } + out.push('\''); + out + } + + /// The integration with its comments, blank lines and indentation + /// dropped. It travels as base64 of UTF-16 — each character costs 8/3 of + /// one on the command line — and the commentary is half of it. The script + /// has no here-strings and no string that breaks onto a line starting + /// with `#`, so such a line is always a comment. + fn compact_powershell(script: &str) -> String { + let mut out = String::with_capacity(script.len()); + for line in script.lines().map(str::trim) { + if !line.is_empty() && !line.starts_with('#') { + out.push_str(line); + out.push('\n'); + } + } + out + } + fn fish_quote(s: &str) -> String { format!("'{}'", s.replace('\\', r"\\").replace('\'', r"\'")) } @@ -1736,6 +1950,346 @@ fi assert!(ok, "bash rejected the remote rcfile:\n{stderr}"); } } + + // --- Windows OpenSSH hosts --- + + const WIN_PS51: &str = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; + const WIN_PWSH: &str = r"C:\Program Files\PowerShell\7\pwsh.exe"; + + #[test] + fn probe_names_the_powershell_that_answered_it() { + // What each edition prints for PROBE_COMMAND: no line at all for + // `echo $SHELL`, then the two keys. Windows output is CRLF. + assert_eq!( + parse_probe( + "__tty7_shell\r\n__tty7_psedition=Desktop\r\n\ + __tty7_pshome=C:\\Windows\\System32\\WindowsPowerShell\\v1.0\r\n" + ), + Some((RemoteShell::PowerShell, WIN_PS51.to_string())) + ); + assert_eq!( + parse_probe( + "__tty7_shell\r\n__tty7_psedition=Core\r\n\ + __tty7_pshome=C:\\Program Files\\PowerShell\\7\\\r\n" + ), + Some((RemoteShell::PowerShell, WIN_PWSH.to_string())) + ); + // A profile that talks on the way in. + assert_eq!( + parse_probe( + "Loading personal and system profiles took 412ms.\r\n__tty7_shell\r\n\ + __tty7_psedition=Core\r\n__tty7_pshome=D:\\tools\\pwsh\r\n" + ), + Some(( + RemoteShell::PowerShell, + r"D:\tools\pwsh\pwsh.exe".to_string() + )) + ); + } + + #[test] + fn probe_declines_powershell_answers_it_cannot_place() { + for out in [ + // pwsh as a Unix login shell keeps its plain shell. + "__tty7_shell\n__tty7_psedition=Core\n__tty7_pshome=/opt/microsoft/powershell/7\n", + "__tty7_shell\n__tty7_psedition=Desktop\n__tty7_pshome=/opt/ps\n", + "__tty7_shell\n__tty7_psedition=Core\n__tty7_pshome=\n", + "__tty7_shell\n__tty7_psedition=Classic\n__tty7_pshome=C:\\ps\n", + "__tty7_shell\n__tty7_psedition=Core\n__tty7_pshome=relative\\ps\n", + // Keys before the marker are someone's banner, not an answer. + "__tty7_psedition=Core\n__tty7_pshome=C:\\ps\n__tty7_shell\n", + ] { + assert_eq!(parse_probe(out), None, "{out:?}"); + } + } + + #[test] + fn cmd_answers_the_probe_with_its_own_echo_and_is_left_alone() { + // cmd has no `;`: the first `echo` prints the rest of the line. + let echoed = PROBE_COMMAND.strip_prefix("echo ").unwrap(); + let out = format!("{echoed}\r\n"); + assert_eq!(parse_probe(&out), None); + assert!(probe_answered_by_cmd(&out)); + // sshd may hand cmd the command in quotes; cmd echoes the tail one. + assert!(probe_answered_by_cmd(&format!("{echoed}\"\r\n"))); + + assert!(!probe_answered_by_cmd("__tty7_shell\n/bin/zsh\n")); + assert!(!probe_answered_by_cmd( + "__tty7_shell\n__tty7_psedition=Core\n__tty7_pshome=C:\\ps\n" + )); + } + + #[test] + fn posix_answers_are_read_as_before_with_the_empty_keys_trailing() { + assert_eq!( + parse_probe("__tty7_shell\n/bin/zsh\n__tty7_psedition=\n__tty7_pshome=\n"), + Some((RemoteShell::Zsh, "/bin/zsh".to_string())) + ); + // fish drops a word whose variable is unset: two empty lines. + assert_eq!( + parse_probe("__tty7_shell\n/usr/bin/fish\n\n\n"), + Some((RemoteShell::Fish, "/usr/bin/fish".to_string())) + ); + // An unset $SHELL must not promote a key line to the shell path. + assert_eq!( + parse_probe("__tty7_shell\n\n__tty7_psedition=\n__tty7_pshome=\n"), + None + ); + } + + /// Runs PROBE_COMMAND through every shell installed here the way sshd + /// would (` -c `), and checks each is recognised as + /// itself. A Unix pwsh is declined like any Unix PowerShell, but what + /// it prints shows the probe's words expand in PowerShell's argument + /// mode — the same parser Windows PowerShell and a Windows pwsh use. + #[cfg(unix)] + #[test] + fn real_shells_answer_the_probe_as_themselves() { + use std::process::Command; + let run = |bin: &str, args: &[&str]| { + Command::new(bin) + .args(args) + .arg("-c") + .arg(PROBE_COMMAND) + .env("SHELL", format!("/usr/bin/{bin}")) + .output() + .ok() + }; + for (bin, shell) in [ + ("sh", None), + ("dash", None), + ("bash", Some(RemoteShell::Bash)), + ("zsh", Some(RemoteShell::Zsh)), + ("fish", Some(RemoteShell::Fish)), + ] { + let Some(out) = run(bin, &[]) else { continue }; + let parsed = parse_probe(&String::from_utf8_lossy(&out.stdout)); + assert_eq!(parsed.map(|(s, _)| s), shell, "{bin}: {out:?}"); + } + if let Some(out) = run("pwsh", &["-NoProfile", "-NonInteractive"]) { + let text = String::from_utf8_lossy(&out.stdout); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.first(), Some(&PROBE_MARKER), "{text:?}"); + assert_eq!(lines.get(1), Some(&"__tty7_psedition=Core"), "{text:?}"); + let home = lines + .get(2) + .and_then(|l| l.strip_prefix(PS_HOME_KEY)) + .unwrap_or_default(); + assert!( + std::path::Path::new(home).join("pwsh").is_file(), + "{home:?} is not pwsh's home ({text:?})" + ); + assert_eq!(parse_probe(&text), None); + assert!(!probe_answered_by_cmd(&text)); + } + } + + /// The script inside `-EncodedCommand`, and everything around it. + fn split_powershell_bootstrap(script: &str) -> (&str, String, &str) { + let (head, rest) = script + .split_once("-EncodedCommand ") + .expect("carries an encoded command"); + let (b64, tail) = rest.split_once(';').expect("ends with the exit"); + (head, super::super::tests::decode_utf16le_base64(b64), tail) + } + + #[test] + fn powershell_bootstrap_starts_the_same_powershell_with_the_integration() { + let script = bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, None); + assert!( + !script.contains('\n'), + "one line through the Win32 command line" + ); + assert!(!script.contains('"'), "no argv quoting for sshd to eat"); + let (head, body, tail) = split_powershell_bootstrap(&script); + assert_eq!( + head, + r"& 'C:\Program Files\PowerShell\7\pwsh.exe' -NoLogo -NoExit " + ); + assert_eq!(tail, " exit $LASTEXITCODE"); + assert_eq!(body, compact_powershell(POWERSHELL_INTEGRATION)); + for needle in ["]133;A", "]133;B", "]133;C", "]133;D", "]7;file://", "]0;"] { + assert!(body.contains(needle), "compacted away {needle}"); + } + assert!(!body.lines().any(|l| l.starts_with('#'))); + + let ps51 = bootstrap_command(RemoteShell::PowerShell, WIN_PS51, None); + assert!(ps51.starts_with(&format!("& '{WIN_PS51}' -NoLogo -NoExit "))); + } + + #[test] + fn windows_start_dirs_are_taken_only_when_they_name_one_place() { + let cases = [ + ("/C:/Users/ann/my dir", Some("C:/Users/ann/my dir")), + ("C:/Users/ann", Some("C:/Users/ann")), + (r"d:\work", Some(r"d:\work")), + ("/C:/", Some("C:/")), + ("//fs01/share/team", Some(r"\\fs01\share\team")), + (r"\\fs01\share", Some(r"\\fs01\share")), + // Relative to whichever drive is current. + ("/Users/ann", None), + // Wherever drive C last was. + ("C:", None), + ("/C:", None), + ("C:Users", None), + ("~/x", None), + ("x", None), + ("", None), + ("//fs01", None), + ("//fs01/", None), + (r"\\?\C:\x", None), + (r"\\.\pipe\x", None), + ("C:/a\0b", None), + ]; + for (dir, want) in cases { + assert_eq!(powershell_start_dir(dir).as_deref(), want, "{dir:?}"); + } + } + + #[test] + fn a_start_dir_opens_the_encoded_script_with_one_quiet_set_location() { + let dir = "/C:/Users/ann/it's ‘quoted’ $HOME"; + let script = bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, Some(dir)); + let (_, body, _) = split_powershell_bootstrap(&script); + let (first, rest) = body.split_once('\n').unwrap(); + assert_eq!( + first, + "try { Set-Location -LiteralPath 'C:/Users/ann/it''s ‘‘quoted’’ $HOME' \ + -ErrorAction Stop } catch { }" + ); + assert_eq!(rest, compact_powershell(POWERSHELL_INTEGRATION)); + + for refused in ["/Users/ann", "C:", "relative"] { + assert_eq!( + bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, Some(refused)), + bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, None), + "{refused:?} must not become a Set-Location" + ); + } + } + + #[test] + fn powershell_bootstrap_fits_a_windows_command_line() { + // sshd's own part: the shell's path and its `-c`. + let sshd = WIN_PWSH.len() + " -c ".len() + 2; + let longest_classic = format!("C:/{}", "d".repeat(257)); + for dir in [None, Some(longest_classic.as_str())] { + let script = bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, dir); + let len = script.chars().count(); + assert!( + len <= POWERSHELL_COMMAND_LINE_BUDGET && len + sshd < 32_767, + "{len} chars with start dir {dir:?}" + ); + } + // A long-path-aware host can report a far longer cwd. It costs the + // start dir, not the session. + let huge = format!("C:/{}", "d/".repeat(8_000)); + assert_eq!( + bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, Some(&huge)), + bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, None), + ); + } + + #[test] + fn ps_quote_doubles_every_single_quote_powershell_honours() { + assert_eq!(ps_quote("a'b"), "'a''b'"); + assert_eq!(ps_quote("a\u{2019}b"), "'a\u{2019}\u{2019}b'"); + assert_eq!(ps_quote(r"C:\x $y"), r"'C:\x $y'"); + } + + /// A throwaway home for a pwsh run, so no real profile is read. + #[cfg(unix)] + fn pwsh(home: &std::path::Path) -> std::process::Command { + let mut cmd = std::process::Command::new("pwsh"); + cmd.current_dir(home) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("XDG_DATA_HOME", home.join(".local/share")) + .env("XDG_CACHE_HOME", home.join(".cache")) + .env_remove("TTY7_SHELL_INTEGRATION"); + cmd + } + + /// Runs the bootstrap in a real pwsh the way a PowerShell default + /// shell runs an exec request (` -c `), with the + /// Windows executable swapped for the local one and commands on stdin + /// in place of a user, and checks the child decodes and runs the + /// integration and hands its exit code back out through the parent. + #[cfg(unix)] + #[test] + fn a_real_pwsh_runs_the_bootstrap_end_to_end() { + use std::io::Write as _; + use std::process::Stdio; + + let home = tempfile::tempdir().expect("tempdir"); + let script = bootstrap_command(RemoteShell::PowerShell, WIN_PWSH, None).replacen( + &ps_quote(WIN_PWSH), + "'pwsh'", + 1, + ); + let Ok(mut child) = pwsh(home.path()) + .args(["-NoProfile", "-c", &script]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + else { + return; + }; + child + .stdin + .take() + .unwrap() + .write_all( + b"Write-Output \"integrated=$env:TTY7_SHELL_INTEGRATION\"\n\ + Write-Output \"prompt=$((Get-Command prompt).ScriptBlock -match '133;A')\"\n\ + exit 7\n", + ) + .unwrap(); + let out = child.wait_with_output().expect("pwsh ran"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stdout.contains("integrated=1"), "{stdout}\n{stderr}"); + assert!(stdout.contains("prompt=True"), "{stdout}\n{stderr}"); + assert_eq!(out.status.code(), Some(7), "{stdout}\n{stderr}"); + } + + /// The start directory line in a real pwsh: it lands in a directory + /// that is there, and says nothing and stays put for one that is gone. + #[cfg(unix)] + #[test] + fn a_real_pwsh_lands_in_the_start_dir_and_stays_quiet_without_it() { + let root = tempfile::tempdir().expect("tempdir"); + let root = root.path().canonicalize().expect("canonical tempdir"); + let home = root.join("home"); + let target = root.join("it's [a] ‘dir’"); + std::fs::create_dir_all(&home).expect("home"); + std::fs::create_dir_all(&target).expect("target"); + let missing = root.join("gone since"); + + for (dir, want) in [(&target, &target), (&missing, &home)] { + let script = format!( + "{}(Get-Location).Path", + set_location_line(dir.to_str().unwrap()) + ); + let Ok(out) = pwsh(&home) + .args(["-NoProfile", "-NonInteractive", "-c", &script]) + .output() + else { + return; + }; + assert!(out.status.success(), "{out:?}"); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim_end(), + want.to_str().unwrap() + ); + assert!( + out.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + } + } } } @@ -2856,7 +3410,7 @@ mod tests { ); } - fn decode_utf16le_base64(b64: &str) -> String { + pub(super) fn decode_utf16le_base64(b64: &str) -> String { fn val(c: u8) -> Option { match c { b'A'..=b'Z' => Some((c - b'A') as u32), diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs index 2a027d8e..5e939f10 100644 --- a/crates/tty7-core/src/daemon/ssh/mod.rs +++ b/crates/tty7-core/src/daemon/ssh/mod.rs @@ -800,7 +800,14 @@ async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell }; let _ = tokio::time::timeout(PROBE_TIMEOUT, collect).await; - remote::parse_probe(&String::from_utf8_lossy(&out)) + let out = String::from_utf8_lossy(&out); + if remote::probe_answered_by_cmd(&out) { + log::debug!( + "ssh {:?}: default shell is cmd.exe, left as it is", + conn.key() + ); + } + remote::parse_probe(&out) } async fn probe_remote_env(conn: &SshConnection) -> Option {