diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml
index f9e944e8f..4aa03241e 100644
--- a/.github/workflows/publish-python.yml
+++ b/.github/workflows/publish-python.yml
@@ -111,10 +111,52 @@ jobs:
name: pypi_files-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux || 'manylinux' }}
path: crates/bashkit-python/dist
+ # Reduced-feature Pyodide/Emscripten wheel (browser / JupyterLite).
+ # Separate job: distinct toolchain (nightly Rust + emsdk + pyodide-build) and a
+ # single Python version dictated by the pyodide-build ABI lockstep.
+ # See specs/emscripten-wheels.md.
+ build-emscripten:
+ name: Build wheel - emscripten (Pyodide)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ # Python 3.13 -> pyodide-build's modern config (Pyodide 0.29.x, Emscripten
+ # 4.0.9), whose binaryen understands modern LLVM's wasm target-features and
+ # whose runtime supports wasm exception handling. See specs/emscripten-wheels.md.
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+
+ # nightly satisfies our deps' MSRV (monty needs 1.95) + edition 2024;
+ # its LLVM matches Emscripten 4.0.9's binaryen. pyodide-build manages its
+ # own matching emsdk via the cross-build env (no setup-emsdk needed).
+ - name: Install nightly Rust with the Emscripten target
+ uses: dtolnay/rust-toolchain@nightly
+ with:
+ targets: wasm32-unknown-emscripten
+
+ - name: Install pyodide-build
+ run: pip install pyodide-build
+
+ - name: Install pyodide cross-build environment
+ run: pyodide xbuildenv install
+
+ - name: Build Pyodide wheel
+ working-directory: crates/bashkit-python
+ env:
+ RUSTUP_TOOLCHAIN: nightly
+ run: pyodide build --outdir dist
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: pypi_files-emscripten
+ path: crates/bashkit-python/dist
+
# Verify built artifacts
inspect:
name: Inspect artifacts
- needs: [build, build-sdist]
+ needs: [build, build-sdist, build-emscripten]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index 020c49906..fa4e69db6 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -181,11 +181,85 @@ jobs:
path: crates/bashkit-python/dist
retention-days: 5
+ # Build the reduced-feature Pyodide/Emscripten wheel and smoke-test it in a
+ # Pyodide venv. See specs/emscripten-wheels.md for the feature matrix and the
+ # pyodide-build <-> Emscripten <-> Python <-> Rust version lockstep.
+ wasm:
+ name: Build wheel (Pyodide/Emscripten)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ # Python 3.13 selects pyodide-build's modern config (Pyodide 0.29.x,
+ # Emscripten 4.0.9), whose binaryen understands the wasm target-features
+ # modern LLVM emits and whose runtime supports wasm exception handling.
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+
+ # Pyodide passes `-Z link-native-libraries=no` (nightly-only). The nightly
+ # must satisfy our deps' MSRV (monty needs rustc 1.95) and edition 2024,
+ # so it has to be recent; its LLVM (19+) matches Emscripten 4.0.9's
+ # binaryen, which is what avoids the wasm-opt target-feature skew that
+ # older Emscripten (3.1.x) hit. See specs/emscripten-wheels.md.
+ - name: Install nightly Rust with the Emscripten target
+ uses: dtolnay/rust-toolchain@nightly
+ with:
+ targets: wasm32-unknown-emscripten
+
+ - uses: Swatinem/rust-cache@v2
+
+ # pyodide-build manages its own matching emsdk via the cross-build env, so
+ # no separate setup-emsdk step is needed.
+ - name: Install pyodide-build
+ run: pip install pyodide-build
+
+ - name: Install pyodide cross-build environment
+ run: pyodide xbuildenv install
+
+ - name: Build Pyodide wheel
+ working-directory: crates/bashkit-python
+ env:
+ RUSTUP_TOOLCHAIN: nightly
+ run: pyodide build --outdir dist
+
+ - name: Smoke test in a Pyodide venv
+ working-directory: crates/bashkit-python
+ # Run the import test from a scratch dir: the crate's own `bashkit/`
+ # source package would otherwise shadow the installed extension module.
+ run: |
+ pyodide venv .venv-pyodide
+ .venv-pyodide/bin/pip install dist/*.whl
+ venv_python="$(pwd)/.venv-pyodide/bin/python"
+ cd "$(mktemp -d)"
+ "$venv_python" -c "
+ from bashkit import Bash
+ b = Bash(python=True)
+ r = b.execute_sync('echo hello && echo 1 | jq .')
+ print(r.stdout)
+ assert r.exit_code == 0, r
+ assert r.stdout == 'hello\n1\n', r.stdout
+ # Reduced-feature build: unavailable config must fail loudly.
+ try:
+ Bash(sqlite=True)
+ except RuntimeError:
+ pass
+ else:
+ raise AssertionError('expected sqlite=True to raise on wasm')
+ print('wasm smoke test OK')
+ "
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: python-wheel-emscripten
+ path: crates/bashkit-python/dist
+ retention-days: 5
+
# Gate job for branch protection
python-check:
name: Python Check
if: always()
- needs: [lint, test, examples, build-wheel]
+ needs: [lint, test, examples, build-wheel, wasm]
runs-on: ubuntu-latest
steps:
- name: Verify all jobs passed
@@ -193,7 +267,8 @@ jobs:
if [[ "${{ needs.lint.result }}" != "success" ]] || \
[[ "${{ needs.test.result }}" != "success" ]] || \
[[ "${{ needs.examples.result }}" != "success" ]] || \
- [[ "${{ needs.build-wheel.result }}" != "success" ]]; then
+ [[ "${{ needs.build-wheel.result }}" != "success" ]] || \
+ [[ "${{ needs.wasm.result }}" != "success" ]]; then
echo "One or more Python CI jobs failed"
exit 1
fi
diff --git a/.gitignore b/.gitignore
index 84f107485..6363ead63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,6 @@ node_modules/
# WASM build artifacts
*.wasm
+
+# Pyodide / Emscripten cross-build environment (downloaded by `pyodide xbuildenv`)
+.pyodide-xbuildenv/
diff --git a/AGENTS.md b/AGENTS.md
index 932fa4b7e..8f7454c99 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,6 +49,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn
| coreutils-args-port | Port uutils `uu_app()` clap definitions (args mode) and platform-clean uucore modules (module mode, manifest-driven) into bashkit via codegen |
| credential-injection | Transparent per-host credential injection for outbound HTTP requests, without exposing secrets to sandboxed scripts |
| performance-results | Benchmark/eval result locations and `/benches` site aggregation contract |
+| emscripten-wheels | Reduced-feature Pyodide/Emscripten (`wasm32-unknown-emscripten`) Python wheel: feature gating, toolchain, CI/publish |
### Documentation
diff --git a/crates/bashkit-python/Cargo.toml b/crates/bashkit-python/Cargo.toml
index 1dc9c83f8..a009d4b03 100644
--- a/crates/bashkit-python/Cargo.toml
+++ b/crates/bashkit-python/Cargo.toml
@@ -16,19 +16,34 @@ name = "bashkit"
doc = false # Python extension, no Rust docs needed
[dependencies]
-# Bashkit core
-# realfs: always-on so Python callers can use mount_real_* APIs
-bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs", "jq", "interop", "http_client", "sqlite"] }
-
# PyO3 native extension
pyo3 = { workspace = true }
-pyo3-async-runtimes = { workspace = true }
-
-# Async runtime
-tokio = { workspace = true, features = ["rt-multi-thread"] }
# Serialization
serde_json = { workspace = true }
# Big-integer support for py_to_monty BigInt extraction
num-bigint = "^0.4.6" # must be compatible with the version pulled in by bashkit core
+
+# Native (non-wasm) targets: full feature set.
+# realfs: always-on so Python callers can use mount_real_* APIs.
+# http_client/sqlite/interop pull mio/tokio-net/rt-multi-thread/tokio::fs,
+# none of which compile on wasm32-unknown-emscripten — see the wasm block
+# below and specs/emscripten-wheels.md.
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs", "jq", "interop", "http_client", "sqlite"] }
+tokio = { workspace = true, features = ["rt-multi-thread"] }
+# pyo3-async-runtimes hard-pulls tokio `rt-multi-thread` + `net` (mio), which
+# `compile_error!`s on wasm. Native-only; the wasm build exposes `execute_sync`
+# (current-thread runtime) and omits the async `execute()` coroutine bridge.
+pyo3-async-runtimes = { workspace = true }
+
+# wasm32 (Pyodide/Emscripten) target: reduced feature set.
+# Drops http_client, sqlite, realfs, interop — each requires threads, sockets,
+# or host filesystem access that Pyodide does not provide. Keeps the in-VFS
+# shell plus the embedded jq and Monty Python interpreters. tokio stays on its
+# wasm-safe base features (sync, macros, io-util, rt, time) — current-thread
+# runtime only. See specs/emscripten-wheels.md.
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "jq"] }
+tokio = { workspace = true }
diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs
index fb4e2e48c..f43384c42 100644
--- a/crates/bashkit-python/src/lib.rs
+++ b/crates/bashkit-python/src/lib.rs
@@ -5,18 +5,32 @@
//! `help`, `system_prompt`, JSON schemas) on top of the core interpreter.
//! Orchestration: `ScriptedTool` — composes Python callbacks as bash builtins.
+// The WebAssembly (Pyodide/Emscripten) build is a reduced-feature variant: the
+// async `execute()` family, network/credential config, host-FS mounts, the capsule
+// interop bridge, sqlite, and external_handler are all native-only (they need
+// threads, sockets, or host FS that Pyodide lacks — see specs/emscripten-wheels.md).
+// That leaves a handful of imports and helper functions referenced only from the
+// native-gated code paths; silence the resulting dead-code/unused-import lints on
+// wasm rather than scattering per-item cfgs through shared conversion helpers.
+#![cfg_attr(target_arch = "wasm32", allow(dead_code, unused_imports))]
+
+// interop (capsule FS handoff), realfs (host mounts) and the credential-injection
+// network config require threads / host FS / sockets that wasm32-unknown-emscripten
+// (Pyodide) does not provide — see specs/emscripten-wheels.md. They are native-only.
+#[cfg(not(target_arch = "wasm32"))]
use bashkit::interop::fs::{BashkitFsAbiOwnedHandleV1, export_filesystem, import_owned_filesystem};
use bashkit::tool::VERSION;
use bashkit::{
- Bash, BashTool as RustBashTool, Builtin, BuiltinContext, BuiltinRegistry, Credential,
+ Bash, BashTool as RustBashTool, Builtin, BuiltinContext, BuiltinRegistry,
DirEntry as FsDirEntry, ExcType, ExecResult as RustExecResult, ExecutionExtensions,
ExecutionLimits, ExtFunctionResult, FileSystem, FileSystemExt, FileType as FsFileType,
InMemoryFs, Metadata as FsMetadata, MontyException, MontyObject, NetworkAllowlist,
OutputCallback as RustOutputCallback, OverlayFs, PosixFs, PythonExternalFnHandler,
- PythonLimits, RealFs, RealFsMode, ScriptedTool as RustScriptedTool,
- ShellStateView as RustShellStateView, SnapshotOptions as RustSnapshotOptions, Tool, ToolArgs,
- ToolDef, ToolRequest, async_trait,
+ PythonLimits, ScriptedTool as RustScriptedTool, ShellStateView as RustShellStateView,
+ SnapshotOptions as RustSnapshotOptions, Tool, ToolArgs, ToolDef, ToolRequest, async_trait,
};
+#[cfg(not(target_arch = "wasm32"))]
+use bashkit::{Credential, RealFs, RealFsMode};
use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::sync::PyOnceLock;
@@ -24,8 +38,23 @@ use pyo3::types::{
PyBytes, PyCapsule, PyCapsuleMethods, PyDict, PyFloat, PyFrozenSet, PyInt, PyList, PyModule,
PySet, PyTuple,
};
+// pyo3-async-runtimes bridges Rust futures to a Python asyncio loop; it hard-pulls
+// multi-threaded tokio + mio sockets, which do not build on wasm. The async
+// `execute()` family and caller-loop callback scheduling are therefore native-only;
+// wasm exposes the blocking `execute_sync()` API. See specs/emscripten-wheels.md.
+#[cfg(not(target_arch = "wasm32"))]
use pyo3_async_runtimes::TaskLocals;
+#[cfg(not(target_arch = "wasm32"))]
use pyo3_async_runtimes::tokio::future_into_py;
+
+// `caller_loop_locals` is a captured asyncio-loop handle, only ever `Some` when an
+// async `execute()` ran (native). On wasm there is no such loop, so the field is
+// always `None`; aliasing the type to the uninhabited `Infallible` lets the shared
+// session structs compile while making the caller-loop branches statically dead.
+#[cfg(not(target_arch = "wasm32"))]
+type CallerLoopLocals = TaskLocals;
+#[cfg(target_arch = "wasm32")]
+type CallerLoopLocals = std::convert::Infallible;
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
@@ -195,6 +224,9 @@ enum PyCredentialSpec {
Headers { headers: Vec<(String, String)> },
}
+// `Credential` is a core type gated behind `http_client`; the conversion is
+// native-only (network injection is unavailable on wasm — see parse_network_config).
+#[cfg(not(target_arch = "wasm32"))]
impl PyCredentialSpec {
fn into_credential(self) -> Credential {
match self {
@@ -206,6 +238,7 @@ impl PyCredentialSpec {
}
impl PyNetworkConfig {
+ #[cfg(not(target_arch = "wasm32"))]
fn to_allowlist(&self) -> NetworkAllowlist {
let base = if self.allow_all {
NetworkAllowlist::allow_all()
@@ -215,6 +248,11 @@ impl PyNetworkConfig {
base.block_private_ips(self.block_private_ips)
}
+ // `network()`, `credential()` and `credential_placeholder()` live behind the
+ // core `http_client` feature, which is off on wasm (no sockets in Pyodide).
+ // On wasm the whole network config is inert — `parse_network_config` rejects
+ // it up front (see that fn), so this is a native-only pass-through.
+ #[cfg(not(target_arch = "wasm32"))]
fn apply(&self, mut builder: bashkit::BashBuilder) -> bashkit::BashBuilder {
builder = builder.network(self.to_allowlist());
for inj in &self.credentials {
@@ -237,74 +275,92 @@ impl PyNetworkConfig {
/// must specify either `allow` (a list of URL patterns) or `allow_all=True`,
/// not both. `block_private_ips` defaults to `True` to preserve the Rust
/// default. Unknown keys raise `ValueError` so typos surface immediately.
+// The Pyodide/Emscripten build has no outbound HTTP client (no sockets in the
+// browser sandbox), so accepting a network allowlist would silently do nothing.
+// Fail loudly instead. See specs/emscripten-wheels.md.
+#[cfg(target_arch = "wasm32")]
+fn parse_network_config(network: Option<&Bound<'_, PyDict>>) -> PyResult