Files
suaveplan/openspec/changes/api-sandbox-wasm-stdio-abi/proposal.md
2026-05-27 23:29:53 +01:00

6.2 KiB

WASI-stdio ABI variant for the WASM host bridge

Package: @suaveplan/api-sandbox-wasm-host-bridge (extension) + @suaveplan/api-types/sandbox-bridge (additive) Status: PROPOSED Type: extension + types-additive

Why

The shipped wasm host-bridge (commit a9fa31db95) accepts WASM modules exporting six custom-shaped functions (__suaveplan_alloc(i32) → i32, __suaveplan_handle_request(i32,i32) → i64, etc.) plus memory. Producing such a module by compiling TypeScript is the job of @suaveplan/wasm-compiler-javy.

When the build agent investigated against Javy v8.1.1 (Bytecode Alliance, current latest), it surfaced an architectural blocker: Javy cannot emit custom-shaped WASM exports. Its -C wit=... flag restricts WIT-exposed exports to "function exports with no arguments and no return values" (verified via javy build -C help). Javy's host-interaction model is fixed: the host writes input to fd 0 (stdin), reads output from fd 1 (stdout). This is the WASI standard interface and is the only ABI Javy honors.

Three resolutions were considered (full transcript in the agent's stop report); the chosen path is to extend the bridge to accept either the existing direct-export ABI OR a WASI-stdio ABI variant. The compiler then becomes a thin Javy wrapper that emits Javy's native artifact — no outer-WASM authorship, no Component Model migration, no bridge-spec rewrite for the existing variant.

Concrete consumer

  • @suaveplan/wasm-compiler-javy (rewritten in a sibling proposal) — every TS→WASM artifact it emits speaks the WASI-stdio variant.
  • Plugin authors who choose Javy as their toolchain (the recommended TS→WASM path).
  • e2e/foundation-stack-full Stage-8 closure smoke — once both proposals ship, api-plugin-example deploys under the wasm tier via Javy + WASI-stdio ABI.

Host bridge

The bridge surface gains one additive option. The default remains the current direct-export ABI so already-shipped consumers see no behaviour change.

type WasmAbiVariant = "direct-i32-i64" | "wasi-stdio";

createSandboxedWasmPluginModule({
    sandbox,
    manifest,
    abiVariant: "wasi-stdio",   // default: "direct-i32-i64"
});

When abiVariant === "wasi-stdio", the bridge:

  1. Skips the six-export ABI validation (those exports won't exist).
  2. Routes every request through the WASI-stdio framing protocol defined below.
  3. Treats the WASM module as a long-running guest process: a single _start call (the WASI entry point) launches the plugin, which then enters a request loop reading from fd 0.

End-to-end test

  • packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/integration/wasi-stdio-flow.test.ts — instantiates a hand-encoded WASM fixture that speaks the WASI-stdio protocol, runs the conformance harness against it, and asserts: route discovery via {kind:"routes"} envelope; request round-trip via {kind:"request"} / {kind:"response"}; error envelope surfaces as SuaveplanError host-side; lifecycle commands ({kind:"start"} / {kind:"dispose"}) idempotent.

What changes

Shared protocol contract (additive to @suaveplan/api-types/sandbox-bridge)

New types alongside the existing SerializedRequest/SerializedResponse/SerializedRoute. Wire format is length-prefixed JSON frames so the protocol survives any WASI implementation's buffering semantics.

// Each envelope is a single WASI-stdio frame:
//   [u32 BE length][JSON bytes of length 'length']

export type PluginEnvelopeIn =
    | { readonly kind: "request"; readonly request: SerializedRequest }
    | { readonly kind: "routes" }
    | { readonly kind: "start" }
    | { readonly kind: "dispose" };

export type PluginEnvelopeOut =
    | { readonly kind: "response"; readonly response: SerializedResponse }
    | { readonly kind: "routes"; readonly routes: readonly SerializedRoute[] }
    | { readonly kind: "ok" }
    | { readonly kind: "error"; readonly code: string; readonly message: string };

export const WASI_STDIO_PROTOCOL_VERSION: 1;

The contract lives in api-types because both bridges (host-side framer) and Javy-compiled plugins (sandbox-side framer, via the suaveplan ABI shim) MUST agree on the wire shape down to the byte. Putting it in either runtime package would re-introduce the SerializedRequest/Response divergence the orchestrator just resolved.

Bridge runtime (extends @suaveplan/api-sandbox-wasm-host-bridge)

  1. src/wasi-stdio/wasi-stdio.ts — the framer: length-prefix encode/decode, Zod-validated envelope schemas, frame-too-large guard (SAND_WASM_BRIDGE.FRAME_OVERSIZE).
  2. src/wasi-stdio/wasi-stdio-runner.ts — wraps a WasmSandboxHandle configured for WASI-stdio: writes envelopes to fd 0, reads from fd 1, surfaces stderr lines as logger events.
  3. src/bridge/bridge.ts — createSandboxedWasmPluginModule becomes a switch (abiVariant). The existing direct-i32-i64 path is renamed src/abi-direct/ and the new path is src/abi-wasi-stdio/. Routes returned by {kind:"routes"} envelope feed into the same BridgedPlugin shape.
  4. New error codes: SAND_WASM_BRIDGE.FRAME_OVERSIZE, SAND_WASM_BRIDGE.PROTOCOL_MISMATCH, SAND_WASM_BRIDGE.STDIO_TIMEOUT. The shipped 7 codes are preserved.
  5. ABI validation: under abiVariant: "wasi-stdio", validate memory + _start + WASI imports (wasi_snapshot_preview1.{fd_read, fd_write, environ_get, environ_sizes_get, ...}). Missing → SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE with context.missing.
  6. Trap classification, capability-gate routing, idempotent lifecycle: all preserved — but lifecycle commands now go through {kind:"start"} / {kind:"dispose"} envelopes, not direct export calls.

Conformance harness (extends)

runWasmHostBridgeConformance({ buildBridge, abiVariant }) gains an abiVariant parameter. Both variants must pass all five existing invariants; the harness picks the right fixture WASM based on the variant.

Out of scope

  • No change to @suaveplan/api-types/sandbox-bridge's SerializedRequest/SerializedResponse/SerializedRoute. They round-trip unchanged through both variants.
  • No change to @suaveplan/api-sandbox-vm-host-bridge. The vm tier uses structuredClone, not stdio.
  • No deprecation of the direct-i32-i64 variant. Both stay first-class.