`. 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
+//