From 2c91c8a2fb2f758e31990bac9aeaf923ad0fc518 Mon Sep 17 00:00:00 2001 From: cesaryuan Date: Tue, 5 May 2026 20:08:12 +0800 Subject: [PATCH 1/3] Handle interactive ssh shell overrides in warpify detection Add ssh parsing support for interactive -t remote shell overrides so Warp can detect and offer warpification for commands like ssh host -t 'fish --login'. Also add integration-test scaffolding and regression coverage for the override flow. Co-Authored-By: Warp --- app/src/integration_testing/subshell/step.rs | 17 ++++- app/src/integration_testing/subshell/util.rs | 23 +++++++ app/src/terminal/ssh/util.rs | 65 +++++++++++++++++-- crates/integration/src/bin/integration.rs | 1 + crates/integration/src/test/ssh.rs | 33 +++++++++- .../integration/shell_integration_tests.rs | 1 + 6 files changed, 131 insertions(+), 9 deletions(-) diff --git a/app/src/integration_testing/subshell/step.rs b/app/src/integration_testing/subshell/step.rs index d2d01e744..a7944ef39 100644 --- a/app/src/integration_testing/subshell/step.rs +++ b/app/src/integration_testing/subshell/step.rs @@ -18,7 +18,7 @@ use crate::{ terminal::{model::rich_content::RichContentType, view::WithinBlockBanner}, }; -use super::util::{ssh_command, user_host}; +use super::util::{ssh_command, ssh_command_with_remote_shell_override, user_host}; /// Sets environment variables needed by the Google Cloud SDK. pub fn setup_gcloud_sdk() -> TestStep { @@ -41,6 +41,21 @@ pub fn enter_ssh_command(shell: &str) -> TestStep { .set_post_step_pause(Duration::from_millis(250)) } +/// Initiates an SSH connection that starts a specific remote shell via `-t`. +pub fn enter_ssh_command_with_remote_shell_override( + login_user_shell: &str, + remote_shell_command: &str, +) -> TestStep { + let ssh_command = + ssh_command_with_remote_shell_override(login_user_shell, remote_shell_command, true); + TestStep::new(&format!( + "Start ssh connection for '{login_user_shell}' with remote shell command '{remote_shell_command}'" + )) + .with_typed_characters(&[&ssh_command]) + .with_keystrokes(&["enter"]) + .set_post_step_pause(Duration::from_millis(250)) +} + pub fn enter_remote_subshell_command(shell: &str) -> TestStep { let ssh_command = ssh_command(shell, false); TestStep::new(&format!("Start ssh connection with remote shell '{shell}'")) diff --git a/app/src/integration_testing/subshell/util.rs b/app/src/integration_testing/subshell/util.rs index 4f9393e01..7211c0b7c 100644 --- a/app/src/integration_testing/subshell/util.rs +++ b/app/src/integration_testing/subshell/util.rs @@ -22,3 +22,26 @@ pub fn ssh_command(shell: &str, should_use_ssh_wrapper: bool) -> String { ] .join(" ") } + +/// Produces the full ssh command to run with a remote shell override via `-t`. +pub fn ssh_command_with_remote_shell_override( + login_user_shell: &str, + remote_shell_command: &str, + should_use_ssh_wrapper: bool, +) -> String { + [ + if should_use_ssh_wrapper { + "ssh" + } else { + "command ssh" + }, + &user_host(login_user_shell), + "-t", + &format!("'{remote_shell_command}'"), + "-p 25784", + &format!("-o ProxyCommand=\"{PROXY_COMMAND}\""), + "-o StrictHostKeyChecking=no", + "-o UserKnownHostsFile=/dev/null", + ] + .join(" ") +} diff --git a/app/src/terminal/ssh/util.rs b/app/src/terminal/ssh/util.rs index 5fbc1179f..e14dbae00 100644 --- a/app/src/terminal/ssh/util.rs +++ b/app/src/terminal/ssh/util.rs @@ -100,6 +100,8 @@ impl InteractiveSshCommand { let tokens = parse_ssh_command_tokens(command)?; let mut host: Option = None; let mut port: Option = None; + let mut forces_tty = false; + let mut remote_command_tokens = Vec::new(); let mut i = 1; while i < tokens.len() { @@ -107,6 +109,12 @@ impl InteractiveSshCommand { // -T or -W imply a non-interactive session. "-T" | "-W" => return None, + // `-t` requests a TTY, which is required when a remote command launches + // an interactive shell that Warp can later bootstrap. + arg if is_forced_tty_option(arg) => { + forces_tty = true; + } + "-p" => { i += 1; if i < tokens.len() { @@ -127,20 +135,52 @@ impl InteractiveSshCommand { // Otherwise, it's a positional argument (e.g., hostname, command to run) pos_arg => { - // If we detect multiple positional args, there's some type of unknown command formulation. - if host.is_some() { - return None; + if host.is_none() { + host = Some(pos_arg.to_string()); + } else { + remote_command_tokens.push(pos_arg.to_string()); } - host = Some(pos_arg.to_string()); } } i += 1; } + if !remote_command_tokens.is_empty() + && (!forces_tty || !is_supported_interactive_ssh_remote_command(&remote_command_tokens)) + { + return None; + } + Some(InteractiveSshCommand { host, port }) } } +/// Returns `true` when an SSH option requests a TTY for the remote session. +fn is_forced_tty_option(arg: &str) -> bool { + matches!(arg, "-t" | "-tt" | "-ttt") +} + +/// Returns `true` when the remote command launches a supported interactive shell. +fn is_supported_interactive_ssh_remote_command(remote_command_tokens: &[String]) -> bool { + let remote_command = remote_command_tokens.join(" "); + let Ok(tokens) = shell_words::split(&remote_command) else { + return false; + }; + + match tokens.as_slice() { + [shell] => is_supported_remote_shell(shell), + [shell, login_flag] if is_supported_remote_shell(shell) => { + matches!(login_flag.as_str(), "-l" | "--login") + } + _ => false, + } +} + +/// Returns `true` when Warp knows how to treat the shell as an interactive remote target. +fn is_supported_remote_shell(shell: &str) -> bool { + matches!(shell, "bash" | "zsh" | "fish" | "sh" | "ash") +} + pub enum SshLikeCommand { Gcloud, ElasticBeanstalk, @@ -395,9 +435,24 @@ mod tests { parse_interactive_ssh_command("ssh -o IdentityFile=/etc/file -T user@host").is_none() ); - // Commands with multiple positional arguments, implying non-interactive + // Commands with multiple positional arguments are only interactive when + // they explicitly request a TTY and launch a supported shell. assert!(parse_interactive_ssh_command("ssh user@host ls").is_none()); assert!(parse_interactive_ssh_command("ssh user@host echo 'Hello, World!'").is_none()); + assert!( + parse_interactive_ssh_command("ssh user@host -t 'fish --login'") + .unwrap() + .host + == Some("user@host".to_string()) + ); + assert!( + parse_interactive_ssh_command("ssh user@host -tt zsh --login") + .unwrap() + .host + == Some("user@host".to_string()) + ); + assert!(parse_interactive_ssh_command("ssh user@host fish --login").is_none()); + assert!(parse_interactive_ssh_command("ssh user@host -t 'echo hello'").is_none()); // Weird spacing and shell characters shouldn't matter assert!( diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index a51b0f1c9..f0e9a68b2 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -262,6 +262,7 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { register_test!(test_ssh_into_fish); register_test!(test_ssh_into_sh); register_test!(test_ssh_into_ash); + register_test!(test_ssh_with_remote_shell_command_override); register_test!(test_ssh_with_shell_override); register_test!(test_custom_open_completions_menu_binding); register_test!(test_color_overrides_in_prompt_dont_crash); diff --git a/crates/integration/src/test/ssh.rs b/crates/integration/src/test/ssh.rs index 62d2245f2..2212c1597 100644 --- a/crates/integration/src/test/ssh.rs +++ b/crates/integration/src/test/ssh.rs @@ -9,9 +9,9 @@ use warp::{ step::new_step_with_default_assertions, subshell::{ accept_tmux_install, assert_subshell_banner_is_showing, - assert_subshell_is_bootstrapped, enter_ssh_command, enter_ssh_password, - run_exit_command, setup_gcloud_sdk, trigger_subshell_bootstrap, - wait_for_password_prompt, + assert_subshell_is_bootstrapped, enter_ssh_command, + enter_ssh_command_with_remote_shell_override, enter_ssh_password, run_exit_command, + setup_gcloud_sdk, trigger_subshell_bootstrap, wait_for_password_prompt, }, terminal::{ assert_active_block_output_for_single_terminal_in_tab, @@ -299,6 +299,33 @@ generate_long_running_block_ssh_test_for_shell!(test_ssh_into_fish, "fish", prom generate_long_running_block_ssh_test_for_shell!(test_ssh_into_sh, "sh", prompt_regex: r"\n\$ $"); generate_long_running_block_ssh_test_for_shell!(test_ssh_into_ash, "ash", prompt_regex: r"\n\$ $"); +/// Tests that `ssh ... -t ''` sessions are detected as interactive SSH. +pub fn test_ssh_with_remote_shell_command_override() -> Builder { + new_builder() + // TODO(CORE-2333) PowerShell has no SSH wrapper. + .set_should_run_test(|| { + let (starter, _) = current_shell_starter_and_version(); + starter.shell_type() != ShellType::PowerShell + }) + .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) + .with_step(setup_gcloud_sdk()) + .with_step(enter_ssh_command_with_remote_shell_override( + "bash", + "zsh --login", + )) + .with_step(wait_for_password_prompt(0, "bash")) + .with_step(enter_ssh_password()) + .with_step(assert_subshell_banner_is_showing()) + .with_step(trigger_subshell_bootstrap()) + .with_step(assert_subshell_is_bootstrapped(0, 0)) + .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) + .with_step( + new_step_with_default_assertions("Assert active block is part of a remote session") + .add_assertion(assert_active_block_is_remote("bash", "ubuntu-14-04")), + ) + .with_step(verify_login_shell("zsh")) +} + /// Tests a regression with the startup shell setting and SSH proxies. /// See WAR-6337 for details - if `$SHELL` is not set to a valid executable file /// path, SSH fails to execute proxy commands (like the one this test uses for diff --git a/crates/integration/tests/integration/shell_integration_tests.rs b/crates/integration/tests/integration/shell_integration_tests.rs index 516fed576..b0e1da70d 100644 --- a/crates/integration/tests/integration/shell_integration_tests.rs +++ b/crates/integration/tests/integration/shell_integration_tests.rs @@ -86,6 +86,7 @@ integration_tests! { // test_ssh_into_fish, test_ssh_into_sh, test_ssh_into_ash, + test_ssh_with_remote_shell_command_override, // Tests of custom prompt behavior. test_copy_prompt_from_block_honor_ps1_enabled, From 6e794708822eb3b73f31af1e538259a31e57ae09 Mon Sep 17 00:00:00 2001 From: cesaryuan Date: Wed, 6 May 2026 20:38:57 +0800 Subject: [PATCH 2/3] Address SSH parser review feedback Preserve all tokens after a remote command begins so dash-prefixed shell arguments are validated correctly, support path-based shell overrides, and gate the new integration test behind the tmux SSH feature flag. Co-Authored-By: Warp --- app/src/terminal/ssh/util.rs | 23 ++++++++++++++++++++++- crates/integration/src/test/ssh.rs | 3 +++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/src/terminal/ssh/util.rs b/app/src/terminal/ssh/util.rs index e14dbae00..364b6d9d1 100644 --- a/app/src/terminal/ssh/util.rs +++ b/app/src/terminal/ssh/util.rs @@ -101,10 +101,17 @@ impl InteractiveSshCommand { let mut host: Option = None; let mut port: Option = None; let mut forces_tty = false; + let mut remote_command_started = false; let mut remote_command_tokens = Vec::new(); let mut i = 1; while i < tokens.len() { + if remote_command_started { + remote_command_tokens.push(tokens[i].clone()); + i += 1; + continue; + } + match tokens[i].as_str() { // -T or -W imply a non-interactive session. "-T" | "-W" => return None, @@ -138,6 +145,7 @@ impl InteractiveSshCommand { if host.is_none() { host = Some(pos_arg.to_string()); } else { + remote_command_started = true; remote_command_tokens.push(pos_arg.to_string()); } } @@ -178,7 +186,13 @@ fn is_supported_interactive_ssh_remote_command(remote_command_tokens: &[String]) /// Returns `true` when Warp knows how to treat the shell as an interactive remote target. fn is_supported_remote_shell(shell: &str) -> bool { - matches!(shell, "bash" | "zsh" | "fish" | "sh" | "ash") + let normalized_shell = Path::new(shell) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(shell) + .trim_start_matches('-'); + + matches!(normalized_shell, "bash" | "zsh" | "fish" | "sh" | "ash") } pub enum SshLikeCommand { @@ -451,8 +465,15 @@ mod tests { .host == Some("user@host".to_string()) ); + assert!( + parse_interactive_ssh_command("ssh user@host -t '/usr/local/bin/fish --login'") + .unwrap() + .host + == Some("user@host".to_string()) + ); assert!(parse_interactive_ssh_command("ssh user@host fish --login").is_none()); assert!(parse_interactive_ssh_command("ssh user@host -t 'echo hello'").is_none()); + assert!(parse_interactive_ssh_command("ssh user@host -t zsh -c 'echo hi'").is_none()); // Weird spacing and shell characters shouldn't matter assert!( diff --git a/crates/integration/src/test/ssh.rs b/crates/integration/src/test/ssh.rs index 2212c1597..0333b2df9 100644 --- a/crates/integration/src/test/ssh.rs +++ b/crates/integration/src/test/ssh.rs @@ -304,6 +304,9 @@ pub fn test_ssh_with_remote_shell_command_override() -> Builder { new_builder() // TODO(CORE-2333) PowerShell has no SSH wrapper. .set_should_run_test(|| { + if !FeatureFlag::SSHTmuxWrapper.is_enabled() { + return false; + } let (starter, _) = current_shell_starter_and_version(); starter.shell_type() != ShellType::PowerShell }) From 577ea7f066096e62e81981eb473658df1311dab9 Mon Sep 17 00:00:00 2001 From: cesaryuan Date: Wed, 6 May 2026 20:52:23 +0800 Subject: [PATCH 3/3] Fix SSH test helper option ordering Place the remote shell override after all SSH options so the integration helper generates a valid SSH command shape for OpenSSH and Warp's parser. Co-Authored-By: Warp --- app/src/integration_testing/subshell/util.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/integration_testing/subshell/util.rs b/app/src/integration_testing/subshell/util.rs index 7211c0b7c..1ea724b36 100644 --- a/app/src/integration_testing/subshell/util.rs +++ b/app/src/integration_testing/subshell/util.rs @@ -37,11 +37,25 @@ pub fn ssh_command_with_remote_shell_override( }, &user_host(login_user_shell), "-t", - &format!("'{remote_shell_command}'"), "-p 25784", &format!("-o ProxyCommand=\"{PROXY_COMMAND}\""), "-o StrictHostKeyChecking=no", "-o UserKnownHostsFile=/dev/null", + &format!("'{remote_shell_command}'"), ] .join(" ") } + +#[cfg(test)] +mod tests { + use super::*; + + /// Verifies that the remote shell override is emitted after all SSH options. + #[test] + fn ssh_remote_shell_override_command_orders_options_before_remote_command() { + let command = ssh_command_with_remote_shell_override("bash", "zsh --login", true); + + assert!(command.contains("bash@ubuntu-14-04 -t -p 25784")); + assert!(command.ends_with("'zsh --login'")); + } +}