diff --git a/crates/bashkit-js/Cargo.toml b/crates/bashkit-js/Cargo.toml index 91776886..16c1adc4 100644 --- a/crates/bashkit-js/Cargo.toml +++ b/crates/bashkit-js/Cargo.toml @@ -14,7 +14,7 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] -bashkit = { path = "../bashkit", features = ["scripted_tool", "python"] } +bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs"] } napi = { workspace = true } napi-derive = { workspace = true } serde = { workspace = true } diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 79253db4..414f8c5c 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -171,9 +171,21 @@ pub struct ExecResult { } // ============================================================================ -// BashOptions +// MountConfig + BashOptions // ============================================================================ +/// Configuration for a real filesystem mount. +#[napi(object)] +#[derive(Clone)] +pub struct MountConfig { + /// Host filesystem path to mount. + pub host_path: String, + /// VFS path where mount appears (defaults to host_path). + pub vfs_path: Option, + /// If true, mount is read-only (default: true). + pub read_only: Option, +} + /// Options for creating a Bash or BashTool instance. #[napi(object)] pub struct BashOptions { @@ -181,9 +193,24 @@ pub struct BashOptions { pub hostname: Option, pub max_commands: Option, pub max_loop_iterations: Option, + pub max_total_loop_iterations: Option, + pub max_function_depth: Option, + /// Execution timeout in milliseconds. + pub timeout_ms: Option, + /// Parser timeout in milliseconds. + pub parser_timeout_ms: Option, + pub max_input_bytes: Option, + pub max_ast_depth: Option, + pub max_parser_operations: Option, + pub max_stdout_bytes: Option, + pub max_stderr_bytes: Option, + /// Whether to capture the final environment state in ExecResult. + pub capture_final_env: Option, /// Files to mount in the virtual filesystem. /// Keys are absolute paths, values are file content strings. pub files: Option>, + /// Real filesystem mounts. Each entry: { hostPath, vfsPath?, readOnly? } + pub mounts: Option>, /// Enable embedded Python execution (`python`/`python3` builtins). pub python: Option, /// Names of external functions callable from embedded Python code. @@ -196,7 +223,18 @@ fn default_opts() -> BashOptions { hostname: None, max_commands: None, max_loop_iterations: None, + max_total_loop_iterations: None, + max_function_depth: None, + timeout_ms: None, + parser_timeout_ms: None, + max_input_bytes: None, + max_ast_depth: None, + max_parser_operations: None, + max_stdout_bytes: None, + max_stderr_bytes: None, + capture_final_env: None, files: None, + mounts: None, python: None, external_functions: None, } @@ -214,6 +252,17 @@ struct SharedState { hostname: Option, max_commands: Option, max_loop_iterations: Option, + max_total_loop_iterations: Option, + max_function_depth: Option, + timeout_ms: Option, + parser_timeout_ms: Option, + max_input_bytes: Option, + max_ast_depth: Option, + max_parser_operations: Option, + max_stdout_bytes: Option, + max_stderr_bytes: Option, + capture_final_env: Option, + mounts: Option>, python: bool, external_functions: Vec, external_handler: Option, @@ -261,39 +310,9 @@ impl Bash { #[napi(constructor)] pub fn new(options: Option) -> napi::Result { let opts = options.unwrap_or_else(default_opts); - let py = opts.python.unwrap_or(false); - let ext_fns = opts.external_functions.clone().unwrap_or_default(); - - let bash = build_bash( - opts.username.as_deref(), - opts.hostname.as_deref(), - opts.max_commands, - opts.max_loop_iterations, - opts.files.as_ref(), - py, - &ext_fns, - None, - ); - let cancelled = bash.cancellation_token(); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?; - + let state = shared_state_from_opts(opts, None)?; Ok(Self { - state: Arc::new(SharedState { - inner: Mutex::new(bash), - rt: Mutex::new(rt), - cancelled, - username: opts.username, - hostname: opts.hostname, - max_commands: opts.max_commands, - max_loop_iterations: opts.max_loop_iterations, - python: py, - external_functions: ext_fns, - external_handler: None, - }), + state: Arc::new(state), }) } @@ -377,17 +396,7 @@ impl Bash { pub fn reset(&self) -> napi::Result<()> { block_on_with(&self.state, |s| async move { let mut bash = s.inner.lock().await; - let new_bash = build_bash( - s.username.as_deref(), - s.hostname.as_deref(), - s.max_commands, - s.max_loop_iterations, - None, - s.python, - &s.external_functions, - s.external_handler.as_ref(), - ); - *bash = new_bash; + *bash = build_bash_from_state(&s, None); Ok(()) }) } @@ -431,40 +440,17 @@ impl Bash { options: Option, ) -> napi::Result { let opts = options.unwrap_or_else(default_opts); + let mut state = shared_state_from_opts(opts, None)?; - // Build a configured Bash instance with proper limits, then restore snapshot state - let mut bash = build_bash( - opts.username.as_deref(), - opts.hostname.as_deref(), - opts.max_commands, - opts.max_loop_iterations, - opts.files.as_ref(), - opts.python.unwrap_or(false), - &opts.external_functions.clone().unwrap_or_default(), - None, - ); // restore_snapshot preserves the instance's limits while restoring shell state - bash.restore_snapshot(&data) + state + .inner + .get_mut() + .restore_snapshot(&data) .map_err(|e| napi::Error::from_reason(e.to_string()))?; - let cancelled = bash.cancellation_token(); - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?; Ok(Self { - state: Arc::new(SharedState { - inner: Mutex::new(bash), - rt: tokio::sync::Mutex::new(rt), - cancelled, - username: opts.username, - hostname: opts.hostname, - max_commands: opts.max_commands, - max_loop_iterations: opts.max_loop_iterations, - python: opts.python.unwrap_or(false), - external_functions: opts.external_functions.unwrap_or_default(), - external_handler: None, - }), + state: Arc::new(state), }) } @@ -549,6 +535,46 @@ impl Bash { Ok(entries.into_iter().map(|e| e.name.clone()).collect()) }) } + + // ======================================================================== + // Mount — real filesystem mounts at runtime + // ======================================================================== + + /// Mount a host directory into the VFS at runtime. + /// + /// `readOnly` defaults to true when omitted. + #[napi] + pub fn mount_real( + &self, + host_path: String, + vfs_path: String, + read_only: Option, + ) -> napi::Result<()> { + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + let ro = read_only.unwrap_or(true); + let mode = if ro { + bashkit::RealFsMode::ReadOnly + } else { + bashkit::RealFsMode::ReadWrite + }; + let real_backend = bashkit::RealFs::new(&host_path, mode) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + let fs: Arc = Arc::new(bashkit::PosixFs::new(real_backend)); + bash.mount(Path::new(&vfs_path), fs) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } + + /// Unmount a previously mounted filesystem. + #[napi] + pub fn unmount(&self, vfs_path: String) -> napi::Result<()> { + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + bash.unmount(Path::new(&vfs_path)) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } } // ============================================================================ @@ -592,39 +618,9 @@ impl BashTool { #[napi(constructor)] pub fn new(options: Option) -> napi::Result { let opts = options.unwrap_or_else(default_opts); - let py = opts.python.unwrap_or(false); - let ext_fns = opts.external_functions.clone().unwrap_or_default(); - - let bash = build_bash( - opts.username.as_deref(), - opts.hostname.as_deref(), - opts.max_commands, - opts.max_loop_iterations, - opts.files.as_ref(), - py, - &ext_fns, - None, - ); - let cancelled = bash.cancellation_token(); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?; - + let state = shared_state_from_opts(opts, None)?; Ok(Self { - state: Arc::new(SharedState { - inner: Mutex::new(bash), - rt: Mutex::new(rt), - cancelled, - username: opts.username, - hostname: opts.hostname, - max_commands: opts.max_commands, - max_loop_iterations: opts.max_loop_iterations, - python: py, - external_functions: ext_fns, - external_handler: None, - }), + state: Arc::new(state), }) } @@ -705,17 +701,7 @@ impl BashTool { pub fn reset(&self) -> napi::Result<()> { block_on_with(&self.state, |s| async move { let mut bash = s.inner.lock().await; - let new_bash = build_bash( - s.username.as_deref(), - s.hostname.as_deref(), - s.max_commands, - s.max_loop_iterations, - None, - s.python, - &s.external_functions, - s.external_handler.as_ref(), - ); - *bash = new_bash; + *bash = build_bash_from_state(&s, None); Ok(()) }) } @@ -852,6 +838,46 @@ impl BashTool { Ok(entries.into_iter().map(|e| e.name.clone()).collect()) }) } + + // ======================================================================== + // Mount — real filesystem mounts at runtime + // ======================================================================== + + /// Mount a host directory into the VFS at runtime. + /// + /// `readOnly` defaults to true when omitted. + #[napi] + pub fn mount_real( + &self, + host_path: String, + vfs_path: String, + read_only: Option, + ) -> napi::Result<()> { + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + let ro = read_only.unwrap_or(true); + let mode = if ro { + bashkit::RealFsMode::ReadOnly + } else { + bashkit::RealFsMode::ReadWrite + }; + let real_backend = bashkit::RealFs::new(&host_path, mode) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + let fs: Arc = Arc::new(bashkit::PosixFs::new(real_backend)); + bash.mount(Path::new(&vfs_path), fs) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } + + /// Unmount a previously mounted filesystem. + #[napi] + pub fn unmount(&self, vfs_path: String) -> napi::Result<()> { + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + bash.unmount(Path::new(&vfs_path)) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } } // ============================================================================ @@ -1142,34 +1168,59 @@ impl ScriptedTool { // Helpers // ============================================================================ -#[allow(clippy::too_many_arguments)] -fn build_bash( - username: Option<&str>, - hostname: Option<&str>, - max_commands: Option, - max_loop_iterations: Option, - files: Option<&HashMap>, - python: bool, - external_functions: &[String], - external_handler: Option<&ExternalHandlerArc>, -) -> RustBash { +/// Build `ExecutionLimits` from the limit fields stored in `SharedState`. +fn build_limits(state: &SharedState) -> ExecutionLimits { + let mut limits = ExecutionLimits::new(); + if let Some(v) = state.max_commands { + limits = limits.max_commands(v as usize); + } + if let Some(v) = state.max_loop_iterations { + limits = limits.max_loop_iterations(v as usize); + } + if let Some(v) = state.max_total_loop_iterations { + limits = limits.max_total_loop_iterations(v as usize); + } + if let Some(v) = state.max_function_depth { + limits = limits.max_function_depth(v as usize); + } + if let Some(v) = state.timeout_ms { + limits = limits.timeout(std::time::Duration::from_millis(v as u64)); + } + if let Some(v) = state.parser_timeout_ms { + limits = limits.parser_timeout(std::time::Duration::from_millis(v as u64)); + } + if let Some(v) = state.max_input_bytes { + limits = limits.max_input_bytes(v as usize); + } + if let Some(v) = state.max_ast_depth { + limits = limits.max_ast_depth(v as usize); + } + if let Some(v) = state.max_parser_operations { + limits = limits.max_parser_operations(v as usize); + } + if let Some(v) = state.max_stdout_bytes { + limits = limits.max_stdout_bytes(v as usize); + } + if let Some(v) = state.max_stderr_bytes { + limits = limits.max_stderr_bytes(v as usize); + } + if let Some(v) = state.capture_final_env { + limits = limits.capture_final_env(v); + } + limits +} + +fn build_bash_from_state(state: &SharedState, files: Option<&HashMap>) -> RustBash { let mut builder = RustBash::builder(); - if let Some(u) = username { + if let Some(ref u) = state.username { builder = builder.username(u); } - if let Some(h) = hostname { + if let Some(ref h) = state.hostname { builder = builder.hostname(h); } - let mut limits = ExecutionLimits::new(); - if let Some(mc) = max_commands { - limits = limits.max_commands(mc as usize); - } - if let Some(mli) = max_loop_iterations { - limits = limits.max_loop_iterations(mli as usize); - } - builder = builder.limits(limits); + builder = builder.limits(build_limits(state)); // Mount files into the virtual filesystem if let Some(files) = files { @@ -1178,11 +1229,24 @@ fn build_bash( } } + // Apply real filesystem mounts + if let Some(ref mounts) = state.mounts { + for m in mounts { + let read_only = m.read_only.unwrap_or(true); + builder = match (read_only, &m.vfs_path) { + (true, None) => builder.mount_real_readonly(&m.host_path), + (true, Some(vfs)) => builder.mount_real_readonly_at(&m.host_path, vfs), + (false, None) => builder.mount_real_readwrite(&m.host_path), + (false, Some(vfs)) => builder.mount_real_readwrite_at(&m.host_path, vfs), + }; + } + } + // Enable Python/Monty - if python { - if let Some(handler) = external_handler { + if state.python { + if let Some(ref handler) = state.external_handler { let h = handler.clone(); - let fn_names = external_functions.to_vec(); + let fn_names = state.external_functions.to_vec(); let python_handler: PythonExternalFnHandler = Arc::new(move |name, args, kwargs| { let h = h.clone(); Box::pin(async move { h(name, args, kwargs).await }) @@ -1200,6 +1264,78 @@ fn build_bash( builder.build() } +/// Build a `SharedState` from `BashOptions`, wiring up all config + interpreter. +fn shared_state_from_opts( + opts: BashOptions, + external_handler: Option, +) -> napi::Result { + let py = opts.python.unwrap_or(false); + let ext_fns = opts.external_functions.clone().unwrap_or_default(); + let mounts = opts.mounts.clone(); + + // Build a temporary SharedState to pass to build_bash_from_state + let tmp = SharedState { + inner: Mutex::new(RustBash::new()), + rt: Mutex::new( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?, + ), + cancelled: Arc::new(AtomicBool::new(false)), + username: opts.username.clone(), + hostname: opts.hostname.clone(), + max_commands: opts.max_commands, + max_loop_iterations: opts.max_loop_iterations, + max_total_loop_iterations: opts.max_total_loop_iterations, + max_function_depth: opts.max_function_depth, + timeout_ms: opts.timeout_ms, + parser_timeout_ms: opts.parser_timeout_ms, + max_input_bytes: opts.max_input_bytes, + max_ast_depth: opts.max_ast_depth, + max_parser_operations: opts.max_parser_operations, + max_stdout_bytes: opts.max_stdout_bytes, + max_stderr_bytes: opts.max_stderr_bytes, + capture_final_env: opts.capture_final_env, + mounts: mounts.clone(), + python: py, + external_functions: ext_fns.clone(), + external_handler: external_handler.clone(), + }; + + let bash = build_bash_from_state(&tmp, opts.files.as_ref()); + let cancelled = bash.cancellation_token(); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?; + + Ok(SharedState { + inner: Mutex::new(bash), + rt: Mutex::new(rt), + cancelled, + username: opts.username, + hostname: opts.hostname, + max_commands: opts.max_commands, + max_loop_iterations: opts.max_loop_iterations, + max_total_loop_iterations: opts.max_total_loop_iterations, + max_function_depth: opts.max_function_depth, + timeout_ms: opts.timeout_ms, + parser_timeout_ms: opts.parser_timeout_ms, + max_input_bytes: opts.max_input_bytes, + max_ast_depth: opts.max_ast_depth, + max_parser_operations: opts.max_parser_operations, + max_stdout_bytes: opts.max_stdout_bytes, + max_stderr_bytes: opts.max_stderr_bytes, + capture_final_env: opts.capture_final_env, + mounts, + python: py, + external_functions: ext_fns, + external_handler, + }) +} + /// Get the bashkit version string. #[napi] pub fn get_version() -> &'static str { diff --git a/crates/bashkit/tests/realfs_tests.rs b/crates/bashkit/tests/realfs_tests.rs index 6a6b8c85..089acabe 100644 --- a/crates/bashkit/tests/realfs_tests.rs +++ b/crates/bashkit/tests/realfs_tests.rs @@ -342,3 +342,65 @@ async fn realfs_symlink_relative_escape_blocked() { r.stdout ); } + +// --- Runtime mount/unmount (exercises Bash::mount / Bash::unmount) --- + +#[tokio::test] +async fn runtime_mount_readonly() { + use bashkit::{PosixFs, RealFs, RealFsMode}; + use std::sync::Arc; + + let dir = setup_host_dir(); + let mut bash = Bash::new(); + + let backend = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap(); + let fs: Arc = Arc::new(PosixFs::new(backend)); + bash.mount("/mnt/host", fs).unwrap(); + + let result = bash.exec("cat /mnt/host/hello.txt").await.unwrap(); + assert_eq!(result.stdout, "hello world\n"); +} + +#[tokio::test] +async fn runtime_unmount() { + use bashkit::{PosixFs, RealFs, RealFsMode}; + use std::sync::Arc; + + let dir = setup_host_dir(); + let mut bash = Bash::new(); + + let backend = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap(); + let fs: Arc = Arc::new(PosixFs::new(backend)); + bash.mount("/mnt/host", fs).unwrap(); + + let result = bash.exec("cat /mnt/host/hello.txt").await.unwrap(); + assert_eq!(result.exit_code, 0); + + bash.unmount("/mnt/host").unwrap(); + + let result = bash.exec("cat /mnt/host/hello.txt 2>&1").await.unwrap(); + assert_ne!( + result.exit_code, 0, + "file should not be accessible after unmount" + ); +} + +#[tokio::test] +async fn runtime_mount_readwrite() { + use bashkit::{PosixFs, RealFs, RealFsMode}; + use std::sync::Arc; + + let dir = setup_host_dir(); + let mut bash = Bash::new(); + + let backend = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap(); + let fs: Arc = Arc::new(PosixFs::new(backend)); + bash.mount("/workspace", fs).unwrap(); + + bash.exec("echo 'runtime write' > /workspace/runtime.txt") + .await + .unwrap(); + + let content = std::fs::read_to_string(dir.path().join("runtime.txt")).unwrap(); + assert_eq!(content, "runtime write\n"); +}