diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22f5b4b89..757e7d465 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,33 @@ jobs: # getrandom needs an explicit JS backend opt-in on this target. RUSTFLAGS: --cfg getrandom_backend="wasm_js" -D warnings + wasm-web: + # Build the browser package (@everruns/bashkit-web) and run its headless + # integration tests, proving the single-threaded bundle loads with no + # COOP/COEP headers and that sync + async execution both work. See + # specs/browser-package.md. Keep wasm-bindgen-cli in lockstep with the + # wasm-bindgen version in Cargo.lock. + name: WASM web package + 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-unknown-unknown + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Install wasm-bindgen-cli + run: cargo install wasm-bindgen-cli --version 0.2.126 --locked + + - name: Build web bundle + run: bash crates/bashkit-wasm/scripts/build.sh release + + - name: Integration tests (headless Node) + run: node --test crates/bashkit-wasm/__test__/bashkit-web.test.mjs + audit: name: Audit runs-on: ubuntu-latest diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml new file mode 100644 index 000000000..f3ae4bbee --- /dev/null +++ b/.github/workflows/publish-web.yml @@ -0,0 +1,139 @@ +# NPM publishing workflow for the browser package @everruns/bashkit-web. +# Builds a single-threaded wasm32-unknown-unknown bundle via wasm-bindgen and +# publishes to npm. Triggered alongside publish.yml on GitHub Release. +# +# Prerequisites: +# - NPM_TOKEN secret with publish access +# - id-token: write permission for npm provenance (OIDC attestation) +# +# Keep WASM_BINDGEN_VERSION in lockstep with the wasm-bindgen version in +# Cargo.lock — a CLI/crate mismatch fails the bindgen step. + +name: Publish Web + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + WASM_BINDGEN_VERSION: "0.2.126" + +jobs: + verify-source: + name: Verify publish source + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + - name: Verify publish source is reachable from main + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + SOURCE_SHA=$(git rev-parse HEAD) + if ! git merge-base --is-ancestor "$SOURCE_SHA" origin/main; then + echo "Error: publish source $SOURCE_SHA is not reachable from origin/main" + exit 1 + fi + echo "Publish source verified on origin/main: $SOURCE_SHA" + + build-web: + name: Build web bundle + needs: verify-source + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Install wasm-bindgen-cli + run: cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + + - name: Install binaryen (wasm-opt) + run: sudo apt-get update && sudo apt-get install -y binaryen + + - name: Build wasm bundle + run: bash crates/bashkit-wasm/scripts/build.sh release + + - name: Integration tests (headless Node) + run: node --test crates/bashkit-wasm/__test__/bashkit-web.test.mjs + + - name: Upload pkg + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: web-pkg + path: crates/bashkit-wasm/pkg + if-no-files-found: error + + publish-web: + name: Publish to npm + needs: build-web + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm provenance + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Verify publish source is reachable from main + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + SOURCE_SHA=$(git rev-parse HEAD) + if ! git merge-base --is-ancestor "$SOURCE_SHA" origin/main; then + echo "Error: publish source $SOURCE_SHA is not reachable from origin/main" + exit 1 + fi + + - name: Setup node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + + - name: Download pkg + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: web-pkg + path: pkg + + - name: Determine npm dist-tag + id: dist-tag + working-directory: pkg + run: | + PKG_VERSION=$(node -p "require('./package.json').version") + echo "version=$PKG_VERSION" + # Only verified release tags publish as npm latest. + if [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" + exit 1 + fi + echo "tag=latest" >> "$GITHUB_OUTPUT" + else + echo "tag=next" >> "$GITHUB_OUTPUT" + fi + + - name: Publish + working-directory: pkg + run: | + TAG="${{ steps.dist-tag.outputs.tag }}" + echo "Publishing @everruns/bashkit-web with dist-tag: $TAG" + if [ "$TAG" = "latest" ]; then + npm publish --provenance --access public + else + npm publish --provenance --tag "$TAG" --access public + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 4480562f8..033c4117b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn | http-transport | Pluggable HTTP transport: route curl/wget via host egress boundary | | performance-results | Benchmark/eval result locations and `/benches` site aggregation contract | | emscripten-wheels | Reduced-feature Pyodide/Emscripten Python wheel | +| browser-package | Slim single-threaded browser wasm package (`@everruns/bashkit-web`), no COOP/COEP | ### Documentation diff --git a/Cargo.lock b/Cargo.lock index a5ba0df68..80a6f00b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,6 +510,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "bashkit-wasm" +version = "0.13.0" +dependencies = [ + "bashkit", + "console_error_panic_hook", + "futures-util", + "getrandom 0.4.3", + "js-sys", + "send_wrapper", + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "bcrypt-pbkdf" version = "0.11.0" @@ -952,6 +969,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -4683,6 +4710,15 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + [[package]] name = "seq-macro" version = "0.3.6" @@ -4699,6 +4735,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5219,7 +5266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/crates/bashkit-wasm/.gitignore b/crates/bashkit-wasm/.gitignore new file mode 100644 index 000000000..2ce9cffde --- /dev/null +++ b/crates/bashkit-wasm/.gitignore @@ -0,0 +1,6 @@ +# wasm-bindgen build output (generated by scripts/build.sh) +/pkg/ + +# Node +node_modules/ +*.tgz diff --git a/crates/bashkit-wasm/Cargo.toml b/crates/bashkit-wasm/Cargo.toml new file mode 100644 index 000000000..28a5c8db3 --- /dev/null +++ b/crates/bashkit-wasm/Cargo.toml @@ -0,0 +1,52 @@ +# Bashkit browser bindings via wasm-bindgen. +# +# Important decision: this is a deliberately *slim*, single-threaded browser +# build, separate from the napi `bashkit-js` crate. It targets +# `wasm32-unknown-unknown` (pure wasm-bindgen, no WASI, no threads), so it needs +# NO `SharedArrayBuffer` and NO cross-origin isolation (COOP/COEP) headers — +# unlike the wasm32-wasip1-threads example. That makes it drop-in for any web +# app, including embedded/iframe contexts. See specs/browser-package.md. +# +# Feature surface mirrors the `wasm` CI job (scripted_tool + jq). http_client, +# ssh, sqlite, python, realfs, and interop are intentionally excluded: they need +# sockets, threads, or a host filesystem that the browser sandbox lacks. +# +# JS callbacks (js_sys::Function / JsFuture) are `!Send`, but bashkit's `Builtin` +# trait is `Send + Sync` (async_trait). wasm32 is single-threaded, so we bridge +# with `send_wrapper::SendWrapper`, which is sound here: it only ever touches the +# wrapped value on its origin thread, and there is only one thread. + +[package] +name = "bashkit-wasm" +description = "Browser (WebAssembly) bindings for the Bashkit sandboxed bash interpreter" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +bashkit = { path = "../bashkit", default-features = false, features = [ + "scripted_tool", + "jq", +] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +serde = { workspace = true } +serde_json = { workspace = true } +serde-wasm-bindgen = "0.6" +futures-util = { workspace = true } +send_wrapper = { version = "0.6", features = ["futures"] } +console_error_panic_hook = "0.1" +# getrandom's browser backend (matches the workspace pin). Enables `wasm_js` +# so the credential-placeholder RNG links on wasm32-unknown-unknown. +getrandom = { workspace = true } + +[package.metadata.wasm-pack.profile.release] +# Keep the shipped .wasm small; the interpreter is large. +wasm-opt = ["-Oz"] diff --git a/crates/bashkit-wasm/README.md b/crates/bashkit-wasm/README.md new file mode 100644 index 000000000..552c3a543 --- /dev/null +++ b/crates/bashkit-wasm/README.md @@ -0,0 +1,163 @@ +# @everruns/bashkit-web + +Sandboxed bash interpreter for the **browser**, compiled to WebAssembly. + +Unlike a WASI-threads build, this package is **single-threaded**: it needs no +`SharedArrayBuffer` and **no cross-origin isolation** (`COOP`/`COEP`) headers. +That makes it a drop-in for any web app — including embedded and third-party +iframe contexts where those headers can't be set. + +For Node.js / Bun / Deno, use the native package +[`@everruns/bashkit`](https://www.npmjs.com/package/@everruns/bashkit) instead. + +## Install + +```bash +npm install @everruns/bashkit-web +``` + +## Quick start + +```js +import { initBashkit, Bash } from "@everruns/bashkit-web"; + +// Load the .wasm once before constructing Bash. +await initBashkit(); + +const bash = new Bash(); +const result = bash.executeSync('echo "Hello, browser!" | tr a-z A-Z'); +console.log(result.stdout); // HELLO, BROWSER! +``` + +### Plain ` +``` + +## Async custom builtins + +Register JS callbacks as bash commands. Async callbacks (e.g. issuing a +`fetch` / GraphQL request) are awaited by `execute()` — the async API: + +```js +const bash = new Bash({ + customBuiltins: { + graphql: async (ctx) => { + const res = await fetch("/graphql", { + method: "POST", + headers: { "content-type": "application/json" }, + body: ctx.stdin ?? "{}", + }); + return await res.text(); + }, + }, +}); + +const out = await bash.execute('echo "{ me { id } }" | graphql | jq .data'); +console.log(out.stdout); +``` + +`ctx` is `{ name, argv, stdin, env, cwd, fs }`. Return the builtin's stdout as a +string (or a `Promise`); throwing becomes stderr with exit code 1. + +`ctx.fs` is a live handle to the **same** virtual filesystem the script sees, so +a builtin can read inputs and write outputs that later commands pick up: + +```js +const bash = new Bash({ + customBuiltins: { + "uppercase-file": (ctx) => { + const text = ctx.fs.readFile(ctx.argv[0]); + ctx.fs.writeFile("/out.txt", text.toUpperCase()); + return "done\n"; + }, + }, +}); +bash.writeFile("/in.txt", "hello\n"); +await bash.execute("uppercase-file /in.txt && cat /out.txt"); // -> HELLO +``` + +`ctx.fs` has `readFile`, `writeFile`, `appendFile`, `exists`, `mkdir`, `remove`, +and `ls` — the same surface as the `Bash` VFS helpers below. + +## Sync vs async + +- `executeSync(cmd)` — for plain bash and `jq`. Fast, returns an `ExecResult` + directly. Throws if the script suspends (async builtin, `sleep`, background + job). +- `execute(cmd)` — returns `Promise`. Required whenever an async + custom builtin may run. + +## Options + +```ts +new Bash({ + username, hostname, cwd, + env: { KEY: "value" }, + maxCommands, maxLoopIterations, maxMemory, + files: { "/config.json": '{"debug":true}' }, + customBuiltins: { name: (ctx) => "..." }, +}); +``` + +## Virtual filesystem + +Files created via the helpers are visible to scripts and vice versa: + +```js +bash.mkdir("/data"); +bash.writeFile("/data/x.txt", "hi\n"); +bash.appendFile("/data/x.txt", "there\n"); +bash.readFile("/data/x.txt"); // "hi\nthere\n" +bash.exists("/data/x.txt"); // true +bash.ls("/data"); // ["x.txt"] +bash.executeSync("cat /data/x.txt").stdout; // "hi\nthere\n" +bash.remove("/data/x.txt"); + +// bash.fs() returns the same live handle passed to builtins as ctx.fs +const fs = bash.fs(); +``` + +## What's included + +Plain bash plus the built-in text tooling (`grep`, `sed`, `awk`, `jq`, `find`, +…) and `jq`. Not included in the browser build: outbound HTTP (`curl`/`wget`), +`ssh`, `sqlite`, and embedded `python` — these need sockets, threads, or a host +filesystem the browser sandbox doesn't provide. Bridge to the network through a +custom builtin (see above) so requests go through your app's own `fetch`. + +## Limitations + +- **No wall-clock timeout.** `wasm32-unknown-unknown` has no reliable timer + driver, so `timeoutMs` is not enforced. Runaway scripts are instead bounded by + `maxCommands`, `maxLoopIterations`, and the parser fuel budget — a + `while true` loop throws a resource-limit error rather than hanging. +- **`executeSync` can't run async builtins.** The single-threaded event loop + can't settle a `Promise` without yielding; an async builtin under + `executeSync` fails fast with a clear message. Use `execute()`. + +## Examples + +Runnable browser demos live in [`example/`](./example) — an interactive terminal +and an async-builtin/`ctx.fs` demo, both served by any static file server with no +special headers. See [`example/README.md`](./example/README.md). + +## Development + +```bash +# Build the bundle and run the headless integration tests: +bash scripts/build.sh +node --test __test__/bashkit-web.test.mjs +# or, from the repo root: +just build-web +``` + +## License + +MIT — part of the [Bashkit](https://github.com/everruns/bashkit) project. diff --git a/crates/bashkit-wasm/__test__/bashkit-web.test.mjs b/crates/bashkit-wasm/__test__/bashkit-web.test.mjs new file mode 100644 index 000000000..f09955932 --- /dev/null +++ b/crates/bashkit-wasm/__test__/bashkit-web.test.mjs @@ -0,0 +1,240 @@ +// Integration tests for the browser wasm bundle (@everruns/bashkit-web). +// +// Run against the real built package (crates/bashkit-wasm/pkg, produced by +// scripts/build.sh) under Node's built-in test runner: +// +// node --test crates/bashkit-wasm/__test__/ +// +// Feeding the .wasm bytes straight to init (no fetch, no DOM, no COOP/COEP +// headers) exercises the same "no host configuration required" contract the +// browser relies on. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { initBashkit, Bash } from "../pkg/index.js"; + +// Init once for the whole suite. +const wasmBytes = readFileSync( + fileURLToPath(new URL("../pkg/bashkit_wasm_bg.wasm", import.meta.url)), +); +await initBashkit(wasmBytes); + +// --- Sync execution -------------------------------------------------------- + +test("sync: pipes and text tools", () => { + const bash = new Bash(); + const r = bash.executeSync('echo "hello world" | tr a-z A-Z'); + assert.equal(r.stdout, "HELLO WORLD\n"); + assert.equal(r.exitCode, 0); + assert.equal(r.success, true); +}); + +test("sync: jq is available", () => { + const bash = new Bash(); + const r = bash.executeSync(`echo '{"a":1,"b":2}' | jq -c '.a + .b'`); + assert.equal(r.stdout.trim(), "3"); +}); + +test("sync: multi-line script with loop and variables", () => { + const bash = new Bash(); + const r = bash.executeSync(` + total=0 + for n in 1 2 3 4; do total=$((total + n)); done + echo "sum=$total" + `); + assert.equal(r.stdout.trim(), "sum=10"); +}); + +test("sync: non-zero exit surfaces", () => { + const bash = new Bash(); + const r = bash.executeSync("false"); + assert.equal(r.exitCode, 1); + assert.equal(r.success, false); +}); + +test("sync: stderr is captured", () => { + const bash = new Bash(); + const r = bash.executeSync("echo oops >&2"); + assert.equal(r.stderr, "oops\n"); +}); + +// --- Options --------------------------------------------------------------- + +test("options: env and cwd", () => { + const bash = new Bash({ env: { GREETING: "hi" }, cwd: "/tmp" }); + const r = bash.executeSync('echo "$GREETING from $(pwd)"'); + assert.equal(r.stdout, "hi from /tmp\n"); +}); + +test("options: username and hostname", () => { + const bash = new Bash({ username: "agent", hostname: "sandbox" }); + assert.equal(bash.executeSync("whoami").stdout.trim(), "agent"); + assert.equal(bash.executeSync("hostname").stdout.trim(), "sandbox"); +}); + +test("options: maxLoopIterations bounds runaway loops", () => { + const bash = new Bash({ maxLoopIterations: 100 }); + // The loop is capped, so this throws a resource-limit error rather than + // hanging — the browser build's answer to the absence of a wall-clock timeout. + assert.throws( + () => bash.executeSync("while true; do :; done"), + /loop iterations/, + ); +}); + +test("options: seeded files", () => { + const bash = new Bash({ files: { "/config.json": '{"debug":true}' } }); + assert.equal(bash.executeSync("jq -c .debug /config.json").stdout.trim(), "true"); +}); + +// --- Virtual filesystem (Bash helpers) ------------------------------------- + +test("vfs: write / read / exists / ls / remove", () => { + const bash = new Bash(); + bash.mkdir("/data"); + bash.writeFile("/data/x.txt", "abc\n"); + assert.equal(bash.readFile("/data/x.txt"), "abc\n"); + assert.equal(bash.exists("/data/x.txt"), true); + assert.deepEqual(bash.ls("/data"), ["x.txt"]); + bash.appendFile("/data/x.txt", "def\n"); + assert.equal(bash.readFile("/data/x.txt"), "abc\ndef\n"); + bash.remove("/data/x.txt"); + assert.equal(bash.exists("/data/x.txt"), false); +}); + +test("vfs: files created by helpers are visible to scripts (and vice versa)", () => { + const bash = new Bash(); + bash.writeFile("/from-js.txt", "js\n"); + assert.equal(bash.executeSync("cat /from-js.txt").stdout, "js\n"); + bash.executeSync("echo script > /from-script.txt"); + assert.equal(bash.readFile("/from-script.txt"), "script\n"); +}); + +test("vfs: bash.fs() returns a live handle", () => { + const bash = new Bash(); + const fs = bash.fs(); + fs.writeFile("/via-handle.txt", "handle\n"); + assert.equal(bash.executeSync("cat /via-handle.txt").stdout, "handle\n"); +}); + +// --- Async custom builtins ------------------------------------------------- + +test("async builtin: awaited by execute(), with argv + stdin", async () => { + const bash = new Bash({ + customBuiltins: { + fetchy: async (ctx) => { + await Promise.resolve(); + return `got:${ctx.argv[0]}:${ctx.stdin ?? ""}`; + }, + }, + }); + const r = await bash.execute("echo -n payload | fetchy 42 | tr a-z A-Z"); + assert.equal(r.stdout, "GOT:42:PAYLOAD"); +}); + +test("builtin ctx: env is a plain object, not a Map", async () => { + let seenEnv; + const bash = new Bash({ + env: { TOKEN: "secret" }, + customBuiltins: { + inspect: (ctx) => { + seenEnv = ctx.env; + return ""; + }, + }, + }); + await bash.execute("inspect"); + assert.equal(typeof seenEnv, "object"); + assert.ok(!(seenEnv instanceof Map)); + assert.equal(seenEnv.TOKEN, "secret"); +}); + +test("builtin ctx.fs: reads and writes the same VFS as the script", async () => { + const bash = new Bash({ + customBuiltins: { + // Reads an input file via ctx.fs, writes a derived output file. + transform: (ctx) => { + const input = ctx.fs.readFile(ctx.argv[0]); + ctx.fs.writeFile("/out.txt", input.toUpperCase()); + return "done\n"; + }, + }, + }); + bash.writeFile("/in.txt", "hello\n"); + const r = await bash.execute("transform /in.txt"); + assert.equal(r.stdout, "done\n"); + assert.equal(bash.readFile("/out.txt"), "HELLO\n"); +}); + +test("async builtin under executeSync fails fast (no hang)", () => { + const bash = new Bash({ customBuiltins: { slowly: async () => "nope" } }); + const r = bash.executeSync("slowly"); + assert.equal(r.exitCode, 1); + assert.match(r.stderr, /execute\(\)/); +}); + +test("sync builtin works under executeSync", () => { + const bash = new Bash({ + customBuiltins: { shout: (ctx) => (ctx.argv[0] ?? "").toUpperCase() + "\n" }, + }); + assert.equal(bash.executeSync("shout hello").stdout, "HELLO\n"); +}); + +test("throwing builtin -> stderr, exit 1", async () => { + const bash = new Bash({ + customBuiltins: { + boom: () => { + throw new Error("kaboom"); + }, + }, + }); + const r = await bash.execute("boom"); + assert.equal(r.exitCode, 1); + assert.match(r.stderr, /kaboom/); +}); + +test("builtins compose in a pipeline with jq", async () => { + const bash = new Bash({ + customBuiltins: { + graphql: async (ctx) => { + await Promise.resolve(); + // Pretend this issued ctx.stdin as a query and got JSON back. + return JSON.stringify({ data: { echo: ctx.stdin?.trim() } }); + }, + }, + }); + const r = await bash.execute('echo -n "ping" | graphql | jq -r .data.echo'); + assert.equal(r.stdout, "ping\n"); +}); + +// --- Lifecycle ------------------------------------------------------------- + +test("reset clears env and VFS", () => { + const bash = new Bash(); + bash.executeSync("export X=1; echo keep > /f.txt"); + bash.reset(); + const r = bash.executeSync( + "echo [${X-unset}] $(cat /f.txt 2>/dev/null || echo gone)", + ); + assert.equal(r.stdout, "[unset] gone\n"); +}); + +test("reset preserves registered custom builtins", async () => { + const bash = new Bash({ + customBuiltins: { ping: () => "pong\n" }, + }); + bash.reset(); + const r = await bash.execute("ping"); + assert.equal(r.stdout, "pong\n"); +}); + +test("multiple independent instances do not share state", () => { + const a = new Bash(); + const b = new Bash(); + a.executeSync("export SHARED=a; echo a > /marker.txt"); + assert.equal(b.executeSync("echo [${SHARED-none}]").stdout, "[none]\n"); + assert.equal(b.exists("/marker.txt"), false); +}); diff --git a/crates/bashkit-wasm/example/README.md b/crates/bashkit-wasm/example/README.md new file mode 100644 index 000000000..e517521e6 --- /dev/null +++ b/crates/bashkit-wasm/example/README.md @@ -0,0 +1,32 @@ +# Browser examples + +Self-contained demos of `@everruns/bashkit-web`. They load the package as a +plain ES module and need **no bundler and no cross-origin isolation** (no +`COOP`/`COEP` headers) — any static file server works. + +## Run + +```bash +# From crates/bashkit-wasm/: +bash scripts/build.sh # produces ./pkg (imported by the examples) +python3 -m http.server 8000 # or any static server +# then open: +# http://localhost:8000/example/ (interactive terminal) +# http://localhost:8000/example/custom-builtins.html (async builtins + ctx.fs) +``` + +Both pages `import { initBashkit, Bash } from "../pkg/index.js"`, so the `pkg/` +directory must exist (build it first). + +## Files + +| File | Shows | +|------|-------| +| `index.html` | Interactive terminal; `execute()` with a small async builtin | +| `custom-builtins.html` | Async builtin issuing a real `fetch` (the issue #2172 pattern) + `ctx.fs` read/write over the shared VFS | + +## Using a bundler instead + +The same imports work under Vite/webpack/esbuild — the `.wasm` is resolved via +`import.meta.url`, which bundlers understand. Install the published package +(`npm install @everruns/bashkit-web`) and import from `@everruns/bashkit-web`. diff --git a/crates/bashkit-wasm/example/custom-builtins.html b/crates/bashkit-wasm/example/custom-builtins.html new file mode 100644 index 000000000..75c3493e6 --- /dev/null +++ b/crates/bashkit-wasm/example/custom-builtins.html @@ -0,0 +1,89 @@ + + + + + + Bashkit — Async custom builtins + ctx.fs + + + +

Async custom builtins + ctx.fs

+

+ Single-threaded wasm — no SharedArrayBuffer, no COOP/COEP headers. + Serve with any static server: python3 -m http.server -d .. 8000 + (after bash ../scripts/build.sh) then open + /example/custom-builtins.html. +

+ +

1. An async builtin that issues a real request

+

+ This is the pattern from + issue #2172: + a custom bash command (gh-user) whose callback awaits a + fetch() and returns JSON, which the script pipes through jq. + Swap fetch for your own GraphQL/Relay client. +

+
running…
+ +

2. A builtin that reads and writes the shared VFS via ctx.fs

+

+ ctx.fs is a live handle to the same virtual filesystem the script + sees: the builtin reads an input file and writes a derived output file that the + next command in the script reads back. +

+
running…
+ + + + diff --git a/crates/bashkit-wasm/example/index.html b/crates/bashkit-wasm/example/index.html new file mode 100644 index 000000000..50cc4f95e --- /dev/null +++ b/crates/bashkit-wasm/example/index.html @@ -0,0 +1,83 @@ + + + + + + Bashkit — Browser Terminal (no COOP/COEP) + + + +
+ bashkit · single-threaded wasm · no SharedArrayBuffer, no COOP/COEP headers +
+
+
+ +
+ + + + + diff --git a/crates/bashkit-wasm/js/index.d.ts b/crates/bashkit-wasm/js/index.d.ts new file mode 100644 index 000000000..8bc83b65f --- /dev/null +++ b/crates/bashkit-wasm/js/index.d.ts @@ -0,0 +1,99 @@ +// Public TypeScript surface for @everruns/bashkit-web. + +/** A live handle to a `Bash` instance's virtual filesystem. */ +export interface FileSystem { + readFile(path: string): string; + writeFile(path: string, content: string): void; + appendFile(path: string, content: string): void; + exists(path: string): boolean; + mkdir(path: string): void; + remove(path: string): void; + ls(path: string): string[]; +} + +/** Payload passed to a custom-builtin callback. */ +export interface BuiltinRequest { + /** Command name as invoked. */ + readonly name: string; + /** Arguments, not including the command name. */ + readonly argv: string[]; + /** Piped stdin, or `null` when the builtin was not on the right of a pipe. */ + readonly stdin: string | null; + /** Exported environment variables. */ + readonly env: Record; + /** Current working directory. */ + readonly cwd: string; + /** Live handle to the interpreter's virtual filesystem (shared with the script). */ + readonly fs: FileSystem; +} + +/** + * A JS callback registered as a bash builtin. Return the builtin's stdout, or a + * `Promise` of it. Async callbacks are only awaited by {@link Bash.execute} + * (not `executeSync`). Throwing / rejecting becomes stderr with exit code 1. + */ +export type CustomBuiltin = ( + ctx: BuiltinRequest, +) => string | Promise; + +/** Options for constructing a {@link Bash} instance. */ +export interface BashOptions { + username?: string; + hostname?: string; + /** Initial working directory (avoids a leading `cd`). */ + cwd?: string; + /** Initial environment variables. */ + env?: Record; + /** Max commands per execution (resource limit). */ + maxCommands?: number; + /** Max iterations per loop (resource limit). */ + maxLoopIterations?: number; + /** Max interpreter memory in bytes. */ + maxMemory?: number; + /** Pre-created files seeded into the virtual filesystem (string contents). */ + files?: Record; + /** JS callbacks registered as bash builtins. */ + customBuiltins?: Record; +} + +/** Result of a bash execution. */ +export interface ExecResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; + readonly success: boolean; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +/** Sandboxed bash interpreter running entirely in the browser. */ +export class Bash { + constructor(options?: BashOptions); + /** + * Execute synchronously. Valid only for scripts that complete without + * suspending (plain bash and `jq`). Throws if the script invokes an async + * custom builtin or otherwise yields — use {@link Bash.execute} instead. + */ + executeSync(commands: string): ExecResult; + /** Execute asynchronously; supports async custom builtins. */ + execute(commands: string): Promise; + /** Reset to a fresh state, keeping options and registered custom builtins. */ + reset(): void; + /** Live handle to the virtual filesystem (same VFS the script sees). */ + fs(): FileSystem; + readFile(path: string): string; + writeFile(path: string, content: string): void; + appendFile(path: string, content: string): void; + exists(path: string): boolean; + mkdir(path: string): void; + remove(path: string): void; + ls(path: string): string[]; +} + +/** + * Initialize the WebAssembly module. Must resolve before constructing `Bash`. + * Idempotent. + */ +export function initBashkit( + input?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module, +): Promise; diff --git a/crates/bashkit-wasm/js/index.js b/crates/bashkit-wasm/js/index.js new file mode 100644 index 000000000..329dd1457 --- /dev/null +++ b/crates/bashkit-wasm/js/index.js @@ -0,0 +1,30 @@ +// Hand-authored entry point for @everruns/bashkit-web. +// +// Wraps the wasm-bindgen glue so consumers get a single, bundler-friendly +// module. The .wasm URL is resolved relative to this file via import.meta.url, +// so it loads correctly whether served from a CDN, a bundler, or a plain +//