Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
# @suaveplan/api-sandbox-wasm-host-bridge - Host bridge wrapping a @suaveplan/api-sandbox-wasm WasmSandboxHandle as a @suaveplan/api-host PluginModule. Validates the WASM ABI surface (__suaveplan_alloc / free / handle_request / routes / start / dispose + memory) at bridge-init, marshals SerializedRequest bytes into linear memory via __suaveplan_alloc, invokes __suaveplan_handle_request, reads the packed (responsePtr, responseLen) result, JSON-decodes the SerializedResponse, and unmarshals it back to a host Response. Traps surface as SAND_WASM_BRIDGE.HANDLER_TRAPPED with classified trapKind; denied capabilities surface as SAND_PERM.DENIED.
Host-integration layer that wraps a `@suaveplan/api-sandbox-wasm` `WasmSandboxHandle` as a `@suaveplan/api-host` `PluginModule`. Sandboxed-tier plugins compiled to WebAssembly export the `__suaveplan_*` ABI surface (`memory`, `__suaveplan_alloc`, `__suaveplan_free`, `__suaveplan_handle_request`, `__suaveplan_routes`, `__suaveplan_start`, `__suaveplan_dispose`); this bridge validates that surface, discovers the guest's routes once at bridge-init time, and emits per-route closures that marshal `SerializedRequest` records through the guest's linear memory.
<!-- begin:auto-docs -->
## Documentation tree
- [bridge](./src/bridge/bridge.md) — `createSandboxedWasmPluginModule({ sandbox, manifest })` is the public entry point of `@suaveplan/api-sandbox-wasm-host…
- [bridge](./src/bridge/bridge.md) — `createSandboxedWasmPluginModule({ sandbox, manifest })` is the public entry point of `@suaveplan/api-sandbox-wasm-host…
- [error-codes](./src/error-codes/error-codes.md) — Defines the seven stable `SAND_WASM_BRIDGE.*` error codes raised on production paths in `@suaveplan/api-sandbox-wasm-ho…
- [error-codes](./src/error-codes/error-codes.md) — Defines the seven stable `SAND_WASM_BRIDGE.*` error codes raised on production paths in `@suaveplan/api-sandbox-wasm-ho…
- [instruments](./src/instruments/instruments.md) — `createSandboxWasmBridgeInstruments(config)` constructs the OTel counter + histogram registry every production path in…
- [instruments](./src/instruments/instruments.md) — `createSandboxWasmBridgeInstruments(config)` constructs the OTel counter + histogram registry every production path in…
- [lifecycle](./src/lifecycle/lifecycle.md) — `createIdempotentLifecycle(deps)` returns the `start` / `dispose` pair the bridge installs on the host's `PluginModule.…
- [lifecycle](./src/lifecycle/lifecycle.md) — `createIdempotentLifecycle(deps)` returns the `start` / `dispose` pair the bridge installs on the host's `PluginModule.…
- [marshal](./src/marshal/marshal.md) — Encodes a `SerializedRequest` to UTF-8 JSON bytes for transport into the guest's linear memory and decodes the matching…
- [marshal](./src/marshal/marshal.md) — Encodes a `SerializedRequest` to UTF-8 JSON bytes for transport into the guest's linear memory and decodes the matching…
- [memory](./src/memory/memory.md) — Wraps the four primitive operations the host bridge performs against the guest's linear memory: allocating a buffer via…
- [memory](./src/memory/memory.md) — Wraps the four primitive operations the host bridge performs against the guest's linear memory: allocating a buffer via…
- [trap-classifier](./src/memory/trap-classifier.md) — `classifyTrap(cause)` maps a raw `WebAssembly.RuntimeError` (or any thrown value the bridge catches around a guest call…
- [route-discovery](./src/route-discovery/route-discovery.md) — `discoverRoutes(exports, pluginName)` calls the guest's `__suaveplan_routes` export exactly once at bridge-init time, u…
- [route-discovery](./src/route-discovery/route-discovery.md) — `discoverRoutes(exports, pluginName)` calls the guest's `__suaveplan_routes` export exactly once at bridge-init time, u…
- [wasm-host-bridge-conformance](./src/testing/wasm-host-bridge-conformance/wasm-host-bridge-conformance.md) — `runWasmHostBridgeConformance({ buildBridge })` is the contract test every wasm host-bridge configuration must pass. Th…
- [wasm-host-bridge-conformance](./src/testing/wasm-host-bridge-conformance/wasm-host-bridge-conformance.md) — `runWasmHostBridgeConformance({ buildBridge })` is the contract test every wasm host-bridge configuration must pass. Th…
- [abi-validator](./src/wasm-abi/abi-validator.md) — `validateWasmAbi(instance, pluginName)` performs the bridge-init-time ABI check that gates every other operation in `@s…
- [abi](./src/wasm-abi/abi.md) — Declares the `__suaveplan_*` ABI surface a sandboxed WASM plugin must export for `@suaveplan/api-sandbox-wasm-host-brid…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-sandbox-wasm-host-bridge
```
Peer-style dependencies (already required by any host that boots sandboxed-tier plugins):
- `@suaveplan/api-host`
- `@suaveplan/api-sandbox-permissions`
- `@suaveplan/api-sandbox-wasm`
- `@suaveplan/api-types`
## Basic usage
```ts
import { createWasmSandbox } from "@suaveplan/api-sandbox-wasm";
import { createSandboxedWasmPluginModule } from "@suaveplan/api-sandbox-wasm-host-bridge";
const sandbox = await createWasmSandbox({
pluginName: "example",
policy,
capabilities,
checker,
wasmBytes,
});
const install = createSandboxedWasmPluginModule({ sandbox, manifest });
await host.installPlugin({
module: install.module,
manifest: install.manifest,
manifestHash,
specifier: "example@0.1.0",
routes: install.routes,
});
```
## Conformance
The package ships `runWasmHostBridgeConformance({ buildBridge })` under `./testing`. Five invariants — route registration, request round-trip, lifecycle idempotency, trap propagation, and capability-gate fidelity — are asserted against the supplied factory. The harness uses a hand-encoded WASM fixture (no `wabt` build step) so the test suite has no external compile-time dependency.
## Error codes
`SAND_WASM_BRIDGE.*`: `INVALID_PLUGIN_SURFACE`, `MEMORY_ALLOC_FAILED`, `MARSHAL_FAILED`, `UNMARSHAL_FAILED`, `HANDLER_TRAPPED`, `CAPABILITY_DENIED`, `LIFECYCLE_FAILED`. Capability denials are surfaced through the canonical `SAND_PERM.DENIED` code from `@suaveplan/api-sandbox-permissions`.

View File

@@ -0,0 +1,91 @@
# Request / response marshaller
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes a `SerializedRequest` to UTF-8 JSON bytes for transport into the guest's linear memory and decodes the matching `SerializedResponse` (or the JSON-encoded `SerializedRoute[]` produced by `__suaveplan_routes`) back into typed records. Wraps every failure mode behind the two stable codes `SAND_WASM_BRIDGE.MARSHAL_FAILED` and `SAND_WASM_BRIDGE.UNMARSHAL_FAILED` so call sites have a single catch-and-translate path.
## Features
- `marshalRequest` — `JSON.stringify` + `TextEncoder` round-trip, distinguishing `JSON.stringify` throws from the `undefined`-return case caused by non-serialisable top-level values.
- `unmarshalResponse` / `unmarshalRoutes` — strict UTF-8 decoding (`fatal: true`) + `JSON.parse` + Zod schema validation, with `context.reason` set to one of `"invalid_utf8" | "invalid_json" | "shape_mismatch"` so operators can tell apart guest-corruption from guest-bug failures.
- Pure functions — no shared state, safe to call concurrently from multiple route handlers.
- Strict-mode Zod schemas reject extra keys at the response boundary; the bridge therefore never silently honours an unspecified field a future guest might emit.
## Usage: Basic
```ts
import {
marshalRequest,
unmarshalResponse,
} from "@suaveplan/api-sandbox-wasm-host-bridge/marshal";
const reqBytes = marshalRequest(serialized, "example");
const respBytes = await invokeGuest(reqBytes);
const wireResponse = unmarshalResponse(respBytes, "example");
```
## Usage: Advanced
```ts
import {
marshalRequest,
unmarshalResponse,
unmarshalRoutes,
} from "@suaveplan/api-sandbox-wasm-host-bridge/marshal";
import type { SerializedRequest } from "@suaveplan/api-types/sandbox-bridge";
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
function safeUnmarshal(
bytes: Uint8Array,
plugin: string,
): Result {
try {
return { ok: true, value: unmarshalResponse(bytes, plugin) };
} catch (err) {
if (
err instanceof SandboxWasmBridgeError &&
err.code === SAND_WASM_BRIDGE_CODES.UNMARSHAL_FAILED
) {
return { ok: false, reason: err.context?.["reason"] as string };
}
throw err;
}
}
const routes = unmarshalRoutes(routeBytes, "example");
const requestBytes: Uint8Array = marshalRequest(
{
url: "https://app.example.com/",
method: "GET",
headers: [],
bodyBase64: "",
principalId: null,
tenantId: null,
capabilities: [],
requestId: "r1",
timestamp: 0,
} satisfies SerializedRequest,
"example",
);
type Result =
| { readonly ok: true; readonly value: ReturnType<typeof unmarshalResponse> }
| { readonly ok: false; readonly reason: string };
```
## API Reference
- `marshalRequest(request: SerializedRequest, pluginName: string): Uint8Array` — throws `SAND_WASM_BRIDGE.MARSHAL_FAILED`.
- `unmarshalResponse(bytes: Uint8Array, pluginName: string): SerializedResponse` — throws `SAND_WASM_BRIDGE.UNMARSHAL_FAILED`.
- `unmarshalRoutes(bytes: Uint8Array, pluginName: string): readonly SerializedRoute[]` — same failure mode.
## Implementation Notes
The `TextDecoder` is constructed with `fatal: true` so malformed UTF-8 surfaces as a typed `TypeError` (the bridge's catch wraps it). The Zod schemas use `.strict()` because the wasm-side guest is expected to emit byte-for-byte the agreed shape; tolerating extra keys would let a buggy guest leak metadata into operator logs unnoticed. `JSON.stringify` returning `undefined` (top-level non-encodable, e.g. a `BigInt` reachable through a `toJSON` hook) is treated as a `"non_serialisable"` reason rather than a thrown error so the bridge can attribute the failure even when no `cause` is available.

View File

@@ -0,0 +1,79 @@
# Linear memory helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps the four primitive operations the host bridge performs against the guest's linear memory: allocating a buffer via `__suaveplan_alloc`, freeing it via `__suaveplan_free`, writing bytes into the buffer, and reading bytes back out. Adds the trap-protection and out-of-memory checks the rest of the bridge relies on so caller-site code stays focused on marshalling logic rather than guest-failure handling.
## Features
- `allocBuffer` — calls `__suaveplan_alloc`, catches traps as `SAND_WASM_BRIDGE.MEMORY_ALLOC_FAILED` with classified `trapKind`, and re-raises the same code with `reason: "out_of_memory"` when the guest returns `0`.
- `freeBuffer` — swallows traps so a host-side cleanup fault never shadows the original error being unwound; no-ops on `pointer === 0`.
- `writeBytes` / `readBytes` — typed-array views over `memory.buffer`; `readBytes` returns a copy so subsequent guest allocations cannot resize the view from under the caller.
- `unpackPointerLength` — splits the i64 result the WASM ABI uses to return `(ptr, len)` pairs into a pair of JavaScript numbers.
## Usage: Basic
```ts
import {
allocBuffer,
freeBuffer,
readBytes,
writeBytes,
} from "@suaveplan/api-sandbox-wasm-host-bridge/memory";
const ptr = allocBuffer(exports, bytes.length, "example");
writeBytes(exports.memory, ptr, bytes);
const out = readBytes(exports.memory, ptr, bytes.length);
freeBuffer(exports, ptr);
```
## Usage: Advanced
```ts
import {
allocBuffer,
freeBuffer,
readBytes,
unpackPointerLength,
writeBytes,
} from "@suaveplan/api-sandbox-wasm-host-bridge/memory";
function roundTrip(
exports: import("@suaveplan/api-sandbox-wasm-host-bridge/wasm-abi").WasmAbiExports,
inputBytes: Uint8Array,
pluginName: string,
): Uint8Array {
const reqPtr = allocBuffer(exports, inputBytes.length, pluginName);
try {
writeBytes(exports.memory, reqPtr, inputBytes);
const packed = exports.__suaveplan_handle_request(
reqPtr,
inputBytes.length,
);
const { pointer: respPtr, length: respLen } = unpackPointerLength(packed);
try {
return readBytes(exports.memory, respPtr, respLen);
} finally {
freeBuffer(exports, respPtr);
}
} finally {
freeBuffer(exports, reqPtr);
}
}
```
## API Reference
- `allocBuffer(exports, length, pluginName): number` — throws `SAND_WASM_BRIDGE.MEMORY_ALLOC_FAILED` on trap or `0` return.
- `freeBuffer(exports, pointer): void` — best-effort.
- `writeBytes(memory, pointer, bytes): void`.
- `readBytes(memory, pointer, length): Uint8Array` — copies out of the live buffer.
- `unpackPointerLength(packed: bigint): { pointer: number; length: number }`.
## Implementation Notes
`readBytes` deliberately copies into a fresh `Uint8Array` because `memory.buffer` is detached and replaced whenever the guest grows linear memory (`memory.grow`). A view obtained before a subsequent `__suaveplan_alloc` would silently become invalid; the copy isolates callers from that hazard at the cost of one allocation per response. The `LOW_32_MASK` constant in `unpackPointerLength` is a `bigint` literal so the shift and mask stay in `bigint`-land throughout — mixing `number` and `bigint` here would throw `TypeError`.

View File

@@ -0,0 +1,66 @@
# Trap classifier
## Purpose
`classifyTrap(cause)` maps a raw `WebAssembly.RuntimeError` (or any thrown value the bridge catches around a guest call) into one of the seven canonical `TrapKind` discriminators the bridge surfaces in `SandboxWasmBridgeError.context.trapKind`. The classification is best-effort and string-based because the WebAssembly JavaScript API does not expose a typed trap reason — the spec only guarantees the runtime-error class and a freeform message.
## Features
- Single function, deterministic output: every input maps to exactly one `TrapKind`, with `"other"` as the safe fallback.
- Engine-agnostic: substring patterns cover the canonical V8, SpiderMonkey, and JSC messages for each trap-emitting WASM instruction.
- Defensive on non-RuntimeError throws: arbitrary host-import faults still resolve to a string-based classification so callers always receive a `trapKind` field.
- Pure — no I/O, no side effects, no allocations beyond the lowercased message buffer.
## Usage: Basic
```ts
import { classifyTrap } from "@suaveplan/api-sandbox-wasm-host-bridge/memory";
try {
instance.exports.handle();
} catch (err) {
console.log(classifyTrap(err));
}
```
## Usage: Advanced
```ts
import {
classifyTrap,
type TrapKind,
} from "@suaveplan/api-sandbox-wasm-host-bridge/memory";
function isFatalTrap(err: unknown): boolean {
const kind: TrapKind = classifyTrap(err);
return kind === "unreachable" || kind === "stack_overflow";
}
function describe(err: unknown): string {
const kind = classifyTrap(err);
switch (kind) {
case "unreachable":
return "guest hit an `unreachable` instruction";
case "out_of_bounds":
return "guest accessed memory outside the allocated range";
case "div_by_zero":
return "guest divided by zero";
case "integer_overflow":
case "invalid_conversion":
return "guest produced an integer-conversion trap";
case "stack_overflow":
return "guest exhausted the call stack";
default:
return "guest trapped";
}
}
```
## API Reference
- `TrapKind` — `"unreachable" | "out_of_bounds" | "div_by_zero" | "integer_overflow" | "invalid_conversion" | "stack_overflow" | "other"`.
- `classifyTrap(cause: unknown): TrapKind` — pure function.
## Implementation Notes
The substring patterns intentionally match multiple phrasings per kind (e.g. `"out of bounds"` and `"out-of-bounds"` and `"memory access"`) so engine-version drift does not flip an existing classification. Adding a new engine (or a new WASM trap instruction) requires extending `classifyByMessage` with the engine-specific message fragment AND appending the new kind to the `TrapKind` union. Both ends of that change are TypeScript-enforced because the switch in `bridge.ts` consumes the union exhaustively.

View File

@@ -0,0 +1,65 @@
# ABI validator
## Purpose
`validateWasmAbi(instance, pluginName)` performs the bridge-init-time ABI check that gates every other operation in `@suaveplan/api-sandbox-wasm-host-bridge`. It inspects the supplied `WebAssembly.Instance`'s exports, confirms each of the seven required symbols is present with the right runtime kind (callable function for the six `__suaveplan_*` exports, `WebAssembly.Memory` for `memory`), and either returns the type-narrowed `WasmAbiExports` view or throws `SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE` with a stable `context.missing` list.
## Features
- Single bridge-init gate — no other function in the package reads `instance.exports` directly.
- Type-checked output: callers receive a `WasmAbiExports` so subsequent code is free of `as` casts.
- Stable diagnostics: `context.missing` is sorted alphabetically so logs / tests can match against deterministic strings.
- Carries the plugin name in `context.plugin` so audit sinks attribute denials correctly when multiple plugins are loaded.
## Usage: Basic
```ts
import { validateWasmAbi } from "@suaveplan/api-sandbox-wasm-host-bridge/wasm-abi";
const exports = validateWasmAbi(instance, "example");
const offset = exports.__suaveplan_alloc(64);
```
## Usage: Advanced
```ts
import {
validateWasmAbi,
type WasmAbiExports,
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasm-abi";
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
function safeValidate(
instance: WebAssembly.Instance,
plugin: string,
): { ok: true; exports: WasmAbiExports } | {
ok: false;
missing: readonly string[];
} {
try {
return { ok: true, exports: validateWasmAbi(instance, plugin) };
} catch (err) {
if (
err instanceof SandboxWasmBridgeError &&
err.code === SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE
) {
return {
ok: false,
missing: err.context?.["missing"] as readonly string[],
};
}
throw err;
}
}
```
## API Reference
- `validateWasmAbi(instance: WebAssembly.Instance, pluginName: string): WasmAbiExports` — throws `SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE` on any missing or wrong-kinded symbol.
## Implementation Notes
The kind check distinguishes "exported value of wrong type" from "missing entirely" by treating both as members of the missing-set — this is intentional. A function exported under the name `memory` would still fail the validator with `context.missing: ["memory"]`, because from the bridge's perspective the contract is "callable with the right WASM signature", not "key present in the exports bag". The sorted output is built from the iteration order of `REQUIRED_WASM_ABI_SYMBOLS` (already sorted) plus a defensive `.sort()` on the missing copy so a future reordering of the canonical list cannot break test snapshots.

View File

@@ -0,0 +1,53 @@
# WASM ABI surface
## Purpose
Declares the `__suaveplan_*` ABI surface a sandboxed WASM plugin must export for `@suaveplan/api-sandbox-wasm-host-bridge` to wrap it as a host `PluginModule`. The `REQUIRED_WASM_ABI_SYMBOLS` constant is the canonical, sorted list the validator compares against the instance's actual exports; the matching `WasmAbiExports` type-level view in `abi.types.ts` gives the bridge a strongly-typed handle on the export bag after validation.
## Features
- Single source-of-truth for the ABI: changing the surface here ripples through the validator, marshaller, route-discovery helper, and the fixture WASM builder used by the conformance harness.
- Sorted alphabetically so `context.missing` arrays are deterministic across runs — the foundation `redact` codepath and snapshot tests both rely on this stability.
- Frozen at module load via `Object.freeze` so consumers cannot accidentally mutate the contract.
- Pairs with `RequiredWasmAbiSymbol` (a union of literal strings) so TypeScript narrows array element types automatically.
## Usage: Basic
```ts
import { REQUIRED_WASM_ABI_SYMBOLS } from "@suaveplan/api-sandbox-wasm-host-bridge/wasm-abi";
console.log(REQUIRED_WASM_ABI_SYMBOLS);
// → ["__suaveplan_alloc", "__suaveplan_dispose", "__suaveplan_free",
// "__suaveplan_handle_request", "__suaveplan_routes",
// "__suaveplan_start", "memory"]
```
## Usage: Advanced
```ts
import {
REQUIRED_WASM_ABI_SYMBOLS,
type WasmAbiExports,
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasm-abi";
function missingExports(
instance: WebAssembly.Instance,
): readonly string[] {
const present = new Set(Object.keys(instance.exports));
return REQUIRED_WASM_ABI_SYMBOLS.filter((name) => !present.has(name));
}
function typedExports(instance: WebAssembly.Instance): WasmAbiExports {
return instance.exports as unknown as WasmAbiExports;
}
```
## API Reference
- `REQUIRED_WASM_ABI_SYMBOLS: readonly RequiredWasmAbiSymbol[]` — alphabetically sorted, frozen.
- `RequiredWasmAbiSymbol` — `"memory" | "__suaveplan_alloc" | "__suaveplan_dispose" | "__suaveplan_free" | "__suaveplan_handle_request" | "__suaveplan_routes" | "__suaveplan_start"`.
- `WasmAbiExports` — readonly record interface mapping each symbol to its typed signature; consumed by `bridge.ts`.
## Implementation Notes
The constant is split out from the validator function so the fixture WASM builder under `tests/fixtures/` can iterate the same list to generate exports in lockstep with the runtime check — keeping both ends of the contract drift-free. Sorting alphabetically (instead of "logical-order") is deliberate: it makes the `context.missing` diagnostic stable under any future reordering of the symbol list, and matches the foundation's general policy of "set-shape diagnostics use sorted output".

View File

@@ -0,0 +1,101 @@
# `wasi-stdio-validate`
## Purpose
ABI surface validator for the WASI-stdio variant. Inspects a
`WebAssembly.Module`'s imports and exports BEFORE instantiation and
raises `SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE` if the module is
missing any of the five required WASI imports
(`wasi_snapshot_preview1.{fd_read, fd_write, environ_get,
environ_sizes_get, proc_exit}`) OR the two required exports (`memory`,
`_start`). The check is the WASI-stdio mirror of `validateWasmAbi`
in `abi-direct/wasm-abi/`; it does NOT require the six
`__suaveplan_*` direct-variant exports because Javy-compiled plugins
cannot emit them.
## Features
- Module-level (pre-instantiation) check — operators see the failure
before the host commits to instantiating the plugin.
- Both required imports and required exports are validated in a single
pass; `context.missing` carries the sorted union of absent symbols.
- Namespace-qualified diagnostic for imports
(`wasi_snapshot_preview1.fd_read`) vs bare for exports (`_start`)
makes the absent surface obvious at a glance.
- The required-symbol lists are sorted constants so a future addition
surfaces as a typecheck divergence rather than a silent drift.
## Usage: Basic
```ts
import { validateWasiStdioAbi } from
"@suaveplan/api-sandbox-wasm-host-bridge/abi-wasi-stdio";
declare const module: WebAssembly.Module;
validateWasiStdioAbi(module, "example");
// Throws SandboxWasmBridgeError on missing surface.
```
## Usage: Advanced
```ts
import { validateWasiStdioAbi } from
"@suaveplan/api-sandbox-wasm-host-bridge/abi-wasi-stdio";
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
declare const module: WebAssembly.Module;
try {
validateWasiStdioAbi(module, "billing");
} catch (err) {
if (
err instanceof SandboxWasmBridgeError &&
err.code === SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE
) {
const missing = (err.context as { missing?: readonly string[] })
.missing;
// missing → ["_start"] OR ["wasi_snapshot_preview1.fd_read", ...]
void missing;
}
}
```
## API Reference
### `validateWasiStdioAbi(module, pluginName): void`
- `module: WebAssembly.Module` — pre-compiled module under inspection.
- `pluginName: string` — error-context label.
Throws `SandboxWasmBridgeError` with code
`SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE` and
`context.abiVariant = "wasi-stdio"` + sorted `context.missing`.
### `REQUIRED_WASI_STDIO_IMPORTS: readonly string[]`
Sorted list: `environ_get`, `environ_sizes_get`, `fd_read`, `fd_write`,
`proc_exit`.
### `REQUIRED_WASI_STDIO_EXPORTS: readonly string[]`
Sorted list: `_start`, `memory`.
## Implementation Notes
- The validator walks `WebAssembly.Module.imports(module)` and
`WebAssembly.Module.exports(module)` once each — both are O(n) over
the module's metadata and never touch the linear memory. Running
them BEFORE instantiation costs effectively nothing.
- The required-imports list is intentionally minimal: it captures the
five hooks any WASI-stdio guest MUST call. A guest that uses
additional WASI imports (e.g. `clock_time_get`, `random_get`) is
fine — those are wired by the runner but their absence is not a
surface error.
- `context.abiVariant` carries the literal `"wasi-stdio"` string so
operators reading a stack trace can tell which variant raised
without inferring it from the plugin's manifest. The direct
validator does not set this field for the inverse reason: the
direct path is the default and the absence of the field implies
it.

View File

@@ -0,0 +1,102 @@
# `bridge-direct`
## Purpose
Direct-i32-i64 variant of `createSandboxedWasmPluginModule`. Validates
the six `__suaveplan_*` ABI exports + `memory`, discovers routes via
`__suaveplan_routes`, and assembles `PipelineRoute` records whose
handler closures marshal `SerializedRequest` bytes through linear
memory and unmarshal the `(responsePtr, responseLen)` packed-i64
return. This file owns the entire direct-variant production path —
the dispatcher in `bridge.ts` calls it when `abiVariant` is unset or
`"direct-i32-i64"`.
The split from `bridge.ts` is structural rather than mechanical: every
direct-variant import (`abi-direct/wasm-abi`, `abi-direct/memory`,
`abi-direct/marshal`) is referenced from this file only, so a future
removal of the direct variant collapses to deleting `bridge-direct.ts`
+ `abi-direct/` and trimming the dispatcher.
## Features
- `buildDirectInstall(options)` — accepts a `DirectVariantOptions`,
returns the bridge's `SandboxedWasmPluginInstall`.
- Trap classification, capability-gate routing through
`SAND_PERM.DENIED`, idempotent lifecycle — preserved exactly from
the shipped contract.
- Instrument registry plumbed with `abi_variant: "direct-i32-i64"` on
every counter and histogram emission.
- Route closures are pure functions of the manifest + ABI exports;
re-instantiating the bridge against the same sandbox produces
identical `PipelineRoute[]` records.
## Usage: Basic
```ts
import { createSandboxedWasmPluginModule } from
"@suaveplan/api-sandbox-wasm-host-bridge";
declare const sandbox: import("@suaveplan/api-sandbox-wasm").WasmSandboxHandle;
declare const manifest: import("@suaveplan/api-types/manifest").PluginManifest;
const install = createSandboxedWasmPluginModule({ sandbox, manifest });
install.module.start();
```
## Usage: Advanced
```ts
import { createSandboxedWasmPluginModule } from
"@suaveplan/api-sandbox-wasm-host-bridge";
import { createSandboxWasmBridgeInstruments } from
"@suaveplan/api-sandbox-wasm-host-bridge/instruments";
declare const sandbox: import("@suaveplan/api-sandbox-wasm").WasmSandboxHandle;
declare const manifest: import("@suaveplan/api-types/manifest").PluginManifest;
const instruments = createSandboxWasmBridgeInstruments({
pluginName: manifest.name,
abiVariant: "direct-i32-i64",
});
const install = createSandboxedWasmPluginModule({
abiVariant: "direct-i32-i64",
sandbox,
manifest,
instruments,
});
try {
install.module.start();
for (const route of install.routes) {
void route;
}
} finally {
install.module.stop();
}
```
## API Reference
### `buildDirectInstall(options): SandboxedWasmPluginInstall`
Internal entrypoint called by the dispatcher. Public consumers should
use `createSandboxedWasmPluginModule` from the package root.
- `options: DirectVariantOptions` — `sandbox`, `manifest`, optional
`instruments`.
Throws `SandboxWasmBridgeError` with code `INVALID_PLUGIN_SURFACE` if
ABI validation fails BEFORE any guest call.
## Implementation Notes
- The function never touches the sandbox's `instance` directly past
the ABI validator — the typed `WasmAbiExports` view is the single
hand-off point.
- `freeBuffer` is called in a `finally` block so the request pointer
is released even when the response unmarshal throws. The response
pointer is released BEFORE unmarshal runs because Zod parsing may
allocate additional bytes that would shadow the original buffer.
- Capability denials surface through the canonical
`SAND_PERM.DENIED` code, not the bridge-local `CAPABILITY_DENIED`.
Operators see one code for "permission denied" regardless of which
bridge raised it.

View File

@@ -0,0 +1,107 @@
# `bridge-marshal`
## Purpose
Shared `RequestContext` → `SerializedRequest` and `SerializedResponse`
→ `Response` helpers used by BOTH bridge variants. Splitting them out
keeps `bridge-direct.ts` and `bridge-wasi-stdio.ts` focused on the
guest-call orchestration that differs between variants — the wire
marshalling is identical because both variants exchange the same
`SerializedRequest` / `SerializedResponse` shapes (the difference is
the transport: linear-memory copy vs. WASI-stdio frame).
The error-code mapping for sandbox-originated `errorCode` values is
also shared: `SAND_PERM.DENIED` surfaces as `SandboxPermissionError`,
everything else as `SAND_WASM_BRIDGE.CAPABILITY_DENIED` carrying the
plugin's own code.
## Features
- `marshalContextRequest(ctx)` — convert a structural `RequestContext`
into the wire `SerializedRequest`. Body bytes are base64-url-no-pad
encoded; headers are lowercased.
- `buildHostResponse(wire, pluginName)` — convert the wire
`SerializedResponse` into a host `Response`. When `wire.errorCode`
is set, raise the matching sandbox error instead of returning a
response.
- `RequestContextLike` — minimal structural interface for the five
fields the bridge reads. Defined here so neither variant has to
import the host's generic `RequestContext<T>`.
## Usage: Basic
```ts
import {
buildHostResponse,
marshalContextRequest,
type RequestContextLike,
} from "../bridge/bridge-marshal.js";
async function handle(ctx: RequestContextLike): Promise<Response> {
const wire = await marshalContextRequest(ctx);
void wire;
return buildHostResponse(
{ status: 200, headers: [], bodyBase64: "" },
"plugin",
);
}
```
## Usage: Advanced
```ts
import { buildHostResponse } from "../bridge/bridge-marshal.js";
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "../error-codes/error-codes.js";
try {
const res = buildHostResponse(
{
status: 500,
headers: [],
bodyBase64: "",
errorCode: "PLUGIN.RATE_LIMITED",
errorMessage: "too many",
},
"billing",
);
void res;
} catch (err) {
if (
err instanceof SandboxWasmBridgeError &&
err.code === SAND_WASM_BRIDGE_CODES.CAPABILITY_DENIED
) {
// The plugin's own code is on `err.context.sandboxErrorCode`.
}
throw err;
}
```
## API Reference
### `marshalContextRequest(ctx: RequestContextLike): Promise<SerializedRequest>`
Reads the request body via `arrayBuffer()` once and encodes the bytes
as base64-url-no-pad. Headers are lowercased.
### `buildHostResponse(wire: SerializedResponse, pluginName: string): Response`
Throws `SandboxPermissionError` when `wire.errorCode === "SAND_PERM.DENIED"`;
otherwise throws `SandboxWasmBridgeError` with code
`CAPABILITY_DENIED` for any other `errorCode`. Returns a `Response`
when no error.
## Implementation Notes
- Base64-url-no-pad matches the encoding used by `api-types/sandbox-bridge`'s
documented body field; both bridges and Javy guests use the same
encoding so the wire shape stays consistent.
- The request's `body` is materialised eagerly via `arrayBuffer()`
because the wasi-stdio guest cannot read from a `ReadableStream` —
the entire payload must be in the envelope before it crosses the
fd 0 boundary.
- Capability denials route through `SAND_PERM.DENIED` regardless of
variant; the bridge-local `CAPABILITY_DENIED` is reserved for
plugin-originated codes outside the `SAND_PERM` namespace.

View File

@@ -0,0 +1,122 @@
# `bridge-wasi-stdio`
## Purpose
WASI-stdio variant of `createSandboxedWasmPluginModule`. Validates the
WASI snapshot-preview1 import surface + `_start` + `memory` exports
BEFORE instantiation, then builds a `WasiStdioRunner` that drives the
length-prefixed JSON protocol over fd 0 / fd 1. Routes are discovered
through a `{kind:"routes"}` envelope; each per-route handler closure
sends one `{kind:"request"}` envelope and unwraps the response, error,
or protocol mismatch the guest returns.
The path is structurally separate from `bridge-direct.ts` — the two
files share only the wire-marshalling helpers in `bridge-marshal.ts`.
A future deprecation of either variant collapses to deleting one file
plus its `abi-*` siblings.
## Features
- Module-level ABI validation via `validateWasiStdioAbi`.
- Runner construction with `extraImports` plumb-through for
`suaveplan_caps`-style capability imports.
- Route discovery via `discoverRoutesViaStdio`; routes flow into the
same `PipelineRoute[]` shape the direct variant produces.
- Idempotent lifecycle adapted to the host's `IdempotentLifecycle`
interface so both variants return the same install shape.
- Per-request handler closures emit `abi_variant: "wasi-stdio"`
attributes on every counter and histogram emission.
- Plugin-originated `{kind:"error"}` replies route through the
canonical `SAND_PERM.DENIED` mapping or surface as
`SAND_WASM_BRIDGE.CAPABILITY_DENIED`.
- Protocol-mismatch (e.g. a `{kind:"routes"}` reply to a request)
surfaces as `SAND_WASM_BRIDGE.PROTOCOL_MISMATCH` with
`context.expected = "response"` and `context.received = "<kind>"`.
## Usage: Basic
```ts
import { createSandboxedWasmPluginModule } from
"@suaveplan/api-sandbox-wasm-host-bridge";
declare const sandbox: import("@suaveplan/api-sandbox-wasm").WasmSandboxHandle;
declare const wasmModule: WebAssembly.Module;
declare const manifest: import("@suaveplan/api-types/manifest").PluginManifest;
const install = createSandboxedWasmPluginModule({
abiVariant: "wasi-stdio",
sandbox,
wasmModule,
manifest,
});
install.module.start();
```
## Usage: Advanced
```ts
import { createSandboxedWasmPluginModule } from
"@suaveplan/api-sandbox-wasm-host-bridge";
declare const sandbox: import("@suaveplan/api-sandbox-wasm").WasmSandboxHandle;
declare const wasmModule: WebAssembly.Module;
declare const manifest: import("@suaveplan/api-types/manifest").PluginManifest;
declare const capImports: WebAssembly.ModuleImports;
const install = createSandboxedWasmPluginModule({
abiVariant: "wasi-stdio",
sandbox,
wasmModule,
manifest,
stdioTimeoutMs: 10_000,
logger: { warn: (line) => console.warn(line) },
extraImports: { suaveplan_caps: capImports },
});
try {
install.module.start();
for (const route of install.routes) {
void route;
}
} finally {
install.module.stop();
}
```
## API Reference
### `buildWasiStdioInstall(options): SandboxedWasmPluginInstall`
Internal entrypoint called by the dispatcher.
- `options.sandbox` — `WasmSandboxHandle` (retained for dispose chain;
its `instance` is NOT used).
- `options.wasmModule` — pre-compiled module the runner instantiates.
- `options.manifest` — plugin manifest.
- `options.instruments?` — pre-built registry; defaults to one with
`abi_variant: "wasi-stdio"`.
- `options.logger?` — stderr sink.
- `options.stdioTimeoutMs?` — per-step deadline (default 5000).
- `options.extraImports?` — composed with the runner's WASI snapshot.
Throws `SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE` from the validator OR
runner; `LIFECYCLE_FAILED` / `HANDLER_TRAPPED` / `PROTOCOL_MISMATCH` /
`FRAME_OVERSIZE` / `STDIO_TIMEOUT` from the per-step round-trips.
## Implementation Notes
- The runner-owned WASI snapshot is the source of truth for
fd 0 / fd 1 / fd 2 wiring. The sandbox's existing WASI snapshot is
NOT touched — operators wanting permission-policy preopens for a
hybrid plugin compose them via `extraImports` (typed as
`WebAssembly.ModuleImports`).
- Per-request handler closures pre-compute the route label so the
instrument emissions reuse the same string identity across calls.
- The `adaptLifecycle` adapter wraps the `WasiStdioLifecycle` shape
in the `IdempotentLifecycle` interface so the install record's
`lifecycle` field has the same shape across both variants. The
bridge dispatcher in `bridge.ts` does NOT need a variant branch
on the `lifecycle` field.
- Trap classification under wasi-stdio works exactly as under direct:
the runner's `exchange` method wraps any `WebAssembly.RuntimeError`
from `_start` into `SAND_WASM_BRIDGE.HANDLER_TRAPPED` with
`context.trapKind` set by `classifyTrap`.

View File

@@ -0,0 +1,78 @@
# Bridge factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createSandboxedWasmPluginModule(options)` is the public entry point of `@suaveplan/api-sandbox-wasm-host-bridge`. It dispatches on the supplied `abiVariant`: the default (`"direct-i32-i64"`) routes to `buildDirectInstall` in `bridge-direct.ts`, which validates the six `__suaveplan_*` ABI exports + `memory` and bridges requests through linear memory; the additive variant (`"wasi-stdio"`) routes to `buildWasiStdioInstall` in `bridge-wasi-stdio.ts`, which validates the WASI snapshot-preview1 import surface + `_start` + `memory` and bridges requests through length-prefixed JSON envelopes on fd 0 / fd 1. Both paths return the same `SandboxedWasmPluginInstall` shape so the host's `installPlugin` call is variant-agnostic. The returned bundle carries the `module`, the bridged `PipelineRoute[]`, the idempotent lifecycle, and the underlying sandbox handle.
## Features
- Single bridge-init validation: the ABI is checked, routes are discovered, and instruments are wired up in one synchronous block; failures surface BEFORE the host sees the install bundle.
- Per-route closures own the full linear-memory round-trip: alloc → write → handle_request → unpack → read → free → JSON-decode → host `Response`.
- Trap classification: `__suaveplan_handle_request` faults surface as `SAND_WASM_BRIDGE.HANDLER_TRAPPED` with `context.trapKind` derived from the runtime error message.
- Empty-response detection: a `(0, 0)` packed return value from the guest is treated as a marshal failure (`reason: "empty_response"`) rather than as a silent success.
- Capability denial passthrough: a `SerializedResponse.errorCode === "SAND_PERM.DENIED"` is re-thrown as the canonical `SandboxPermissionError`; other error codes surface as `SAND_WASM_BRIDGE.CAPABILITY_DENIED` carrying the sandbox's original code in `context.sandboxErrorCode`.
- Lifecycle stop chains through to `WasmSandboxHandle.dispose()` so a `host.uninstallPlugin` always tears down the underlying sandbox.
## Usage: Basic
```ts
import { createWasmSandbox } from "@suaveplan/api-sandbox-wasm";
import { createSandboxedWasmPluginModule } from "@suaveplan/api-sandbox-wasm-host-bridge";
const sandbox = await createWasmSandbox({
pluginName: "example",
policy,
capabilities,
checker,
wasmBytes,
});
const install = createSandboxedWasmPluginModule({ sandbox, manifest });
await host.installPlugin({
module: install.module,
manifest: install.manifest,
manifestHash,
specifier: "example@0.1.0",
routes: install.routes,
});
```
## Usage: Advanced
```ts
import { createSandboxWasmBridgeInstruments } from "@suaveplan/api-sandbox-wasm-host-bridge/instruments";
import { createSandboxedWasmPluginModule } from "@suaveplan/api-sandbox-wasm-host-bridge/bridge";
const instruments = createSandboxWasmBridgeInstruments({
pluginName: manifest.name,
meterProvider: customProvider,
});
const install = createSandboxedWasmPluginModule({
sandbox,
manifest,
instruments,
});
install.module.start();
try {
await runRequests(install.routes);
} finally {
install.module.stop();
}
```
## API Reference
- `createSandboxedWasmPluginModule(options): SandboxedWasmPluginInstall` — throws `SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE` on ABI mismatch.
- `CreateSandboxedWasmPluginModuleOptions` — `{ sandbox, manifest, instruments? }`.
- `SandboxedWasmPluginInstall` — `{ module, manifest, routes, lifecycle, sandbox }`.
- `SandboxedWasmPluginModule` — plugin-module facade with `name`, `version`, `requires`, `isolation: "sandboxed"`, `trustLevel`, `start()`, `stop()`.
## Implementation Notes
The per-route closure is async because reading the host's `Request.body` requires awaiting the `arrayBuffer()` promise; the guest call itself is synchronous (WASM exports are synchronous unless the module uses the JS Promise Integration proposal, which this bridge does not target). Base64-url-no-pad encoding/decoding is inlined rather than delegated to a foundation utility because the bridge must boot before any optional foundation polyfill has a chance to load — keeping the codec self-contained avoids a circular load order with `@suaveplan/api-host`. `Object.freeze` is applied to the `routes` array AND each route's `options` / `params` / `statusMap` records so the host's downstream code cannot mutate the bridge's view by accident.

View File

@@ -0,0 +1,82 @@
# Error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the ten stable `SAND_WASM_BRIDGE.*` error codes raised on production paths in `@suaveplan/api-sandbox-wasm-host-bridge`, and exports the `SandboxWasmBridgeError` `SuaveplanError` subclass that carries them. Every bridge throw uses one of these codes so operators and the host's error boundary can branch on a stable `code` string without parsing messages. Seven codes serve the direct-i32-i64 variant; three additional codes (`FRAME_OVERSIZE`, `PROTOCOL_MISMATCH`, `STDIO_TIMEOUT`) cover failure modes that are unique to the WASI-stdio variant.
## Features
- `defineCodes`-backed `SAND_WASM_BRIDGE_CODES` registry — frozen, fully typed, identity-checked by `@suaveplan/error`.
- `SandboxWasmBridgeError` subclass — public constructor (rule §19), pinned `name` for stack-trace identification, `code` + `cause` + `context` carried through `SuaveplanErrorOptions`.
- Ten codes covering the entire bridge production surface: surface validation, memory allocation, marshal/unmarshal, trap classification, capability denial, lifecycle faults, plus the WASI-stdio-specific frame-oversize, protocol-mismatch, and stdio-timeout cases.
- Capability denials are deliberately surfaced through the canonical `SAND_PERM.DENIED` code in the host-facing path; the bridge-local `CAPABILITY_DENIED` code is reserved for diagnostics that prefer namespace locality.
## Usage: Basic
```ts
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
throw new SandboxWasmBridgeError(
SAND_WASM_BRIDGE_CODES.MEMORY_ALLOC_FAILED,
"error.sand_wasm_bridge.memory_alloc_failed",
{ context: { len: 1024 } },
);
```
## Usage: Advanced
```ts
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
type SandboxWasmBridgeCode,
} from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
function isBridgeError(
err: unknown,
): err is SandboxWasmBridgeError {
return err instanceof SandboxWasmBridgeError;
}
try {
await runBridge();
} catch (err) {
if (!isBridgeError(err)) throw err;
const code: SandboxWasmBridgeCode = err.code;
switch (code) {
case SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE:
console.error("missing ABI exports", err.context?.["missing"]);
break;
case SAND_WASM_BRIDGE_CODES.HANDLER_TRAPPED:
console.error(
"wasm handler trap",
err.context?.["trapKind"],
err.cause,
);
break;
case SAND_WASM_BRIDGE_CODES.LIFECYCLE_FAILED:
console.error("lifecycle failure", err.context?.["phase"]);
break;
default:
console.error("bridge error", err.code, err.cause);
}
}
```
## API Reference
- `SAND_WASM_BRIDGE_CODES` — frozen record mapping each short name to the fully-qualified code string.
- `SandboxWasmBridgeCode` — string-literal union of all seven codes.
- `SandboxWasmBridgeError` — class extends `SuaveplanError<SandboxWasmBridgeCode>` with a public constructor; `name === "SandboxWasmBridgeError"`.
- `SAND_WASM_BRIDGE_ERROR_NAME` — the pinned `name` constant.
## Implementation Notes
The seven codes deliberately track the seven failure modes the bridge can produce from a real WASM module: missing exports caught at init, alloc traps mid-marshal, JSON encode/decode failures, trap classification from `WebAssembly.RuntimeError` messages, denied capability calls, and lifecycle phase trap. The `CAPABILITY_DENIED` code exists for symmetry with the other six but the bridge's user-facing path always re-throws the `SAND_PERM.DENIED` code from `@suaveplan/api-sandbox-permissions` so request handlers see the canonical denial code regardless of which sandbox tier produced it.

View File

@@ -0,0 +1,67 @@
# Bridge instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createSandboxWasmBridgeInstruments(config)` constructs the OTel counter + histogram registry every production path in the wasm host bridge writes to. Four counters track invocations, traps, marshal failures, and lifecycle failures; one histogram tracks handler duration in milliseconds. Acquiring the instruments once at bridge construction time keeps the first `.add()` / `.record()` call after that point allocation-free.
## Features
- Pinned counter + histogram name arrays — a regression that drops one surfaces as a typecheck error in `bridge.ts` rather than as a silent metric gap.
- `recordInvocation(route, outcome)` is the single emit point for per-request counters: it always bumps `handler_invocations_total` and additionally bumps the trap-or-marshal-failure counter when the outcome warrants.
- `recordDuration(route, ms)` and `recordLifecycleFailure(phase)` are dedicated single-purpose recorders so call sites read clearly.
- Meter provider falls back to the OTel global, which is the no-op provider when no SDK is installed — tests therefore need no provider injection.
- Every counter/histogram emission carries an `abi_variant` attribute (`"direct-i32-i64"` or `"wasi-stdio"`) pinned at registry-construction time so dashboards can split traffic between the two ABI paths without re-reading the package's source.
## Usage: Basic
```ts
import { createSandboxWasmBridgeInstruments } from "@suaveplan/api-sandbox-wasm-host-bridge/instruments";
const instruments = createSandboxWasmBridgeInstruments({ pluginName: "example" });
instruments.recordInvocation("GET /users", "ok");
instruments.recordDuration("GET /users", 4.2);
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { createSandboxWasmBridgeInstruments } from "@suaveplan/api-sandbox-wasm-host-bridge/instruments";
const instruments = createSandboxWasmBridgeInstruments({
pluginName: "example",
meterProvider: metrics.getMeterProvider(),
});
function instrumentedInvoke<T>(
route: string,
handler: () => T,
): T {
const t0 = performance.now();
try {
const result = handler();
instruments.recordInvocation(route, "ok");
instruments.recordDuration(route, performance.now() - t0);
return result;
} catch (err) {
instruments.recordInvocation(route, "trap");
instruments.recordDuration(route, performance.now() - t0);
throw err;
}
}
```
## API Reference
- `SAND_WASM_BRIDGE_COUNTER_NAMES` / `SAND_WASM_BRIDGE_HISTOGRAM_NAMES` — pinned const-tuples.
- `SandboxWasmBridgeInstrumentsConfig` — `{ pluginName, meterProvider? }`.
- `SandboxWasmBridgeInstruments` — frozen record with `meter`, `counters`, `histograms`, and three recorder methods.
- `createSandboxWasmBridgeInstruments(config): SandboxWasmBridgeInstruments`.
## Implementation Notes
The counter for `handler_invocations_total` is always emitted, and the trap / marshal-failure-specific counters are emitted IN ADDITION when relevant. This lets dashboards compute both "total request rate per plugin" (one source) and "trap rate per plugin" (another source) without joining label dimensions across two counters. Using `Object.create(null)` for the counter / histogram bags avoids prototype-pollution surface area when the label values contain user-controlled strings (e.g. the `route` label is plugin-provided).

View File

@@ -0,0 +1,74 @@
# Idempotent lifecycle forwarders
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createIdempotentLifecycle(deps)` returns the `start` / `dispose` pair the bridge installs on the host's `PluginModule.start` / `dispose` hooks. Each call delegates to the matching `__suaveplan_*` WASM export exactly once across repeated invocations and re-surfaces guest traps as `SAND_WASM_BRIDGE.LIFECYCLE_FAILED` with the `phase` set to `"start"` or `"dispose"`. The forwarders also chain through to the underlying `WasmSandboxHandle.dispose()` so the sandbox is torn down even when the guest's dispose hook trapped.
## Features
- Once-flag-guarded `start` / `dispose` — a second call is a no-op, matching the host's contract for hot-reload boundaries.
- Trap classification: `context.trapKind` is filled in from the raw `WebAssembly.RuntimeError` so logs distinguish `unreachable` from `out_of_bounds` even mid-dispose.
- Sandbox-handle cleanup runs even when the guest's `__suaveplan_dispose` traps — preventing leaked instances in the operator's process under partial-failure conditions.
- Observable state — `started()` / `disposed()` return the bridge's view of the lifecycle for diagnostics and conformance harness assertions.
## Usage: Basic
```ts
import { createIdempotentLifecycle } from "@suaveplan/api-sandbox-wasm-host-bridge/lifecycle";
const lifecycle = createIdempotentLifecycle({
exports,
pluginName: "example",
disposeSandbox: () => sandbox.dispose(),
});
lifecycle.start();
// ... handle requests ...
lifecycle.dispose();
lifecycle.dispose(); // no-op
```
## Usage: Advanced
```ts
import {
createIdempotentLifecycle,
type LifecyclePhase,
} from "@suaveplan/api-sandbox-wasm-host-bridge/lifecycle";
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
function safeStart(lifecycle: ReturnType<typeof createIdempotentLifecycle>): boolean {
try {
lifecycle.start();
return true;
} catch (err) {
if (
err instanceof SandboxWasmBridgeError &&
err.code === SAND_WASM_BRIDGE_CODES.LIFECYCLE_FAILED
) {
const phase = err.context?.["phase"] as LifecyclePhase;
console.error(`lifecycle ${phase} failed`, err.cause);
return false;
}
throw err;
}
}
```
## API Reference
- `LifecyclePhase` — `"start" | "dispose"`.
- `IdempotentLifecycle` — record with `start()`, `dispose()`, `started()`, `disposed()`.
- `IdempotentLifecycleDeps` — `{ exports, pluginName, disposeSandbox? }`.
- `createIdempotentLifecycle(deps): IdempotentLifecycle`.
## Implementation Notes
The dispose-side cleanup logic runs `disposeSandbox` both on the happy path and inside the `catch` block: this is intentional. A trapped guest dispose still needs the sandbox handle released, but the original trap is what reaches the caller because operators care about the WASM-side fault more than about the cleanup. The inner `disposeSandbox` call in the catch path is wrapped in its own try/catch with empty body to ensure the original `SandboxWasmBridgeError` is the one that propagates regardless of cleanup outcome.

View File

@@ -0,0 +1,52 @@
# Route discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`discoverRoutes(exports, pluginName)` calls the guest's `__suaveplan_routes` export exactly once at bridge-init time, unpacks the `(pointer, length)` pair the guest returns as a packed `bigint`, reads the JSON-encoded `SerializedRoute[]` from linear memory, frees the guest-allocated buffer, and returns the typed array. The bridge uses this list to build the `PipelineRoute` array installed on the host.
## Features
- Single bridge-init call — the result is cached by `createSandboxedWasmPluginModule`; the guest is never asked for its routes again.
- Trap-protected: a fault inside `__suaveplan_routes` surfaces as `SAND_WASM_BRIDGE.HANDLER_TRAPPED` with `context.phase: "routes"` and a classified `trapKind`.
- Malformed-payload aware: bytes that fail Zod validation surface as `SAND_WASM_BRIDGE.UNMARSHAL_FAILED` (re-thrown from `unmarshalRoutes`).
- Empty-list tolerant: a guest returning `(0, 0)` is treated as "no routes" rather than as an error, so plugins with lifecycle-only hooks remain valid.
- Frees the guest's buffer before returning — the bridge never leaves linear-memory pages owned by the guest's allocator dangling after init.
## Usage: Basic
```ts
import { discoverRoutes } from "@suaveplan/api-sandbox-wasm-host-bridge/route-discovery";
const routes = discoverRoutes(exports, "example");
```
## Usage: Advanced
```ts
import { discoverRoutes } from "@suaveplan/api-sandbox-wasm-host-bridge/route-discovery";
import type { SerializedRoute } from "@suaveplan/api-types/sandbox-bridge";
function buildRouteIndex(
exports: import("@suaveplan/api-sandbox-wasm-host-bridge/wasm-abi").WasmAbiExports,
plugin: string,
): ReadonlyMap<string, SerializedRoute> {
const routes = discoverRoutes(exports, plugin);
const index = new Map<string, SerializedRoute>();
for (const route of routes) {
index.set(`${route.method} ${route.path}`, route);
}
return index;
}
```
## API Reference
- `discoverRoutes(exports: WasmAbiExports, pluginName: string): readonly SerializedRoute[]`.
## Implementation Notes
The empty-list case (`pointer === 0 || length === 0`) is reached when the guest's `__suaveplan_routes` returns the literal `bigint` `0n`. We still attempt to free the buffer when `pointer !== 0` to be defensive — a guest that returns `(ptr, 0)` may have legitimately allocated a zero-length buffer that the host still owns the responsibility to release. The freeBuffer helper is a no-op when `pointer === 0`, so the double-guard is safe.

View File

@@ -0,0 +1,64 @@
# WASM host-bridge conformance harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runWasmHostBridgeConformance({ buildBridge })` is the contract test every wasm host-bridge configuration must pass. The harness exercises five invariants against a bridge factory the caller supplies, ensuring downstream packages (and third-party adapters) preserve the behaviour the bridge promises: route registration, request round-trip, lifecycle idempotency, trap propagation, and capability-gate fidelity. The harness lives under `./testing` so it ships with the package but does not pull into the production bundle.
## Features
- Five invariants run as standard Vitest test cases — failures look identical to any other test failure in CI output.
- Single factory contract — `buildBridge(kind)` returns a fresh install + manifest for each scenario kind so tests can reuse fixtures without leaking state.
- `assertBridgeThrowsOnTrap(factory)` — a focused self-test the harness's own tests use to verify that the trap-propagation invariant cannot be satisfied by a silent-retry bridge.
- Uses `@suaveplan/testing/runner` so adapters compile under either Vitest (default) or Bun's `bun:test` (the runner-conditional export).
- No mocks — every invariant runs against a real `WebAssembly.Instance` produced by the package's hand-encoded fixture builder.
## Usage: Basic
```ts
import { runWasmHostBridgeConformance } from "@suaveplan/api-sandbox-wasm-host-bridge/testing";
runWasmHostBridgeConformance({
async buildBridge(kind) {
const { install, manifest } = await myBridgeFactory(kind);
return { install, manifest };
},
});
```
## Usage: Advanced
```ts
import {
assertBridgeThrowsOnTrap,
runWasmHostBridgeConformance,
type ConformanceBridgeFactory,
} from "@suaveplan/api-sandbox-wasm-host-bridge/testing";
const factory: ConformanceBridgeFactory = async (kind) => {
const sandbox = await buildSandbox(kind);
const manifest = buildManifest(kind);
const install = createSandboxedWasmPluginModule({ sandbox, manifest });
return { install, manifest };
};
runWasmHostBridgeConformance({ buildBridge: factory });
// Explicit self-test:
await assertBridgeThrowsOnTrap(factory);
```
## API Reference
- `runWasmHostBridgeConformance(options): void` — registers the five invariants as Vitest test cases.
- `RunWasmHostBridgeConformanceOptions` — `{ buildBridge: ConformanceBridgeFactory }`.
- `ConformanceBridgeFactory` — `(kind: ConformanceFixtureKind) => Promise<{ install, manifest }>`.
- `ConformanceFixtureKind` — `"happy" | "trapping-handler" | "trapping-start" | "trapping-dispose" | "missing-export" | "capability-denied"`.
- `assertBridgeThrowsOnTrap(factory): Promise<void>` — focused self-test.
## Implementation Notes
The harness deliberately calls `install.module.start()` and `install.module.stop()` around each route invocation rather than at suite level so a failing invariant cannot leak sandbox state into the next test. Repeated `start` / `stop` calls are expected and tolerated (the idempotency invariant explicitly tests them). The capability-gate invariant relies on the fixture WASM emitting an error JSON with `errorCode: "SAND_PERM.DENIED"`; the bridge then re-throws as the canonical `SandboxPermissionError` from `@suaveplan/api-sandbox-permissions`. This indirection (via a JSON error code rather than a host-side capability call) lets the harness exercise the gate without standing up a real WASI capability host.

View File

@@ -0,0 +1,153 @@
# `wasi-stdio-frame`
## Purpose
Owns the WASI-stdio wire format: a single length-prefixed JSON frame
exchanged on the guest's standard streams. Encodes outgoing
`PluginEnvelopeIn` values into `[u32 BE length][JSON bytes]` byte
buffers and decodes incoming frames back into Zod-validated
`PluginEnvelopeOut` values. Every failure mode surfaces as a stable
`SAND_WASM_BRIDGE.*` code so the caller can branch on `code` without
parsing messages.
The encoder refuses oversize frames BEFORE allocating the output
buffer, so no partial writes ever reach fd 0. The decoder enforces the
same 16 MiB ceiling on incoming frames AND validates the JSON payload
against the schema for `PluginEnvelopeOut`, preventing a malformed
sandbox reply from propagating into the host pipeline.
## Features
- `encodeWasiStdioFrame(envelope, pluginName)` — emit a single frame
ready to write into fd 0 in one syscall.
- `decodeWasiStdioFrame(bytes, pluginName)` — parse one frame and
validate its envelope against the strict Zod schema. Returns a
type-narrowed `PluginEnvelopeOut`.
- `PluginEnvelopeInSchema` / `PluginEnvelopeOutSchema` — Zod schemas
re-exported for callers that need to validate other byte sources
(e.g. integration tests, replay tooling).
- `MAX_WASI_STDIO_FRAME_BYTES` — 16 MiB ceiling per direction.
- Six distinct decoder failure reasons each carry a specific
`context.reason`: `short_header`, `length_mismatch`, `invalid_utf8`,
`invalid_json`, `shape_mismatch`, plus the oversize case under its
own code.
## Usage: Basic
```ts
import {
decodeWasiStdioFrame,
encodeWasiStdioFrame,
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
const frame = encodeWasiStdioFrame({ kind: "routes" }, "example");
// → write frame to fd 0
declare const incoming: Uint8Array;
const reply = decodeWasiStdioFrame(incoming, "example");
if (reply.kind === "routes") {
for (const route of reply.routes) {
// route.method, route.path
}
}
```
## Usage: Advanced
```ts
import {
decodeWasiStdioFrame,
encodeWasiStdioFrame,
MAX_WASI_STDIO_FRAME_BYTES,
PluginEnvelopeOutSchema,
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
import { SandboxWasmBridgeError } from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
// Catch the oversize-frame case BEFORE the write hits the guest:
try {
const frame = encodeWasiStdioFrame(
{ kind: "request", request: buildHugeRequest() },
"example",
);
void frame;
} catch (err) {
if (
err instanceof SandboxWasmBridgeError &&
err.code === "SAND_WASM_BRIDGE.FRAME_OVERSIZE"
) {
// Surface a 413 to the caller without touching the guest.
}
}
// Use the raw schema to validate replay-buffer bytes outside the
// runtime path:
declare const replayBytes: Uint8Array;
declare function buildHugeRequest(): never;
const view = new DataView(
replayBytes.buffer,
replayBytes.byteOffset,
replayBytes.byteLength,
);
const length = view.getUint32(0, false);
if (length <= MAX_WASI_STDIO_FRAME_BYTES) {
const result = PluginEnvelopeOutSchema.safeParse(
JSON.parse(
new TextDecoder().decode(replayBytes.subarray(4, 4 + length)),
),
);
void result;
}
declare function decodeRoundtrip(): void;
void decodeRoundtrip;
void decodeWasiStdioFrame;
```
## API Reference
### `encodeWasiStdioFrame(envelope, pluginName): Uint8Array`
Throws:
- `SAND_WASM_BRIDGE.MARSHAL_FAILED` — `JSON.stringify` rejected the
envelope (e.g. it carried a `BigInt`).
- `SAND_WASM_BRIDGE.FRAME_OVERSIZE` — payload exceeds
`MAX_WASI_STDIO_FRAME_BYTES`.
### `decodeWasiStdioFrame(bytes, pluginName): PluginEnvelopeOut`
Throws `SAND_WASM_BRIDGE.PROTOCOL_MISMATCH` for `short_header`,
`length_mismatch`, `invalid_utf8`, `invalid_json`, `shape_mismatch`;
`SAND_WASM_BRIDGE.FRAME_OVERSIZE` when the declared length exceeds
the ceiling.
### `MAX_WASI_STDIO_FRAME_BYTES`
`16 * 1024 * 1024` — fixed by the protocol.
### `PluginEnvelopeInSchema` / `PluginEnvelopeOutSchema`
Zod schemas for the two envelope unions. Strict object shapes;
`safeParse` returns the discriminated union after passing.
## Implementation Notes
- Length is encoded big-endian (`DataView.setUint32(..., false)`) to
match every other length-prefix wire format in the Suaveplan stack
(CBOR, drive-template manifests).
- The encoder refuses oversize frames BEFORE allocating the output
buffer — emitting a partial header followed by truncated payload
would leave the guest's fd 0 in an unrecoverable state and force a
sandbox restart.
- The decoder rejects extra bytes implicitly by ignoring anything past
`HEADER_BYTES + length` — the runner is expected to slice exactly
one frame's worth of bytes per call. Extra bytes after a valid
frame are the caller's responsibility (they belong to the NEXT
frame).
- Zod schemas are pinned at module-load time so each
encode/decode is allocation-stable past the first invocation.
- The `PluginEnvelopeIn` schema is exported for callers that need to
validate envelopes they constructed by hand (e.g. test factories
authoring corrupt payloads); the production encode path does NOT
validate outgoing envelopes — type-narrowed callers cannot
construct an invalid `PluginEnvelopeIn`.

View File

@@ -0,0 +1,111 @@
# `wasi-stdio-imports`
## Purpose
Builds the `wasi_snapshot_preview1` imports table the WASI-stdio
runner threads into `WebAssembly.instantiate`. Centralises the WASI
shim implementations so the runner stays focused on protocol
orchestration; testing each WASI hook in isolation becomes a unit
test against this module rather than an integration test that needs
a hand-encoded fixture per hook.
The five hooks the bridge's ABI validator requires (`fd_read`,
`fd_write`, `environ_get`, `environ_sizes_get`, `proc_exit`) are
always present. Additional common WASI hooks (`fd_close`,
`clock_time_get`, `random_get`, `path_open`, `fd_seek`, `fd_fdstat_get`,
`fd_prestat_get`, `fd_prestat_dir_name`) are wired with safe defaults
so a guest that calls them does not abort instantiation. The
defaults are intentionally restrictive — `path_open` returns ENOENT
(2), `fd_prestat_get` returns BADF (8) — to keep the wasi-stdio
variant focused on its stdin/stdout protocol surface.
## Features
- Single-call builder; returns a frozen-shape record ready to feed
into `WebAssembly.Instance`.
- `memoryRef` thunk pattern — callers wire the imports BEFORE the
instance is constructed; the thunk resolves to the bound memory
once the runner has called `bindMemory`.
- `random_get` uses the host's `crypto.getRandomValues`, the same
source as `@suaveplan/api-sandbox-wasm`'s default snapshot.
- `clock_time_get` returns wall-clock nanoseconds (`Date.now() *
1_000_000`). Test consumers wanting a deterministic clock build
their own imports table.
## Usage: Basic
```ts
import { buildWasiStdioImports } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
import { createWasiStdioStreams } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
const streams = createWasiStdioStreams({ pluginName: "ex" });
let memory: WebAssembly.Memory | undefined;
const imports = buildWasiStdioImports({
streams,
memoryRef: () => {
if (memory === undefined) throw new Error("memory not bound");
return memory;
},
});
void imports;
```
## Usage: Advanced
```ts
import { buildWasiStdioImports } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
declare const streams: import("./wasi-stdio-streams.js").WasiStdioStreams;
declare const memory: WebAssembly.Memory;
const imports = buildWasiStdioImports({
streams,
memoryRef: () => memory,
});
// Verify the shape — every guarded WASI hook is present.
for (const name of [
"fd_read",
"fd_write",
"environ_get",
"environ_sizes_get",
"proc_exit",
"fd_close",
"clock_time_get",
"random_get",
"path_open",
"fd_seek",
] as const) {
if (typeof imports[name] !== "function") {
throw new Error(`missing import ${name}`);
}
}
```
## API Reference
### `buildWasiStdioImports({ streams, memoryRef })`
Returns a record keyed by WASI snapshot-preview1 function name. Every
entry is a function with the WASI ABI signature. Pass the returned
record under the `wasi_snapshot_preview1` namespace when
instantiating a module.
## Implementation Notes
- The `(...args: never[]) => number | undefined` signature is the
union of `proc_exit` (void return) with every other entry (number
return). The `as never` cast at each call site is structurally
safe — WASI host imports are typed at the JS API by argument
arity, not return shape.
- `path_open` returns `2` (`ENOENT`) so a guest's `path_open` request
fails synchronously instead of trapping. Operators wanting real
preopens compose `extraImports` on the runner.
- `fd_prestat_get` returns `8` (`BADF`) — the host has zero
preopened fds beyond stdin/stdout/stderr. The matching
`fd_prestat_dir_name` is wired for symmetry; both return BADF.
- `clock_time_get` writes to the supplied `timePtr` then returns
`0`; the value is little-endian as required by the WASI ABI.

View File

@@ -0,0 +1,106 @@
# `wasi-stdio-lifecycle`
## Purpose
Idempotent `start` / `dispose` forwarder for the WASI-stdio variant.
Each call sends one lifecycle envelope (`{kind:"start"}` or
`{kind:"dispose"}`) to the guest and verifies the reply is
`{kind:"ok"}`. Wraps the host's `IdempotentLifecycle` shape so a
caller swapping between variants sees no API divergence.
The two methods are guarded by once-flags so the host's "reload twice"
or "dispose-then-redispose" patterns never double-invoke the guest's
lifecycle hooks. `dispose()` ALWAYS chains through to the runner's
dispose, even when the guest's reply was unexpected — the host must
never leak the underlying instance.
## Features
- `start()` / `dispose()` — idempotent forwarders matching the direct
variant's `IdempotentLifecycle` interface, so the bridge factory
produces a single shape across variants.
- `started()` / `disposed()` — getters for the once-flags, used by the
conformance harness invariant.
- Plugin-originated `{kind:"error"}` replies surface as
`SAND_WASM_BRIDGE.LIFECYCLE_FAILED` carrying the plugin's code.
- Unexpected reply kinds (e.g. `{kind:"response"}` to a `start`)
surface as `LIFECYCLE_FAILED` with `context.expected = "ok"` and
`context.received = "<kind>"`.
## Usage: Basic
```ts
import {
createWasiStdioLifecycle,
createWasiStdioRunner,
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
declare const module: WebAssembly.Module;
const runner = createWasiStdioRunner({ pluginName: "ex", wasmModule: module });
const lifecycle = createWasiStdioLifecycle({ runner, pluginName: "ex" });
lifecycle.start();
lifecycle.dispose();
```
## Usage: Advanced
```ts
import { createWasiStdioLifecycle } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
declare const runner: import("./wasi-stdio-runner.js").WasiStdioRunner;
const lifecycle = createWasiStdioLifecycle({
runner,
pluginName: "billing",
});
try {
lifecycle.start();
// Repeated start is a no-op — host's reload boundary safely re-calls it.
lifecycle.start();
} catch (err) {
if (
err instanceof SandboxWasmBridgeError &&
err.code === SAND_WASM_BRIDGE_CODES.LIFECYCLE_FAILED
) {
// Guest reported a fatal init failure — dispose the runner.
}
throw err;
} finally {
lifecycle.dispose();
}
```
## API Reference
### `createWasiStdioLifecycle(deps): WasiStdioLifecycle`
- `deps.runner: WasiStdioRunner` — runner the lifecycle drives.
- `deps.pluginName: string` — error-context label.
Returns `WasiStdioLifecycle`:
- `start(): void` — first call sends `{kind:"start"}` and verifies
`{kind:"ok"}`. Subsequent calls are no-ops.
- `dispose(): void` — first call sends `{kind:"dispose"}` then chains
to `runner.dispose()`. Subsequent calls are no-ops. The chain runs
even if the dispose envelope reply was unexpected.
- `started(): boolean` / `disposed(): boolean` — once-flag readers.
## Implementation Notes
- The once-flag for each method is set BEFORE the envelope is sent.
This guarantees that a trap during the envelope round-trip does not
leave the flag flipped back to `false`, which would invite a
second `_start` invocation on the guest.
- `dispose()` always calls `runner.dispose()` in its `finally` block.
An unexpected reply during dispose is reported via the thrown
`SandboxWasmBridgeError` AND the underlying instance is still torn
down. This matches the direct variant's "never leak the handle"
guarantee.
- The plugin's own `errorCode` from a `{kind:"error"}` reply becomes
the thrown error's `code`. Operators see the actual root cause
rather than a generic bridge-namespaced label.

View File

@@ -0,0 +1,83 @@
# `wasi-stdio-memory-ref`
## Purpose
Lazy-bound memory reference cell threaded through the WASI-stdio
runner's WASI snapshot. The runner has to wire imports BEFORE the
WASM instance exists, but every memory-touching WASI hook needs to
read from the bound `WebAssembly.Memory` AFTER instantiation. The
cell decouples those two timings: the runner calls `bind(memory)`
exactly once, then every subsequent `get()` returns the bound memory.
Calling `get()` BEFORE `bind()` raises a "host bug" Error rather
than a WASI errno — the scenario indicates the runner forgot to wire
memory, not that the guest is doing something wrong, so the failure
must surface visibly to operators reading the host's stack trace.
## Features
- `createMemoryRefCell(): MemoryRefCell` — fresh cell, unbound.
- `bind(memory)` — single-call binding; subsequent rebinds replace
the reference, by design (the runner re-uses a single cell per
instance).
- `get()` — returns the bound memory or throws a host-bug error.
## Usage: Basic
```ts
import { createMemoryRefCell } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
const cell = createMemoryRefCell();
declare const memory: WebAssembly.Memory;
cell.bind(memory);
const bound = cell.get();
void bound;
```
## Usage: Advanced
```ts
import {
buildWasiStdioImports,
createMemoryRefCell,
createWasiStdioStreams,
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
const cell = createMemoryRefCell();
const streams = createWasiStdioStreams({ pluginName: "ex" });
const imports = buildWasiStdioImports({
streams,
memoryRef: cell.get,
});
declare const module: WebAssembly.Module;
const instance = new WebAssembly.Instance(module, {
wasi_snapshot_preview1: imports as never,
});
cell.bind(instance.exports["memory"] as WebAssembly.Memory);
streams.bindMemory(cell.get());
```
## API Reference
### `MemoryRefCell`
```ts
interface MemoryRefCell {
bind(memory: WebAssembly.Memory): void;
get(): WebAssembly.Memory;
}
```
### `createMemoryRefCell(): MemoryRefCell`
Returns a fresh unbound cell. Idempotent.
## Implementation Notes
- The cell is intentionally not a closure-baked thunk on the runner —
extracting it makes the "memory not bound" branch unit-testable
without needing a WASM module with a `start` section.
- Re-binding (`bind(a); bind(b)`) replaces the reference. The runner
never calls bind twice; tests MAY exercise the rebind path to
prove the cell's mutability semantics.

View File

@@ -0,0 +1,94 @@
# `wasi-stdio-routes`
## Purpose
Bridge-init route discovery for the WASI-stdio variant. Issues a single
`{kind:"routes"}` envelope to the guest and validates the reply
against the protocol's expected shape. Returns the typed
`SerializedRoute[]` ready to be wired into the host's
`PipelineRoute[]`. Centralises the "expect-routes-or-error" branching
so the bridge factory stays focused on assembly.
## Features
- One round-trip per call — the bridge invokes this exactly once at
init time, immediately after the runner is built and before any
`start` lifecycle envelope.
- Plugin-originated errors are re-thrown carrying the plugin's own
`code` (not the bridge's namespace) so consumer error boundaries see
the failure root cause.
- Unexpected reply kinds (e.g. a `response` envelope to a `routes`
probe) surface as `SAND_WASM_BRIDGE.PROTOCOL_MISMATCH` with
`context.expected = "routes"` and `context.received = "<kind>"`.
## Usage: Basic
```ts
import {
createWasiStdioRunner,
discoverRoutesViaStdio,
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
declare const module: WebAssembly.Module;
const runner = createWasiStdioRunner({ pluginName: "ex", wasmModule: module });
const routes = discoverRoutesViaStdio(runner, "ex");
for (const r of routes) {
void r.method;
void r.path;
}
```
## Usage: Advanced
```ts
import { discoverRoutesViaStdio } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
import {
SAND_WASM_BRIDGE_CODES,
SandboxWasmBridgeError,
} from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
declare const runner: import("./wasi-stdio-runner.js").WasiStdioRunner;
try {
const routes = discoverRoutesViaStdio(runner, "ex");
void routes;
} catch (err) {
if (
err instanceof SandboxWasmBridgeError &&
err.code === SAND_WASM_BRIDGE_CODES.PROTOCOL_MISMATCH
) {
// Likely a stale plugin that does not understand the routes probe.
// Bridge MUST refuse to install — proceeding would leave the host
// without a route table.
}
throw err;
}
```
## API Reference
### `discoverRoutesViaStdio(runner, pluginName): readonly SerializedRoute[]`
- `runner` — a configured `WasiStdioRunner`.
- `pluginName` — error-context label.
- Returns the `SerializedRoute[]` carried by the plugin's
`{kind:"routes"}` reply.
Throws `SandboxWasmBridgeError` with code `PROTOCOL_MISMATCH` on
unexpected reply kinds or sandbox-originated errors.
## Implementation Notes
- The function does NOT swallow the plugin's `error` envelope.
Re-throwing with the plugin's code lets the host's error boundary
decide whether the failure is fatal — a "plugin reported a fatal
init error" is operationally different from "bridge cannot talk to
plugin".
- An empty `routes: []` reply is a valid configuration — the plugin
installs only lifecycle hooks, no HTTP handlers. The function
returns the empty array verbatim; the bridge wires no
`PipelineRoute` records in that case.
- The route shapes are validated as part of `decodeWasiStdioFrame`'s
Zod check before this function ever sees them, so a malformed route
entry has already raised by the time we land here.

View File

@@ -0,0 +1,140 @@
# `wasi-stdio-runner`
## Purpose
Synchronous "one-envelope-per-`_start`" driver for WASI-stdio
sandboxed plugins. Owns the WASM instantiation against a host-supplied
WASI snapshot extended with stdin/stdout/stderr bindings, then exposes
a single `exchange(envelope, phase)` primitive the bridge calls once
per protocol step. Each invocation queues exactly one outgoing frame,
invokes `_start`, and pops exactly one reply frame.
The runner intentionally does NOT model a long-running guest loop.
WebAssembly's JS API is strictly synchronous and would force a busy-
spin if the guest tried to block on empty stdin. The one-shot model
keeps every step bounded and re-entrant for the next request within
the same instance.
## Features
- Self-instantiates the WASM module — the runner is the source of
truth for the WASI snapshot it wires.
- Stdin/stdout backed by `createWasiStdioStreams`; stderr lines
forward to a `WasiStdioLoggerSink`.
- `proc_exit` ignored (not treated as a trap) — Javy-compiled plugins
call it after every `_start`.
- Per-step deadline via `stdioTimeoutMs` (default 5s); a missing reply
frame surfaces as `SAND_WASM_BRIDGE.STDIO_TIMEOUT` with `phase` and
elapsed milliseconds in the error context.
- Trap propagation honours the seven-kind classification from
`classifyTrap` — `unreachable` mid-handler surfaces as
`HANDLER_TRAPPED` with `trapKind: "unreachable"`.
- `dispose()` is idempotent and chains through to the optional
`WasmSandboxHandle` it was constructed with.
- `extraImports` lets the bridge wire `suaveplan_caps.*` capability
imports from the sandbox into the runner's parallel instance so
`SAND_PERM.DENIED` flows continue working under wasi-stdio.
## Usage: Basic
```ts
import { createWasiStdioRunner } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
declare const module: WebAssembly.Module;
const runner = createWasiStdioRunner({
pluginName: "example",
wasmModule: module,
});
const reply = runner.exchange({ kind: "routes" }, "routes");
if (reply.kind === "routes") {
for (const route of reply.routes) {
void route;
}
}
runner.dispose();
```
## Usage: Advanced
```ts
import { createWasiStdioRunner } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
import type { LoggerLike } from "@suaveplan/types";
declare const module: WebAssembly.Module;
declare const logger: LoggerLike;
declare const capabilityImports: WebAssembly.ModuleImports;
const runner = createWasiStdioRunner({
pluginName: "ex",
wasmModule: module,
logger,
stdioTimeoutMs: 30_000,
extraImports: { suaveplan_caps: capabilityImports },
});
try {
const ok = runner.exchange({ kind: "start" }, "start");
if (ok.kind !== "ok") {
throw new Error(`unexpected reply ${ok.kind}`);
}
declare function buildRequest(): never;
const response = runner.exchange(
{ kind: "request", request: buildRequest() },
"request",
);
void response;
} finally {
runner.dispose();
}
```
## API Reference
### `createWasiStdioRunner(deps): WasiStdioRunner`
- `deps.pluginName: string` — plugin name (error context label).
- `deps.wasmModule: WebAssembly.Module` — pre-compiled module.
- `deps.logger?: WasiStdioLoggerSink` — stderr line sink.
- `deps.stdioTimeoutMs?: number` — per-step deadline (default 5000).
- `deps.sandbox?: { dispose; disposed }` — sandbox dispose chain.
- `deps.extraImports?: Record<string, ModuleImports>` — composed with
the runner's WASI snapshot at instantiate time.
### `WasiStdioRunner.exchange(envelope, phase): PluginEnvelopeOut`
Sends one envelope, invokes `_start`, pops one reply frame. Throws:
- `SAND_WASM_BRIDGE.HANDLER_TRAPPED` (with `trapKind` + `phase`) on
WASM traps during `_start`.
- `SAND_WASM_BRIDGE.STDIO_TIMEOUT` when no reply frame is buffered
after `_start` returns.
- `SAND_WASM_BRIDGE.LIFECYCLE_FAILED` if called after `dispose()`.
- `SAND_WASM_BRIDGE.FRAME_OVERSIZE` / `MARSHAL_FAILED` / `PROTOCOL_MISMATCH`
from the framer when encoding/decoding fails.
### `WasiStdioRunner.dispose(): void`
Idempotent; chains through to `deps.sandbox.dispose()`.
## Implementation Notes
- The runner wires a minimal WASI snapshot in-process — it does NOT
delegate fs/path/preopens to `@suaveplan/api-sandbox-wasm`'s
`createWasiSnapshot`, because those imports are not part of the
WASI-stdio variant's contract (Javy-compiled plugins use stdin/
stdout exclusively). When permission-policy preopens DO matter for
a hybrid plugin, the consumer composes the additional imports via
`extraImports`.
- `proc_exit` is treated as a normal return. Most WASI runtimes throw
when `_start` calls `proc_exit` non-zero, but Javy's pattern is to
call `proc_exit(0)` after writing the reply; treating it as a
no-op keeps the guest's intent intact.
- `STDIO_TIMEOUT` triggers when `_start` returns without writing any
frame to fd 1. It is not driven by a wall-clock timer — WebAssembly
is synchronous, so there is no thread to interrupt. The `elapsedMs`
context field carries the observed `_start` duration so dashboards
can spot near-misses.
- `dispose()` resets every buffer in addition to chaining the sandbox
dispose. Resetting first guarantees that a future GC cycle on the
runner does not keep the guest's memory pinned through a closure.

View File

@@ -0,0 +1,101 @@
# `wasi-stdio-streams`
## Purpose
Owns the in-process pipe implementation of WASI fd 0 / fd 1 / fd 2 the
host bridge wires into the guest module under the WASI-stdio variant.
Encapsulates the stdin/stdout/stderr buffers so the runner can stay
focused on protocol orchestration. Every byte the guest reads from
fd 0 originated in a `pushStdin` call; every frame the guest writes to
fd 1 surfaces via `popStdoutFrame`; every line on fd 2 is forwarded to
the configured logger sink.
The implementation enforces the WASI snapshot-preview1 `fd_read` /
`fd_write` ABI exactly — host imports return the standard WASI errno
codes (0 = SUCCESS, 8 = BADF). Multi-iovec writes ARE supported: each
iovec is concatenated into the stdout pending buffer; frame demarcation
happens after each `fd_write` call so a single multi-iovec write can
emit one OR many frames.
## Features
- `fdRead` / `fdWrite` with full WASI iovec multi-segment semantics.
- `pushStdin(bytes)` — host queues outgoing frames before invoking
`_start`.
- `popStdoutFrame()` — host pops complete `[u32 BE len][JSON]` frames
one at a time. Returns `undefined` when no complete frame is pending.
- Line-buffered stderr forwarding to a `WasiStdioLoggerSink` (typically
`@suaveplan/logger`).
- `bindMemory(memory)` deferred binding so the streams object survives
WASM instantiation order constraints.
- `reset()` clears every buffer — used by the runner's `dispose()`.
## Usage: Basic
```ts
import { createWasiStdioStreams } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
const streams = createWasiStdioStreams({ pluginName: "ex" });
const imports = {
wasi_snapshot_preview1: {
fd_read: streams.fdRead,
fd_write: streams.fdWrite,
},
};
declare const module: WebAssembly.Module;
const instance = new WebAssembly.Instance(module, imports as never);
streams.bindMemory(instance.exports["memory"] as WebAssembly.Memory);
streams.pushStdin(new Uint8Array([0, 0, 0, 0]));
```
## Usage: Advanced
```ts
import { createWasiStdioStreams } from
"@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
const lines: string[] = [];
const streams = createWasiStdioStreams({
pluginName: "ex",
logger: { warn: (line) => lines.push(line) },
});
declare const memory: WebAssembly.Memory;
streams.bindMemory(memory);
// After the guest runs:
let frame = streams.popStdoutFrame();
while (frame !== undefined) {
void frame; // length-prefixed envelope
frame = streams.popStdoutFrame();
}
streams.reset();
```
## API Reference
### `createWasiStdioStreams({ logger?, pluginName })`
Returns a `WasiStdioStreams` instance with the methods:
- `fdRead(fd, iovsPtr, iovsLen, nreadPtr): number` — WASI snapshot-
preview1 errno return. `fd` must be 0; otherwise returns BADF (8).
- `fdWrite(fd, iovsPtr, iovsLen, nwrittenPtr): number` — accepts fd
1 (stdout) and fd 2 (stderr). Other fds return BADF.
- `pushStdin(bytes)`, `popStdoutFrame()`, `bindMemory(memory)`,
`reset()` — host-side controls.
## Implementation Notes
- WASI iovecs are `[ptr: u32 LE, len: u32 LE]` 8-byte records. The
implementation reads them as little-endian via `DataView`; this
matches every WASI implementation surveyed.
- Frame demarcation uses the protocol's `[u32 BE length]` header (note
the endianness difference from iovec layout — the iovec is a CPU
struct laid out in linear memory, the frame is a protocol record).
- Stderr is line-buffered: bytes accumulate until a newline appears,
then the line is forwarded. A partial line at dispose time is
dropped — operators do not want a half-line in their structured
log feed.
- `bindMemory` must be called before the first `fdRead`/`fdWrite`. A
call without it raises a host-bug error (no WASI errno) because the
scenario indicates a broken host wiring, not a guest fault.