Skip to content

Commit edac651

Browse files
Run executable shell scripts in the terminal instead of opening in the editor (#9503)
## Description `is_file_openable_in_warp` returns `Some(Text)` for any non-binary file, which shadowed the existing executor branch in `open_file` when handling a `file://` URL — so executable shell scripts were opened in the editor instead of being run. The URI handler now classifies the action up front via a new pure helper `classify_open_file_action`, and routes executable shell scripts to the executor path that the rest of the agent flow already uses. Detection rules for "runnable shell script": - **Unix:** user-execute bit set AND (extension in `{sh, bash, zsh, fish, ksh}` OR file starts with a `#!` shebang). - **Windows:** extension in `{ps1, bat, cmd}` (no x-bit concept). Non-executable shell scripts still open in the editor — the routing is gated on the user-execute bit on Unix, so this only changes behavior for files the user marked executable. Fixes #9005. ## Testing Unit tests added at both layers: - **`is_runnable_shell_script`** — 7 tests in `app/src/util/openable_file_type.rs`: executable `.sh`, non-executable `.sh`, alternate executable extensions (`.bash`, `.zsh`, `.fish`, `.ksh`), shebang-with-no-extension (with and without x-bit), plain-text rejection, symlink to executable. - **`classify_open_file_action`** — 6 tests in `app/src/uri/uri_test.rs`: executable `.sh` → execute, non-executable `.sh` → editor, executable `.bash`/`.zsh`/`.fish` → execute, markdown → notebook, Rust source → editor, directory → session. `./script/presubmit` is clean (`cargo fmt`, `cargo clippy --workspace --all-targets --tests -- -D warnings`, `clang-format`, `wgslfmt`, `cargo nextest`). The 5 `shell_integration_tests::*ssh*` failures observed locally are unrelated to this change — they require a Docker SSH container — and are skipped on fork PRs per #9304. ## Agent Mode - [ ] Warp Agent Mode — This PR was created via Warp's AI Agent Mode CHANGELOG-BUG-FIX: Executable shell scripts opened from a \`file://\` URL now run in the terminal instead of opening in the editor.
1 parent 0ac090c commit edac651

3 files changed

Lines changed: 312 additions & 4 deletions

File tree

‎app/src/uri/mod.rs‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use crate::linear::{LinearAction, LinearIssueWork};
1313
use crate::root_view::{open_new_window_get_handles, OpenLaunchConfigArg};
1414
use crate::server::ids::ServerId;
1515
use crate::server::telemetry::{LaunchConfigUiLocation, TelemetryEvent};
16-
use crate::util::openable_file_type::{is_file_openable_in_warp, is_markdown_file};
16+
use crate::util::openable_file_type::{
17+
is_file_openable_in_warp, is_markdown_file, is_runnable_shell_script, starts_with_shebang,
18+
};
1719
use crate::workspace::{Workspace, WorkspaceAction, WorkspaceRegistry};
1820
use crate::{cloud_object::ObjectType, workspace::ToastStack};
1921
use crate::{drive::OpenWarpDriveObjectArgs, view_components::DismissibleToast};
@@ -30,7 +32,7 @@ use anyhow::{anyhow, ensure, Result};
3032
use itertools::Itertools;
3133
use session_sharing_protocol::common::SessionId;
3234
use std::collections::HashMap;
33-
use std::path::PathBuf;
35+
use std::path::{Path, PathBuf};
3436
use std::str::FromStr;
3537
use url::Url;
3638
use warpui::notification::UserNotification;
@@ -1004,6 +1006,39 @@ fn get_primary_window(
10041006
non_quake_mode_windows.next()
10051007
}
10061008

1009+
/// What `open_file` should do with an incoming `file://` URL.
1010+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1011+
enum OpenFileAction {
1012+
/// Open in the markdown notebook pane.
1013+
Notebook,
1014+
/// Open in Warp's code/text editor pane.
1015+
Editor,
1016+
/// Open a session at the parent directory and queue the file as the pending command,
1017+
/// or just open a session at the directory path if `path` is a directory.
1018+
ExecuteInSession,
1019+
}
1020+
1021+
/// Pure routing decision for `open_file`. Extracted so it can be unit-tested without
1022+
/// standing up a full `AppContext`.
1023+
fn classify_open_file_action(path: &Path) -> OpenFileAction {
1024+
if is_markdown_file(path) {
1025+
return OpenFileAction::Notebook;
1026+
}
1027+
if path.is_file() {
1028+
if is_runnable_shell_script(path) {
1029+
return OpenFileAction::ExecuteInSession;
1030+
}
1031+
// Anything we can show in the editor opens there. The second branch catches
1032+
// shebang scripts that `is_file_openable_in_warp` rejects on extension alone
1033+
// (e.g. an extensionless `#!/bin/sh` file without the user-execute bit) so
1034+
// they don't fall through to the executor and produce a `permission denied`.
1035+
if is_file_openable_in_warp(path).is_some() || starts_with_shebang(path) {
1036+
return OpenFileAction::Editor;
1037+
}
1038+
}
1039+
OpenFileAction::ExecuteInSession
1040+
}
1041+
10071042
/// Handle an incoming `file://` URL.
10081043
/// * Markdown files are opened as notebook panes.
10091044
/// * For directories, open a new session at the directory path.
@@ -1015,7 +1050,8 @@ fn open_file(window_id: Option<WindowId>, path: PathBuf, ctx: &mut AppContext) {
10151050
.map(|view_id| (window_id, view_id))
10161051
});
10171052

1018-
if is_markdown_file(&path) {
1053+
let action = classify_open_file_action(&path);
1054+
if action == OpenFileAction::Notebook {
10191055
if let Some((primary_window_id, root_view_id)) = primary_window_and_view {
10201056
ctx.dispatch_action(
10211057
primary_window_id,
@@ -1027,7 +1063,7 @@ fn open_file(window_id: Option<WindowId>, path: PathBuf, ctx: &mut AppContext) {
10271063
} else {
10281064
ctx.dispatch_global_action("root_view:open_new_with_file_notebook", &path);
10291065
}
1030-
} else if path.is_file() && is_file_openable_in_warp(&path).is_some() {
1066+
} else if action == OpenFileAction::Editor {
10311067
#[cfg(feature = "local_fs")]
10321068
{
10331069
use crate::code::editor_management::CodeSource;

‎app/src/uri/uri_test.rs‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,3 +573,89 @@ fn test_parse_tab_path_bare_tilde() {
573573
let home = dirs::home_dir().expect("HOME must be set for this test");
574574
assert_eq!(parse_tab_path(&url), Some(home));
575575
}
576+
577+
// Regression coverage for issue #9005: shell scripts opened via `file://` should run,
578+
// not open in the editor. Exercised through the pure routing helper to avoid standing
579+
// up a full `AppContext`.
580+
581+
#[test]
582+
#[cfg(unix)]
583+
fn test_open_file_executable_sh_routes_to_execute() {
584+
use std::os::unix::fs::PermissionsExt;
585+
let dir = tempfile::tempdir().unwrap();
586+
let p = dir.path().join("run.sh");
587+
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
588+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
589+
assert_eq!(
590+
classify_open_file_action(&p),
591+
OpenFileAction::ExecuteInSession
592+
);
593+
}
594+
595+
#[test]
596+
#[cfg(unix)]
597+
fn test_open_file_non_executable_sh_routes_to_editor() {
598+
use std::os::unix::fs::PermissionsExt;
599+
let dir = tempfile::tempdir().unwrap();
600+
let p = dir.path().join("view.sh");
601+
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
602+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
603+
assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor);
604+
}
605+
606+
#[test]
607+
#[cfg(unix)]
608+
fn test_open_file_executable_bash_zsh_fish_route_to_execute() {
609+
use std::os::unix::fs::PermissionsExt;
610+
let dir = tempfile::tempdir().unwrap();
611+
for name in ["run.bash", "run.zsh", "run.fish"] {
612+
let p = dir.path().join(name);
613+
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
614+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
615+
assert_eq!(
616+
classify_open_file_action(&p),
617+
OpenFileAction::ExecuteInSession,
618+
"{name} should route to ExecuteInSession",
619+
);
620+
}
621+
}
622+
623+
#[test]
624+
fn test_open_file_markdown_unchanged() {
625+
let dir = tempfile::tempdir().unwrap();
626+
let p = dir.path().join("README.md");
627+
std::fs::write(&p, b"# hi\n").unwrap();
628+
assert_eq!(classify_open_file_action(&p), OpenFileAction::Notebook);
629+
}
630+
631+
#[test]
632+
#[cfg(feature = "local_fs")]
633+
fn test_open_file_rust_source_still_opens_in_editor() {
634+
let dir = tempfile::tempdir().unwrap();
635+
let p = dir.path().join("main.rs");
636+
std::fs::write(&p, b"fn main() {}\n").unwrap();
637+
assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor);
638+
}
639+
640+
#[test]
641+
fn test_open_file_directory_routes_to_session() {
642+
let dir = tempfile::tempdir().unwrap();
643+
assert_eq!(
644+
classify_open_file_action(dir.path()),
645+
OpenFileAction::ExecuteInSession
646+
);
647+
}
648+
649+
#[test]
650+
#[cfg(unix)]
651+
fn test_open_file_non_runnable_shebang_routes_to_editor() {
652+
// Extensionless `#!/bin/sh` file without the user-execute bit. Without the
653+
// shebang fall-through this would hit `ExecuteInSession` and the shell would
654+
// refuse to run it; the editor is the right place to view it.
655+
use std::os::unix::fs::PermissionsExt;
656+
let dir = tempfile::tempdir().unwrap();
657+
let p = dir.path().join("noext");
658+
std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap();
659+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
660+
assert_eq!(classify_open_file_action(&p), OpenFileAction::Editor);
661+
}

‎app/src/util/openable_file_type.rs‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,62 @@ pub fn is_supported_image_file(path: impl AsRef<Path>) -> bool {
8181
.unwrap_or(false)
8282
}
8383

84+
/// Returns true if `path` looks like a shell script the user intends to run when
85+
/// "Open with Warp" is invoked from Finder/another app via a `file://` URL.
86+
///
87+
/// Policy: extension in {sh, bash, zsh, fish, ksh} with the user-execute bit set on Unix,
88+
/// or extension in {ps1, bat, cmd} on Windows (no x-bit concept). On Unix, files with no
89+
/// extension but a `#!` shebang and the user-execute bit set also qualify.
90+
///
91+
/// Narrow on purpose: this only affects the URI entry point, not "Open in New Tab" from
92+
/// other UI surfaces, which still want shell scripts viewable in the editor.
93+
/// Returns true if `path` exists and starts with a `#!` shebang. Reads only the
94+
/// first two bytes — the URI entry point is reached from a `file://` URL, so the
95+
/// file is attacker-controlled in size and `std::fs::read` would risk an OOM.
96+
pub(crate) fn starts_with_shebang(path: &Path) -> bool {
97+
use std::io::Read;
98+
let mut prefix = [0u8; 2];
99+
match std::fs::File::open(path) {
100+
Ok(mut file) => file.read_exact(&mut prefix).is_ok() && prefix == [b'#', b'!'],
101+
Err(_) => false,
102+
}
103+
}
104+
105+
#[cfg(unix)]
106+
pub fn is_runnable_shell_script(path: &Path) -> bool {
107+
use std::os::unix::fs::PermissionsExt;
108+
109+
// Match the documented routing policy: only the owner's execute bit counts.
110+
// A file `chmod 070` belongs to a group, not to the user invoking Warp.
111+
let has_user_x_bit = std::fs::metadata(path)
112+
.map(|m| m.permissions().mode() & 0o100 != 0)
113+
.unwrap_or(false);
114+
if !has_user_x_bit {
115+
return false;
116+
}
117+
let ext = path
118+
.extension()
119+
.and_then(|e| e.to_str())
120+
.map(|e| e.to_ascii_lowercase());
121+
if let Some(ext) = ext.as_deref() {
122+
return matches!(ext, "sh" | "bash" | "zsh" | "fish" | "ksh");
123+
}
124+
starts_with_shebang(path)
125+
}
126+
127+
#[cfg(windows)]
128+
pub fn is_runnable_shell_script(path: &Path) -> bool {
129+
path.extension()
130+
.and_then(|e| e.to_str())
131+
.map(|e| e.to_ascii_lowercase())
132+
.is_some_and(|ext| matches!(ext.as_str(), "ps1" | "bat" | "cmd"))
133+
}
134+
135+
#[cfg(not(any(unix, windows)))]
136+
pub fn is_runnable_shell_script(_path: &Path) -> bool {
137+
false
138+
}
139+
84140
/// Determines if a file can be opened in Warp and returns its type.
85141
/// Returns `None` if the file is binary and should not be opened.
86142
pub fn is_file_openable_in_warp(path: &Path) -> Option<OpenableFileType> {
@@ -344,4 +400,134 @@ mod tests {
344400
assert!(!is_supported_code_file(Path::new("data.txt")));
345401
assert!(!is_supported_code_file(Path::new("image.png")));
346402
}
403+
404+
#[test]
405+
#[cfg(unix)]
406+
fn test_is_runnable_shell_script_executable_sh() {
407+
use std::os::unix::fs::PermissionsExt;
408+
let dir = tempfile::tempdir().unwrap();
409+
let p = dir.path().join("hello.sh");
410+
std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap();
411+
let mut perms = std::fs::metadata(&p).unwrap().permissions();
412+
perms.set_mode(0o755);
413+
std::fs::set_permissions(&p, perms).unwrap();
414+
assert!(is_runnable_shell_script(&p));
415+
}
416+
417+
#[test]
418+
#[cfg(unix)]
419+
fn test_is_runnable_shell_script_non_executable_sh() {
420+
use std::os::unix::fs::PermissionsExt;
421+
let dir = tempfile::tempdir().unwrap();
422+
let p = dir.path().join("hello.sh");
423+
std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap();
424+
let mut perms = std::fs::metadata(&p).unwrap().permissions();
425+
perms.set_mode(0o644);
426+
std::fs::set_permissions(&p, perms).unwrap();
427+
assert!(!is_runnable_shell_script(&p));
428+
}
429+
430+
#[test]
431+
#[cfg(unix)]
432+
fn test_is_runnable_shell_script_group_only_executable_rejected() {
433+
// Mode 0o070: group-x and group-r/w only, no user-execute. Must NOT classify
434+
// as runnable — only the owner's execute bit drives the routing decision.
435+
use std::os::unix::fs::PermissionsExt;
436+
let dir = tempfile::tempdir().unwrap();
437+
let p = dir.path().join("group_only.sh");
438+
std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap();
439+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o070)).unwrap();
440+
assert!(!is_runnable_shell_script(&p));
441+
}
442+
443+
#[test]
444+
#[cfg(unix)]
445+
fn test_is_runnable_shell_script_other_shell_extensions() {
446+
use std::os::unix::fs::PermissionsExt;
447+
let dir = tempfile::tempdir().unwrap();
448+
for name in ["run.bash", "run.zsh", "run.fish", "run.ksh"] {
449+
let p = dir.path().join(name);
450+
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
451+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
452+
assert!(is_runnable_shell_script(&p), "{name} should be runnable");
453+
}
454+
}
455+
456+
#[test]
457+
#[cfg(unix)]
458+
fn test_is_runnable_shell_script_shebang_no_extension() {
459+
use std::os::unix::fs::PermissionsExt;
460+
let dir = tempfile::tempdir().unwrap();
461+
let p = dir.path().join("noext");
462+
std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap();
463+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
464+
assert!(is_runnable_shell_script(&p));
465+
}
466+
467+
#[test]
468+
#[cfg(unix)]
469+
fn test_is_runnable_shell_script_shebang_no_extension_no_x_bit() {
470+
use std::os::unix::fs::PermissionsExt;
471+
let dir = tempfile::tempdir().unwrap();
472+
let p = dir.path().join("noext");
473+
std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap();
474+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
475+
assert!(!is_runnable_shell_script(&p));
476+
}
477+
478+
#[test]
479+
#[cfg(unix)]
480+
fn test_is_runnable_shell_script_plain_text_rejected() {
481+
use std::os::unix::fs::PermissionsExt;
482+
let dir = tempfile::tempdir().unwrap();
483+
let p = dir.path().join("notes.txt");
484+
std::fs::write(&p, b"just some text\n").unwrap();
485+
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
486+
assert!(!is_runnable_shell_script(&p));
487+
}
488+
489+
#[test]
490+
#[cfg(unix)]
491+
fn test_is_runnable_shell_script_symlink_to_executable() {
492+
use std::os::unix::fs::PermissionsExt;
493+
let dir = tempfile::tempdir().unwrap();
494+
let target = dir.path().join("real.sh");
495+
std::fs::write(&target, b"#!/bin/sh\n:\n").unwrap();
496+
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
497+
let link = dir.path().join("link.sh");
498+
std::os::unix::fs::symlink(&target, &link).unwrap();
499+
assert!(is_runnable_shell_script(&link));
500+
}
501+
502+
#[test]
503+
fn test_starts_with_shebang_present() {
504+
let dir = tempfile::tempdir().unwrap();
505+
let p = dir.path().join("script");
506+
std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap();
507+
assert!(starts_with_shebang(&p));
508+
}
509+
510+
#[test]
511+
fn test_starts_with_shebang_absent() {
512+
let dir = tempfile::tempdir().unwrap();
513+
let p = dir.path().join("plain");
514+
std::fs::write(&p, b"echo hi\n").unwrap();
515+
assert!(!starts_with_shebang(&p));
516+
}
517+
518+
#[test]
519+
fn test_starts_with_shebang_one_byte_file() {
520+
// `read_exact(&mut [0u8; 2])` must short-read on a single-byte file.
521+
let dir = tempfile::tempdir().unwrap();
522+
let p = dir.path().join("tiny");
523+
std::fs::write(&p, b"#").unwrap();
524+
assert!(!starts_with_shebang(&p));
525+
}
526+
527+
#[test]
528+
fn test_starts_with_shebang_missing_path() {
529+
let dir = tempfile::tempdir().unwrap();
530+
let p = dir.path().join("nope");
531+
assert!(!starts_with_shebang(&p));
532+
}
347533
}

0 commit comments

Comments
 (0)