From ce4cd2fd77858050e072d39645765045997169fc Mon Sep 17 00:00:00 2001 From: AMRIK Date: Wed, 29 Apr 2026 04:12:53 +0530 Subject: [PATCH 1/2] Run executable shell scripts in the terminal instead of opening in the editor `is_file_openable_in_warp` returns `Some(Text)` for any non-binary file, which shadowed the existing executor branch when opening a `.sh` from a `file://` URL. Now the URI handler classifies the action up front and falls through to the executor path when the target is an executable shell script (x-bit + .sh/bash/zsh/fish/ksh extension or `#!` shebang on Unix; .ps1/.bat/.cmd on Windows), matching the rest of the agent flow. Fixes #9005 --- app/src/uri/mod.rs | 36 ++++++- app/src/uri/uri_test.rs | 72 ++++++++++++++ app/src/util/openable_file_type.rs | 150 +++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 4 deletions(-) diff --git a/app/src/uri/mod.rs b/app/src/uri/mod.rs index f2d6596bb0..adfcc39afe 100644 --- a/app/src/uri/mod.rs +++ b/app/src/uri/mod.rs @@ -13,7 +13,9 @@ use crate::linear::{LinearAction, LinearIssueWork}; use crate::root_view::{open_new_window_get_handles, OpenLaunchConfigArg}; use crate::server::ids::ServerId; use crate::server::telemetry::{LaunchConfigUiLocation, TelemetryEvent}; -use crate::util::openable_file_type::{is_file_openable_in_warp, is_markdown_file}; +use crate::util::openable_file_type::{ + is_file_openable_in_warp, is_markdown_file, is_runnable_shell_script, +}; use crate::workspace::{Workspace, WorkspaceAction, WorkspaceRegistry}; use crate::{cloud_object::ObjectType, workspace::ToastStack}; use crate::{drive::OpenWarpDriveObjectArgs, view_components::DismissibleToast}; @@ -30,7 +32,7 @@ use anyhow::{anyhow, ensure, Result}; use itertools::Itertools; use session_sharing_protocol::common::SessionId; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; use url::Url; use warpui::notification::UserNotification; @@ -1004,6 +1006,31 @@ fn get_primary_window( non_quake_mode_windows.next() } +/// What `open_file` should do with an incoming `file://` URL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpenFileAction { + /// Open in the markdown notebook pane. + Notebook, + /// Open in Warp's code/text editor pane. + Editor, + /// Open a session at the parent directory and queue the file as the pending command, + /// or just open a session at the directory path if `path` is a directory. + ExecuteInSession, +} + +/// Pure routing decision for `open_file`. Extracted so it can be unit-tested without +/// standing up a full `AppContext`. +fn classify_open_file_action(path: &Path) -> OpenFileAction { + if is_markdown_file(path) { + return OpenFileAction::Notebook; + } + if path.is_file() && is_file_openable_in_warp(path).is_some() && !is_runnable_shell_script(path) + { + return OpenFileAction::Editor; + } + OpenFileAction::ExecuteInSession +} + /// Handle an incoming `file://` URL. /// * Markdown files are opened as notebook panes. /// * For directories, open a new session at the directory path. @@ -1015,7 +1042,8 @@ fn open_file(window_id: Option, path: PathBuf, ctx: &mut AppContext) { .map(|view_id| (window_id, view_id)) }); - if is_markdown_file(&path) { + let action = classify_open_file_action(&path); + if action == OpenFileAction::Notebook { if let Some((primary_window_id, root_view_id)) = primary_window_and_view { ctx.dispatch_action( primary_window_id, @@ -1027,7 +1055,7 @@ fn open_file(window_id: Option, path: PathBuf, ctx: &mut AppContext) { } else { ctx.dispatch_global_action("root_view:open_new_with_file_notebook", &path); } - } else if path.is_file() && is_file_openable_in_warp(&path).is_some() { + } else if action == OpenFileAction::Editor { #[cfg(feature = "local_fs")] { use crate::code::editor_management::CodeSource; diff --git a/app/src/uri/uri_test.rs b/app/src/uri/uri_test.rs index a4d0df7805..367e6bab0d 100644 --- a/app/src/uri/uri_test.rs +++ b/app/src/uri/uri_test.rs @@ -573,3 +573,75 @@ fn test_parse_tab_path_bare_tilde() { let home = dirs::home_dir().expect("HOME must be set for this test"); assert_eq!(parse_tab_path(&url), Some(home)); } + +// Regression coverage for issue #9005: shell scripts opened via `file://` should run, +// not open in the editor. Exercised through the pure routing helper to avoid standing +// up a full `AppContext`. + +#[test] +#[cfg(unix)] +fn test_open_file_executable_sh_routes_to_execute() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("run.sh"); + std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + classify_open_file_action(&p), + OpenFileAction::ExecuteInSession + ); +} + +#[test] +#[cfg(unix)] +fn test_open_file_non_executable_sh_routes_to_editor() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("view.sh"); + std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor); +} + +#[test] +#[cfg(unix)] +fn test_open_file_executable_bash_zsh_fish_route_to_execute() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + for name in ["run.bash", "run.zsh", "run.fish"] { + let p = dir.path().join(name); + std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + classify_open_file_action(&p), + OpenFileAction::ExecuteInSession, + "{name} should route to ExecuteInSession", + ); + } +} + +#[test] +fn test_open_file_markdown_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("README.md"); + std::fs::write(&p, b"# hi\n").unwrap(); + assert_eq!(classify_open_file_action(&p), OpenFileAction::Notebook); +} + +#[test] +#[cfg(feature = "local_fs")] +fn test_open_file_rust_source_still_opens_in_editor() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("main.rs"); + std::fs::write(&p, b"fn main() {}\n").unwrap(); + assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor); +} + +#[test] +fn test_open_file_directory_routes_to_session() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + classify_open_file_action(dir.path()), + OpenFileAction::ExecuteInSession + ); +} diff --git a/app/src/util/openable_file_type.rs b/app/src/util/openable_file_type.rs index 1dce2c929e..02b7f44d86 100644 --- a/app/src/util/openable_file_type.rs +++ b/app/src/util/openable_file_type.rs @@ -81,6 +81,58 @@ pub fn is_supported_image_file(path: impl AsRef) -> bool { .unwrap_or(false) } +/// Returns true if `path` looks like a shell script the user intends to run when +/// "Open with Warp" is invoked from Finder/another app via a `file://` URL. +/// +/// Policy: extension in {sh, bash, zsh, fish, ksh} with the user-execute bit set on Unix, +/// or extension in {ps1, bat, cmd} on Windows (no x-bit concept). On Unix, files with no +/// extension but a `#!` shebang and the user-execute bit set also qualify. +/// +/// Narrow on purpose: this only affects the URI entry point, not "Open in New Tab" from +/// other UI surfaces, which still want shell scripts viewable in the editor. +#[cfg(unix)] +pub fn is_runnable_shell_script(path: &Path) -> bool { + use std::io::Read; + use std::os::unix::fs::PermissionsExt; + + // Match the documented routing policy: only the owner's execute bit counts. + // A file `chmod 070` belongs to a group, not to the user invoking Warp. + let has_user_x_bit = std::fs::metadata(path) + .map(|m| m.permissions().mode() & 0o100 != 0) + .unwrap_or(false); + if !has_user_x_bit { + return false; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + if let Some(ext) = ext.as_deref() { + return matches!(ext, "sh" | "bash" | "zsh" | "fish" | "ksh"); + } + // No extension: accept if file starts with a `#!` shebang. Read only the first + // two bytes — this path is reached from a `file://` URL, so the file is + // attacker-controlled in size; `std::fs::read` would risk an OOM. + let mut prefix = [0u8; 2]; + if let Ok(mut file) = std::fs::File::open(path) { + return file.read_exact(&mut prefix).is_ok() && prefix == [b'#', b'!']; + } + false +} + +#[cfg(windows)] +pub fn is_runnable_shell_script(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .is_some_and(|ext| matches!(ext.as_str(), "ps1" | "bat" | "cmd")) +} + +#[cfg(not(any(unix, windows)))] +pub fn is_runnable_shell_script(_path: &Path) -> bool { + false +} + /// Determines if a file can be opened in Warp and returns its type. /// Returns `None` if the file is binary and should not be opened. pub fn is_file_openable_in_warp(path: &Path) -> Option { @@ -344,4 +396,102 @@ mod tests { assert!(!is_supported_code_file(Path::new("data.txt"))); assert!(!is_supported_code_file(Path::new("image.png"))); } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_executable_sh() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("hello.sh"); + std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap(); + let mut perms = std::fs::metadata(&p).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&p, perms).unwrap(); + assert!(is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_non_executable_sh() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("hello.sh"); + std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap(); + let mut perms = std::fs::metadata(&p).unwrap().permissions(); + perms.set_mode(0o644); + std::fs::set_permissions(&p, perms).unwrap(); + assert!(!is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_group_only_executable_rejected() { + // Mode 0o070: group-x and group-r/w only, no user-execute. Must NOT classify + // as runnable — only the owner's execute bit drives the routing decision. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("group_only.sh"); + std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o070)).unwrap(); + assert!(!is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_other_shell_extensions() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + for name in ["run.bash", "run.zsh", "run.fish", "run.ksh"] { + let p = dir.path().join(name); + std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(is_runnable_shell_script(&p), "{name} should be runnable"); + } + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_shebang_no_extension() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("noext"); + std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_shebang_no_extension_no_x_bit() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("noext"); + std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(!is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_plain_text_rejected() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("notes.txt"); + std::fs::write(&p, b"just some text\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!is_runnable_shell_script(&p)); + } + + #[test] + #[cfg(unix)] + fn test_is_runnable_shell_script_symlink_to_executable() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("real.sh"); + std::fs::write(&target, b"#!/bin/sh\n:\n").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + let link = dir.path().join("link.sh"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(is_runnable_shell_script(&link)); + } } From 419780537625b27aeede21798fdb539c90acd36e Mon Sep 17 00:00:00 2001 From: AMRIK Date: Thu, 30 Apr 2026 04:31:10 +0530 Subject: [PATCH 2/2] Route non-runnable shebang scripts to the editor An extensionless `#!/bin/sh` file without the user-execute bit was falling through to `ExecuteInSession` because `is_file_openable_in_warp` rejects extensionless files by extension only, so the `!is_runnable_shell_script` gate never fired. Such files now route to the editor. Extracts `starts_with_shebang` from `is_runnable_shell_script` and reuses it both there and in `classify_open_file_action`. Adds a URI-layer regression test plus four helper tests for shebang detection. --- app/src/uri/mod.rs | 16 ++++++--- app/src/uri/uri_test.rs | 14 ++++++++ app/src/util/openable_file_type.rs | 54 +++++++++++++++++++++++++----- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/app/src/uri/mod.rs b/app/src/uri/mod.rs index adfcc39afe..da9c8a151d 100644 --- a/app/src/uri/mod.rs +++ b/app/src/uri/mod.rs @@ -14,7 +14,7 @@ use crate::root_view::{open_new_window_get_handles, OpenLaunchConfigArg}; use crate::server::ids::ServerId; use crate::server::telemetry::{LaunchConfigUiLocation, TelemetryEvent}; use crate::util::openable_file_type::{ - is_file_openable_in_warp, is_markdown_file, is_runnable_shell_script, + is_file_openable_in_warp, is_markdown_file, is_runnable_shell_script, starts_with_shebang, }; use crate::workspace::{Workspace, WorkspaceAction, WorkspaceRegistry}; use crate::{cloud_object::ObjectType, workspace::ToastStack}; @@ -1024,9 +1024,17 @@ fn classify_open_file_action(path: &Path) -> OpenFileAction { if is_markdown_file(path) { return OpenFileAction::Notebook; } - if path.is_file() && is_file_openable_in_warp(path).is_some() && !is_runnable_shell_script(path) - { - return OpenFileAction::Editor; + if path.is_file() { + if is_runnable_shell_script(path) { + return OpenFileAction::ExecuteInSession; + } + // Anything we can show in the editor opens there. The second branch catches + // shebang scripts that `is_file_openable_in_warp` rejects on extension alone + // (e.g. an extensionless `#!/bin/sh` file without the user-execute bit) so + // they don't fall through to the executor and produce a `permission denied`. + if is_file_openable_in_warp(path).is_some() || starts_with_shebang(path) { + return OpenFileAction::Editor; + } } OpenFileAction::ExecuteInSession } diff --git a/app/src/uri/uri_test.rs b/app/src/uri/uri_test.rs index 367e6bab0d..44ba39e438 100644 --- a/app/src/uri/uri_test.rs +++ b/app/src/uri/uri_test.rs @@ -645,3 +645,17 @@ fn test_open_file_directory_routes_to_session() { OpenFileAction::ExecuteInSession ); } + +#[test] +#[cfg(unix)] +fn test_open_file_non_runnable_shebang_routes_to_editor() { + // Extensionless `#!/bin/sh` file without the user-execute bit. Without the + // shebang fall-through this would hit `ExecuteInSession` and the shell would + // refuse to run it; the editor is the right place to view it. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("noext"); + std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor); +} diff --git a/app/src/util/openable_file_type.rs b/app/src/util/openable_file_type.rs index 02b7f44d86..ef1366c24e 100644 --- a/app/src/util/openable_file_type.rs +++ b/app/src/util/openable_file_type.rs @@ -90,9 +90,20 @@ pub fn is_supported_image_file(path: impl AsRef) -> bool { /// /// Narrow on purpose: this only affects the URI entry point, not "Open in New Tab" from /// other UI surfaces, which still want shell scripts viewable in the editor. +/// Returns true if `path` exists and starts with a `#!` shebang. Reads only the +/// first two bytes — the URI entry point is reached from a `file://` URL, so the +/// file is attacker-controlled in size and `std::fs::read` would risk an OOM. +pub(crate) fn starts_with_shebang(path: &Path) -> bool { + use std::io::Read; + let mut prefix = [0u8; 2]; + match std::fs::File::open(path) { + Ok(mut file) => file.read_exact(&mut prefix).is_ok() && prefix == [b'#', b'!'], + Err(_) => false, + } +} + #[cfg(unix)] pub fn is_runnable_shell_script(path: &Path) -> bool { - use std::io::Read; use std::os::unix::fs::PermissionsExt; // Match the documented routing policy: only the owner's execute bit counts. @@ -110,14 +121,7 @@ pub fn is_runnable_shell_script(path: &Path) -> bool { if let Some(ext) = ext.as_deref() { return matches!(ext, "sh" | "bash" | "zsh" | "fish" | "ksh"); } - // No extension: accept if file starts with a `#!` shebang. Read only the first - // two bytes — this path is reached from a `file://` URL, so the file is - // attacker-controlled in size; `std::fs::read` would risk an OOM. - let mut prefix = [0u8; 2]; - if let Ok(mut file) = std::fs::File::open(path) { - return file.read_exact(&mut prefix).is_ok() && prefix == [b'#', b'!']; - } - false + starts_with_shebang(path) } #[cfg(windows)] @@ -494,4 +498,36 @@ mod tests { std::os::unix::fs::symlink(&target, &link).unwrap(); assert!(is_runnable_shell_script(&link)); } + + #[test] + fn test_starts_with_shebang_present() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("script"); + std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap(); + assert!(starts_with_shebang(&p)); + } + + #[test] + fn test_starts_with_shebang_absent() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("plain"); + std::fs::write(&p, b"echo hi\n").unwrap(); + assert!(!starts_with_shebang(&p)); + } + + #[test] + fn test_starts_with_shebang_one_byte_file() { + // `read_exact(&mut [0u8; 2])` must short-read on a single-byte file. + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("tiny"); + std::fs::write(&p, b"#").unwrap(); + assert!(!starts_with_shebang(&p)); + } + + #[test] + fn test_starts_with_shebang_missing_path() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("nope"); + assert!(!starts_with_shebang(&p)); + } }