Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion app/src/integration_testing/subshell/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}'"))
Expand Down
37 changes: 37 additions & 0 deletions app/src/integration_testing/subshell/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,40 @@ 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",
"-p 25784",
&format!("-o ProxyCommand=\"{PROXY_COMMAND}\""),
"-o StrictHostKeyChecking=no",
"-o UserKnownHostsFile=/dev/null",
Comment thread
kevinchevalier marked this conversation as resolved.
&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'"));
}
}
86 changes: 81 additions & 5 deletions app/src/terminal/ssh/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,28 @@ impl InteractiveSshCommand {
let tokens = parse_ssh_command_tokens(command)?;
let mut host: Option<String> = None;
let mut port: Option<String> = 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,

// `-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() {
Expand All @@ -127,20 +142,59 @@ 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_started = true;
remote_command_tokens.push(pos_arg.to_string());
Comment thread
cesaryuan marked this conversation as resolved.
}
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 {
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 {
Gcloud,
ElasticBeanstalk,
Expand Down Expand Up @@ -395,9 +449,31 @@ 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 -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!(
Expand Down
1 change: 1 addition & 0 deletions crates/integration/src/bin/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
36 changes: 33 additions & 3 deletions crates/integration/src/test/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -299,6 +299,36 @@ 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 '<shell --login>'` 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(|| {
if !FeatureFlag::SSHTmuxWrapper.is_enabled() {
return false;
}
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())
Comment thread
cesaryuan marked this conversation as resolved.
.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down