diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 4d3cc545c..e4add8b72 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -229,11 +229,48 @@ jobs: doppler run --only-secrets OPENAI_API_KEY -- ${{ matrix.run }} vercel_ai_tool.mjs doppler run --only-secrets OPENAI_API_KEY -- ${{ matrix.run }} langchain_agent.mjs + # Keeps the wasm32-wasip1-threads target from silently rotting between + # releases: publish-js.yml builds it with --release, so a PR that breaks + # the wasm feature gating would otherwise only surface at publish time. + # Debug profile here — same compile errors, much faster. + wasm-build: + name: WASM build (wasm32-wasip1-threads) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: wasm32-wasip1-threads + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + version: 10.33.0 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: crates/bashkit-js/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: crates/bashkit-js + + - name: Build wasm binding + run: pnpm exec napi build --platform --target wasm32-wasip1-threads + working-directory: crates/bashkit-js + # Gate job for branch protection js-check: name: JS Check if: always() - needs: [typecheck, build-and-test] + needs: [typecheck, build-and-test, wasm-build] runs-on: ubuntu-latest steps: - name: Verify all jobs passed @@ -246,3 +283,7 @@ jobs: echo "JS build/test failed" exit 1 fi + if [[ "${{ needs.wasm-build.result }}" != "success" ]]; then + echo "WASM build failed" + exit 1 + fi diff --git a/.github/workflows/publish-js.yml b/.github/workflows/publish-js.yml index 9233927dc..64d7b6c62 100644 --- a/.github/workflows/publish-js.yml +++ b/.github/workflows/publish-js.yml @@ -74,11 +74,13 @@ jobs: - host: windows-latest target: x86_64-pc-windows-msvc build: pnpm exec napi build --platform --release --target x86_64-pc-windows-msvc && pnpm run build:cjs && pnpm run build:ts - # TODO: WASM disabled — tokio "full" features (from bashkit core) are - # unsupported on wasm32. Needs architectural fix to gate tokio features. - # - host: ubuntu-latest - # target: wasm32-wasip1-threads - # build: pnpm exec napi build --platform --release --target wasm32-wasip1-threads && pnpm run build:cjs && pnpm run build:ts + # wasm32 builds bashkit with a reduced feature set (no interop/ + # sqlite/http_client/realfs — see crates/bashkit-js/Cargo.toml for + # the per-feature reasons) so tokio stays on the wasm-compatible + # current-thread runtime. + - host: ubuntu-latest + target: wasm32-wasip1-threads + build: pnpm exec napi build --platform --release --target wasm32-wasip1-threads && pnpm run build:cjs && pnpm run build:ts steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -375,8 +377,10 @@ jobs: sh -c "node examples/openai_tool.mjs && node examples/vercel_ai_tool.mjs && node examples/langchain_agent.mjs" # ============================================================================ - # Test WASI target — disabled until tokio feature gating is resolved - # TODO: Re-enable when bashkit core gates tokio "full" behind a feature flag + # Test WASI target — the tokio feature gating that blocked this is resolved + # (the wasm32-wasip1-threads matrix entry above builds), but running the + # test suite under the WASI runtime is unverified. TODO: re-enable after + # confirming the ava suite passes with NAPI_RS_FORCE_WASI=1. # ============================================================================ # test-js-wasi: # name: Test WASI target diff --git a/crates/bashkit-js/Cargo.toml b/crates/bashkit-js/Cargo.toml index 206adcd62..63fa16477 100644 --- a/crates/bashkit-js/Cargo.toml +++ b/crates/bashkit-js/Cargo.toml @@ -14,11 +14,27 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] -bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs", "jq", "interop", "http_client", "sqlite"] } napi = { workspace = true } napi-derive = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["sync", "macros", "io-util", "rt", "time"] } +# wasm drops four features present on every other target (see the matching +# `#[cfg(not(target_arch = "wasm32"))]` gates in src/lib.rs): +# - `interop`/`sqlite`: both force `tokio/rt-multi-thread` on, which pulls in +# mio -> socket2, and socket2 hard-fails to compile on any wasm target. +# - `http_client`: reqwest's TCP client needs `tokio::net` (real OS sockets), +# which WASI preview1 doesn't expose. reqwest's fetch-based wasm backend +# only targets wasm32-unknown-unknown (browser via web-sys), not +# wasm32-wasip1-threads, so there's no working backend to fall back to here. +# - `realfs`: mounts the *host* filesystem into the sandbox. Meaningless in a +# browser (no host filesystem to mount) and needs tokio/fs, which is only +# pulled in on non-wasm (see crates/bashkit/Cargo.toml). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs", "jq", "interop", "http_client", "sqlite"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "jq"] } + [build-dependencies] napi-build = { workspace = true } diff --git a/crates/bashkit-js/package.json b/crates/bashkit-js/package.json index 18ae736ed..4760fd155 100644 --- a/crates/bashkit-js/package.json +++ b/crates/bashkit-js/package.json @@ -46,8 +46,14 @@ "aarch64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", - "x86_64-pc-windows-msvc" - ] + "x86_64-pc-windows-msvc", + "wasm32-wasip1-threads" + ], + "wasm": { + "browser": { + "asyncInit": true + } + } }, "files": [ "wrapper.js", @@ -102,6 +108,8 @@ } }, "devDependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", "@langchain/core": "^1.1.39", "@napi-rs/cli": "^3.0.0", "@types/node": "^25.5.0", diff --git a/crates/bashkit-js/pnpm-lock.yaml b/crates/bashkit-js/pnpm-lock.yaml index 56a40939c..3628d4592 100644 --- a/crates/bashkit-js/pnpm-lock.yaml +++ b/crates/bashkit-js/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: .: devDependencies: + '@emnapi/core': + specifier: 1.10.0 + version: 1.10.0 + '@emnapi/runtime': + specifier: 1.10.0 + version: 1.10.0 '@langchain/core': specifier: ^1.1.39 version: 1.1.48 @@ -1612,17 +1618,14 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 - optional: true '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 - optional: true '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 - optional: true '@esbuild/aix-ppc64@0.28.1': optional: true @@ -2858,8 +2861,7 @@ snapshots: tr46@0.0.3: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.22.3: dependencies: diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 4a6a4ed9e..3d77f4a02 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -13,26 +13,43 @@ //! work. This prevents CodeQL `rust/access-invalid-pointer` alerts caused by //! holding a raw-pointer-derived `&self` across `block_on` or `.await` points. +// interop::fs (native FS-handle export/import) and the sqlite builder need +// tokio/rt-multi-thread, which mio/socket2 don't support on any wasm target. +// bashkit-js only requests both bashkit features on non-wasm (see Cargo.toml); +// gate their use here to match. +#[cfg(not(target_arch = "wasm32"))] use bashkit::interop::fs::{ BashkitFsAbiHandleV1, BashkitFsAbiOwnedHandleV1, export_filesystem, import_filesystem, }; use bashkit::tool::VERSION; +// `realfs` mounts the *host* filesystem into the sandbox — meaningless in a +// browser (no host filesystem) and needs tokio/fs, which isn't available on +// wasm (see Cargo.toml). PosixFs itself isn't feature-gated, but every use +// of it here is via a RealFs backend, so it moves with the other two. +#[cfg(not(target_arch = "wasm32"))] +use bashkit::NetworkAllowlist; use bashkit::{ Bash as RustBash, BashTool as RustBashTool, Builtin, BuiltinContext, BuiltinRegistry, - Credential, ExecResult as RustExecResult, ExecutionLimits, ExtFunctionResult, - FileSystem as BashFileSystem, FileType, InMemoryFs, Metadata, MontyObject, NetworkAllowlist, - OutputCallback, PosixFs, PythonExternalFnHandler, PythonLimits, RealFs, RealFsMode, - ScriptedTool as RustScriptedTool, SnapshotOptions as RustSnapshotOptions, Tool, ToolArgs, - ToolDef, ToolRequest, async_trait, + ExecResult as RustExecResult, ExecutionLimits, ExtFunctionResult, FileSystem as BashFileSystem, + FileType, InMemoryFs, Metadata, MontyObject, OutputCallback, PythonExternalFnHandler, + PythonLimits, ScriptedTool as RustScriptedTool, SnapshotOptions as RustSnapshotOptions, Tool, + ToolArgs, ToolDef, ToolRequest, async_trait, }; +#[cfg(not(target_arch = "wasm32"))] +use bashkit::{Credential, PosixFs, RealFs, RealFsMode}; use napi::bindgen_prelude::External; -use napi::{Env, JsValue, Unknown, ValueType, sys}; +#[cfg(not(target_arch = "wasm32"))] +use napi::{Env, ValueType, sys}; +use napi::{JsValue, Unknown}; use napi_derive::napi; use std::collections::HashMap; +#[cfg(not(target_arch = "wasm32"))] use std::ffi::c_void; +#[cfg(not(target_arch = "wasm32"))] use std::mem::{MaybeUninit, size_of}; use std::path::{Path, PathBuf}; use std::pin::Pin; +#[cfg(not(target_arch = "wasm32"))] use std::ptr; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; @@ -57,11 +74,18 @@ fn callback_runtime() -> &'static tokio::runtime::Runtime { use std::sync::OnceLock; static RT: OnceLock = OnceLock::new(); RT.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() + // rt-multi-thread isn't available on wasm (see Cargo.toml); fall + // back to a current-thread runtime there. + #[cfg(not(target_arch = "wasm32"))] + let builder_result = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() - .build() - .expect("failed to create shared callback runtime") + .build(); + #[cfg(target_arch = "wasm32")] + let builder_result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + builder_result.expect("failed to create shared callback runtime") }) } @@ -319,11 +343,15 @@ impl NativeFileSystemState { } } + // Only used by to_external (native-only: the fs-handle ABI export has no + // wasm consumer). + #[cfg(not(target_arch = "wasm32"))] fn export_fs(&self) -> napi::Result> { self.with_fs(|fs| async move { Ok(fs) }) } } +#[cfg(not(target_arch = "wasm32"))] unsafe extern "C" fn finalize_owned_file_system_handle( _env: sys::napi_env, data: *mut c_void, @@ -336,6 +364,7 @@ unsafe extern "C" fn finalize_owned_file_system_handle( } } +#[cfg(not(target_arch = "wasm32"))] fn napi_status_result(status: sys::napi_status, action: &str) -> napi::Result<()> { if status == sys::Status::napi_ok { return Ok(()); @@ -345,6 +374,7 @@ fn napi_status_result(status: sys::napi_status, action: &str) -> napi::Result<() ))) } +#[cfg(not(target_arch = "wasm32"))] fn create_file_system_external( env: &Env, handle: BashkitFsAbiOwnedHandleV1, @@ -369,6 +399,7 @@ fn create_file_system_external( Ok(unsafe { Unknown::from_raw_unchecked(env.raw(), raw_external) }) } +#[cfg(not(target_arch = "wasm32"))] fn file_system_external_handle(external: Unknown<'_>) -> napi::Result { if external.get_type()? != ValueType::External { return Err(napi::Error::from_reason( @@ -395,6 +426,7 @@ fn file_system_external_handle(external: Unknown<'_>) -> napi::Result) -> napi::Result> { let handle = file_system_external_handle(external)?; // SAFETY: `external` was created by export_external_file_system; the @@ -403,6 +435,7 @@ fn import_external_file_system(external: Unknown<'_>) -> napi::Result, @@ -431,11 +464,13 @@ impl NativeFileSystemState { Ok(Self::from_static(fs)) } + #[cfg(not(target_arch = "wasm32"))] fn import_external(external: Unknown<'_>) -> napi::Result { let fs = import_external_file_system(external)?; Ok(Self::from_static(fs)) } + #[cfg(not(target_arch = "wasm32"))] fn to_external(&self, env: Env) -> napi::Result> { let fs = self.export_fs()?; let handle = export_filesystem(fs).map_err(|e| napi::Error::from_reason(e.to_string()))?; @@ -573,6 +608,7 @@ pub fn create_file_system() -> External { External::new(NativeFileSystemState::new()) } +#[cfg(not(target_arch = "wasm32"))] #[napi(js_name = "__realFileSystem")] pub fn real_file_system( host_path: String, @@ -586,6 +622,7 @@ pub fn real_file_system( )?)) } +#[cfg(not(target_arch = "wasm32"))] #[napi(js_name = "__importFileSystem")] pub fn import_file_system(external: Unknown<'_>) -> napi::Result> { Ok(External::new(NativeFileSystemState::import_external( @@ -593,6 +630,7 @@ pub fn import_file_system(external: Unknown<'_>) -> napi::Result, @@ -1110,6 +1148,7 @@ pub struct NetworkOptions { /// Build a core [`Credential`] from the kind-discriminated option fields. /// Mirrors the Python binding's `parse_credential_spec` validation rules. +#[cfg(not(target_arch = "wasm32"))] fn credential_from_parts( label: &str, kind: &str, @@ -1185,6 +1224,7 @@ fn credential_from_parts( /// Validate `NetworkOptions` up front so `build_bash_from_state` (also used /// by `reset()`) can apply them infallibly later. +#[cfg(not(target_arch = "wasm32"))] fn validate_network_options(net: &NetworkOptions) -> napi::Result<()> { let allow_all = net.allow_all.unwrap_or(false); if allow_all && net.allow.is_some() { @@ -1228,6 +1268,7 @@ fn validate_network_options(net: &NetworkOptions) -> napi::Result<()> { /// Apply pre-validated network options to the builder. Panics are impossible /// for inputs that passed [`validate_network_options`]; on the (unreachable) /// invalid path the credential is skipped. +#[cfg(not(target_arch = "wasm32"))] fn apply_network_options( mut builder: bashkit::BashBuilder, net: &NetworkOptions, @@ -1421,6 +1462,9 @@ struct SharedState { max_memory: Option, capture_final_env: Option, mounts: Option>, + // Only read by the native arms of the body-branched mount methods; kept + // unconditional so SharedState construction stays cfg-free. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] allowed_mount_paths: Option>, python: bool, sqlite: bool, @@ -2031,6 +2075,11 @@ impl Bash { /// **Security**: Writable mounts log a warning. Consider using /// `allowedMountPaths` in `BashOptions` to restrict which host paths /// may be mounted. + // Item-level `#[cfg]` on methods inside a `#[napi] impl` block doesn't + // reliably work: napi-rs's macro sees the raw (un-cfg-stripped) tokens + // of the whole block and emits a callback-trampoline reference for every + // method it finds, so a cfg'd-out method leaves a dangling reference. + // Branch inside the body instead, keeping one `#[napi]` item per method. #[napi] pub fn mount( &self, @@ -2038,42 +2087,62 @@ impl Bash { vfs_path: String, writable: Option, ) -> napi::Result<()> { - let is_writable = writable.unwrap_or(false); - if is_writable { - eprintln!( - "bashkit: warning: writable mount at {} — scripts can modify host files", - host_path - ); + #[cfg(target_arch = "wasm32")] + { + let _ = (host_path, vfs_path, writable); + return Err(napi::Error::from_reason( + "Bash.mount is not supported in the wasm/browser build: there is no host filesystem to mount", + )); + } + #[cfg(not(target_arch = "wasm32"))] + { + let is_writable = writable.unwrap_or(false); + if is_writable { + eprintln!( + "bashkit: warning: writable mount at {} — scripts can modify host files", + host_path + ); + } + enforce_mount_policy( + self.state.allowed_mount_paths.as_deref(), + &host_path, + "Bash.mount", + )?; + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + let mode = if is_writable { + RealFsMode::ReadWrite + } else { + RealFsMode::ReadOnly + }; + let real_backend = RealFs::new(&host_path, mode) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + let fs: Arc = Arc::new(PosixFs::new(real_backend)); + bash.mount(Path::new(&vfs_path), fs) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) } - enforce_mount_policy( - self.state.allowed_mount_paths.as_deref(), - &host_path, - "Bash.mount", - )?; - block_on_with(&self.state, |s| async move { - let bash = s.inner.lock().await; - let mode = if is_writable { - RealFsMode::ReadWrite - } else { - RealFsMode::ReadOnly - }; - let real_backend = RealFs::new(&host_path, mode) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - let fs: Arc = Arc::new(PosixFs::new(real_backend)); - bash.mount(Path::new(&vfs_path), fs) - .map_err(|e| napi::Error::from_reason(e.to_string())) - }) } /// Mount a filesystem handle without rebuilding the interpreter. #[napi] pub fn mount_file_system(&self, vfs_path: String, fs: Unknown<'_>) -> napi::Result<()> { - let mounted_fs = import_external_file_system(fs)?; - block_on_with(&self.state, |s| async move { - let bash = s.inner.lock().await; - bash.mount(Path::new(&vfs_path), mounted_fs) - .map_err(|e| napi::Error::from_reason(e.to_string())) - }) + #[cfg(target_arch = "wasm32")] + { + let _ = (vfs_path, fs); + return Err(napi::Error::from_reason( + "Bash.mountFileSystem is not supported in the wasm/browser build", + )); + } + #[cfg(not(target_arch = "wasm32"))] + { + let mounted_fs = import_external_file_system(fs)?; + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + bash.mount(Path::new(&vfs_path), mounted_fs) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } } /// Unmount a previously mounted filesystem. @@ -2663,42 +2732,62 @@ impl BashTool { vfs_path: String, writable: Option, ) -> napi::Result<()> { - let is_writable = writable.unwrap_or(false); - if is_writable { - eprintln!( - "bashkit: warning: writable mount at {} — scripts can modify host files", - host_path - ); + #[cfg(target_arch = "wasm32")] + { + let _ = (host_path, vfs_path, writable); + return Err(napi::Error::from_reason( + "BashTool.mount is not supported in the wasm/browser build: there is no host filesystem to mount", + )); + } + #[cfg(not(target_arch = "wasm32"))] + { + let is_writable = writable.unwrap_or(false); + if is_writable { + eprintln!( + "bashkit: warning: writable mount at {} — scripts can modify host files", + host_path + ); + } + enforce_mount_policy( + self.state.allowed_mount_paths.as_deref(), + &host_path, + "BashTool.mount", + )?; + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + let mode = if is_writable { + RealFsMode::ReadWrite + } else { + RealFsMode::ReadOnly + }; + let real_backend = RealFs::new(&host_path, mode) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + let fs: Arc = Arc::new(PosixFs::new(real_backend)); + bash.mount(Path::new(&vfs_path), fs) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) } - enforce_mount_policy( - self.state.allowed_mount_paths.as_deref(), - &host_path, - "BashTool.mount", - )?; - block_on_with(&self.state, |s| async move { - let bash = s.inner.lock().await; - let mode = if is_writable { - RealFsMode::ReadWrite - } else { - RealFsMode::ReadOnly - }; - let real_backend = RealFs::new(&host_path, mode) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - let fs: Arc = Arc::new(PosixFs::new(real_backend)); - bash.mount(Path::new(&vfs_path), fs) - .map_err(|e| napi::Error::from_reason(e.to_string())) - }) } /// Mount a filesystem handle without rebuilding the interpreter. #[napi] pub fn mount_file_system(&self, vfs_path: String, fs: Unknown<'_>) -> napi::Result<()> { - let mounted_fs = import_external_file_system(fs)?; - block_on_with(&self.state, |s| async move { - let bash = s.inner.lock().await; - bash.mount(Path::new(&vfs_path), mounted_fs) - .map_err(|e| napi::Error::from_reason(e.to_string())) - }) + #[cfg(target_arch = "wasm32")] + { + let _ = (vfs_path, fs); + return Err(napi::Error::from_reason( + "BashTool.mountFileSystem is not supported in the wasm/browser build", + )); + } + #[cfg(not(target_arch = "wasm32"))] + { + let mounted_fs = import_external_file_system(fs)?; + block_on_with(&self.state, |s| async move { + let bash = s.inner.lock().await; + bash.mount(Path::new(&vfs_path), mounted_fs) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } } /// Unmount a previously mounted filesystem. @@ -3168,6 +3257,7 @@ fn build_limits(state: &SharedState) -> ExecutionLimits { limits } +#[cfg(not(target_arch = "wasm32"))] fn derive_sqlite_limits(state: &SharedState) -> bashkit::SqliteLimits { let mut limits = bashkit::SqliteLimits::default(); if let Some(ms) = state.timeout_ms { @@ -3213,22 +3303,33 @@ fn build_bash_from_state(state: &SharedState, files: Option<&HashMap builder.mount_real_readonly(&m.host_path), - (false, Some(vfs)) => builder.mount_real_readonly_at(&m.host_path, vfs), - (true, None) => builder.mount_real_readwrite(&m.host_path), - (true, Some(vfs)) => builder.mount_real_readwrite_at(&m.host_path, vfs), - }; + if let Some(ref mounts) = state.mounts { + for m in mounts { + let writable = m.writable.unwrap_or(false); + builder = match (writable, &m.vfs_path) { + (false, None) => builder.mount_real_readonly(&m.host_path), + (false, Some(vfs)) => builder.mount_real_readonly_at(&m.host_path, vfs), + (true, None) => builder.mount_real_readwrite(&m.host_path), + (true, Some(vfs)) => builder.mount_real_readwrite_at(&m.host_path, vfs), + }; + } } } + #[cfg(target_arch = "wasm32")] + if state.mounts.is_some() { + eprintln!( + "bashkit: warning: real filesystem mounts are not supported in the wasm/browser build; ignoring" + ); + } // Enable Python/Monty. Passing `python: true` from JS is the explicit // opt-in that must also flip the in-process Python env gate. @@ -3253,16 +3354,32 @@ fn build_bash_from_state(state: &SharedState, files: Option<&HashMap