From c42ee708e4af972f8b92e8f100118c0355ef3fb6 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 6 Apr 2026 00:03:41 +0000 Subject: [PATCH] feat(js): expose real filesystem mounts with per-mount readOnly support Add MountConfig napi object and mounts option to BashOptions for configuring real filesystem mounts at construction time. Add mountReal() and unmount() runtime methods to both Bash and BashTool classes. Mount configs are stored in SharedState and re-applied on reset(). Also fixes pre-existing broken code (missing shared_state_from_opts function and stale build_bash references in BashTool::reset). Closes #1066 --- crates/bashkit-js/Cargo.toml | 2 +- crates/bashkit-js/src/lib.rs | 416 ++++++++++++++++++--------- crates/bashkit/tests/realfs_tests.rs | 62 ++++ 3 files changed, 339 insertions(+), 141 deletions(-) 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"); +}