Skip to content

Commit 83a6f74

Browse files
chaliyclaude
andauthored
feat(interpreter): support executing script files by path (#222)
## Summary - Add script execution by absolute/relative path and `$PATH` search in the interpreter dispatch chain - Fix existing partial implementation: add proper call frame (`$0`, `$1..N`), error handling for missing files (exit 127), directories (exit 126), and permission denied (exit 126) - Add `$PATH` search for commands without `/` — searches each `$PATH` directory for executable files after builtins check - Fix monty dependency version (`0.0.6` → `0.0.7`, preexisting build breakage) ## Test plan - [x] 18 new integration tests (`script_execution_tests.rs`): absolute path, relative path, args, `$0`/`$#`/`$@`, missing file, directory, permission denied, exit code propagation, nested paths, `$PATH` search, nested script calls, chmod+exec - [x] 10 new spec test cases (`script-exec.test.sh`): end-to-end bash behavior verification - [x] All 93 existing lib+integration tests pass - [x] All 13 spec test suites pass (including existing `eval-bugs.test.sh` `script_chmod_exec_by_path` case) - [x] `cargo fmt --check` clean - [x] `cargo clippy -- -D warnings` clean --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81e41a5 commit 83a6f74

File tree

10 files changed

+633
-48
lines changed

10 files changed

+633
-48
lines changed

crates/bashkit/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ base64 = { workspace = true, optional = true }
6161
tracing = { workspace = true, optional = true }
6262

6363
# Embedded Python interpreter (optional)
64-
monty = { git = "https://github.com/pydantic/monty", version = "0.0.6", optional = true }
64+
monty = { git = "https://github.com/pydantic/monty", version = "0.0.7", optional = true }
6565

6666
[features]
6767
default = []

crates/bashkit/docs/compatibility.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ for sandbox security reasons. See the compliance spec for details.
6262
| `local` | `VAR=value` | Local variables |
6363
| `source` | `file [args]` | Source script; loads functions/variables, PATH search, positional params |
6464
| `.` | `file [args]` | Alias for source |
65+
| `/path/to/script.sh` | `[args]` | Execute script by absolute/relative path (shebang stripped, call frame) |
66+
| `$PATH` search | `cmd [args]` | Search `$PATH` dirs for executable scripts (after builtins) |
6567
| `break` | `[N]` | Break from loop |
6668
| `continue` | `[N]` | Continue loop |
6769
| `return` | `[N]` | Return from function |

crates/bashkit/src/interpreter/mod.rs

Lines changed: 169 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2203,44 +2203,18 @@ impl Interpreter {
22032203

22042204
// Check if command is a path to an executable script in the VFS
22052205
if name.contains('/') {
2206-
let path = self.resolve_path(name);
2207-
if let Ok(content) = self.fs.read_file(&path).await {
2208-
// Check execute permission
2209-
if let Ok(meta) = self.fs.stat(&path).await {
2210-
if meta.mode & 0o111 != 0 {
2211-
let script_text = String::from_utf8_lossy(&content).to_string();
2212-
// Strip shebang line if present
2213-
let script_text = if script_text.starts_with("#!") {
2214-
script_text
2215-
.find('\n')
2216-
.map(|pos| &script_text[pos + 1..])
2217-
.unwrap_or("")
2218-
.to_string()
2219-
} else {
2220-
script_text
2221-
};
2222-
let parser = Parser::with_limits(
2223-
&script_text,
2224-
self.limits.max_ast_depth,
2225-
self.limits.max_parser_operations,
2226-
);
2227-
match parser.parse() {
2228-
Ok(script) => {
2229-
let result = self.execute(&script).await?;
2230-
return self.apply_redirections(result, &command.redirects).await;
2231-
}
2232-
Err(e) => {
2233-
return Ok(ExecResult::err(format!("bash: {}: {}\n", name, e), 2));
2234-
}
2235-
}
2236-
} else {
2237-
return Ok(ExecResult::err(
2238-
format!("bash: {}: Permission denied\n", name),
2239-
126,
2240-
));
2241-
}
2242-
}
2243-
}
2206+
let result = self
2207+
.try_execute_script_by_path(name, &args, &command.redirects)
2208+
.await?;
2209+
return Ok(result);
2210+
}
2211+
2212+
// No slash in name: search $PATH for executable script
2213+
if let Some(result) = self
2214+
.try_execute_script_via_path_search(name, &args, &command.redirects)
2215+
.await?
2216+
{
2217+
return Ok(result);
22442218
}
22452219

22462220
// Command not found - return error like bash does (exit code 127)
@@ -2250,6 +2224,163 @@ impl Interpreter {
22502224
))
22512225
}
22522226

2227+
/// Execute a script file by resolved path.
2228+
///
2229+
/// Bash behavior for path-based commands (name contains `/`):
2230+
/// 1. Resolve path (absolute or relative to cwd)
2231+
/// 2. stat() — if not found: "No such file or directory" (exit 127)
2232+
/// 3. If directory: "Is a directory" (exit 126)
2233+
/// 4. If not executable (mode & 0o111 == 0): "Permission denied" (exit 126)
2234+
/// 5. Read file, strip shebang, parse, execute in call frame
2235+
async fn try_execute_script_by_path(
2236+
&mut self,
2237+
name: &str,
2238+
args: &[String],
2239+
redirects: &[Redirect],
2240+
) -> Result<ExecResult> {
2241+
let path = self.resolve_path(name);
2242+
2243+
// stat the file
2244+
let meta = match self.fs.stat(&path).await {
2245+
Ok(m) => m,
2246+
Err(_) => {
2247+
return Ok(ExecResult::err(
2248+
format!("bash: {}: No such file or directory", name),
2249+
127,
2250+
));
2251+
}
2252+
};
2253+
2254+
// Directory check
2255+
if meta.file_type.is_dir() {
2256+
return Ok(ExecResult::err(
2257+
format!("bash: {}: Is a directory", name),
2258+
126,
2259+
));
2260+
}
2261+
2262+
// Execute permission check
2263+
if meta.mode & 0o111 == 0 {
2264+
return Ok(ExecResult::err(
2265+
format!("bash: {}: Permission denied", name),
2266+
126,
2267+
));
2268+
}
2269+
2270+
// Read file content
2271+
let content = match self.fs.read_file(&path).await {
2272+
Ok(c) => String::from_utf8_lossy(&c).to_string(),
2273+
Err(_) => {
2274+
return Ok(ExecResult::err(
2275+
format!("bash: {}: No such file or directory", name),
2276+
127,
2277+
));
2278+
}
2279+
};
2280+
2281+
self.execute_script_content(name, &content, args, redirects)
2282+
.await
2283+
}
2284+
2285+
/// Search $PATH for an executable script and run it.
2286+
///
2287+
/// Returns `Ok(None)` if no matching file found (caller emits "command not found").
2288+
async fn try_execute_script_via_path_search(
2289+
&mut self,
2290+
name: &str,
2291+
args: &[String],
2292+
redirects: &[Redirect],
2293+
) -> Result<Option<ExecResult>> {
2294+
let path_var = self
2295+
.variables
2296+
.get("PATH")
2297+
.or_else(|| self.env.get("PATH"))
2298+
.cloned()
2299+
.unwrap_or_default();
2300+
2301+
for dir in path_var.split(':') {
2302+
if dir.is_empty() {
2303+
continue;
2304+
}
2305+
let candidate = PathBuf::from(dir).join(name);
2306+
if let Ok(meta) = self.fs.stat(&candidate).await {
2307+
if meta.file_type.is_dir() {
2308+
continue;
2309+
}
2310+
if meta.mode & 0o111 == 0 {
2311+
continue;
2312+
}
2313+
if let Ok(content) = self.fs.read_file(&candidate).await {
2314+
let script_text = String::from_utf8_lossy(&content).to_string();
2315+
let result = self
2316+
.execute_script_content(name, &script_text, args, redirects)
2317+
.await?;
2318+
return Ok(Some(result));
2319+
}
2320+
}
2321+
}
2322+
2323+
Ok(None)
2324+
}
2325+
2326+
/// Parse and execute script content in a new call frame.
2327+
///
2328+
/// Shared by path-based and $PATH-based script execution.
2329+
/// Sets up $0 = script name, $1..N = args, strips shebang.
2330+
async fn execute_script_content(
2331+
&mut self,
2332+
name: &str,
2333+
content: &str,
2334+
args: &[String],
2335+
redirects: &[Redirect],
2336+
) -> Result<ExecResult> {
2337+
// Strip shebang line if present
2338+
let script_text = if content.starts_with("#!") {
2339+
content
2340+
.find('\n')
2341+
.map(|pos| &content[pos + 1..])
2342+
.unwrap_or("")
2343+
} else {
2344+
content
2345+
};
2346+
2347+
let parser = Parser::with_limits(
2348+
script_text,
2349+
self.limits.max_ast_depth,
2350+
self.limits.max_parser_operations,
2351+
);
2352+
let script = match parser.parse() {
2353+
Ok(s) => s,
2354+
Err(e) => {
2355+
return Ok(ExecResult::err(format!("bash: {}: {}\n", name, e), 2));
2356+
}
2357+
};
2358+
2359+
// Push call frame: $0 = script name, $1..N = args
2360+
self.call_stack.push(CallFrame {
2361+
name: name.to_string(),
2362+
locals: HashMap::new(),
2363+
positional: args.to_vec(),
2364+
});
2365+
2366+
let result = self.execute(&script).await;
2367+
2368+
// Pop call frame
2369+
self.call_stack.pop();
2370+
2371+
match result {
2372+
Ok(mut exec_result) => {
2373+
// Handle return - convert Return control flow to exit code
2374+
if let ControlFlow::Return(code) = exec_result.control_flow {
2375+
exec_result.exit_code = code;
2376+
exec_result.control_flow = ControlFlow::None;
2377+
}
2378+
self.apply_redirections(exec_result, redirects).await
2379+
}
2380+
Err(e) => Err(e),
2381+
}
2382+
}
2383+
22532384
/// Execute `source` / `.` - read and execute commands from a file in current shell.
22542385
///
22552386
/// Bash behavior:

0 commit comments

Comments
 (0)