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

7.4 KiB

Capability Spec Delta: api-sandbox-wasm-host-bridge (WASI-stdio variant)

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

ADDED Requirements

Requirement: createSandboxedWasmPluginModule accepts abiVariant: "wasi-stdio"

The bridge factory MUST accept abiVariant?: "direct-i32-i64" | "wasi-stdio" as an additional option. Default value is "direct-i32-i64" (existing behaviour, unchanged). When abiVariant === "wasi-stdio", the bridge runs the WASI-stdio framing protocol against the sandbox's fd 0 / fd 1 instead of validating direct-i32-i64 exports.

Scenario: Default variant unchanged

  • WHEN createSandboxedWasmPluginModule is called without abiVariant
  • THEN the bridge validates the six direct-export ABI surface exactly as the shipped contract requires

Verified by: tests/integration/full-flow.test.ts::"WASM-exported routes register with the host"


Requirement: WASI-stdio variant validates against the WASI-import surface, not direct exports

Under abiVariant: "wasi-stdio", the bridge MUST validate that the WASM module:

  1. Exports memory AND _start.
  2. Imports the WASI snapshot-preview1 surface (wasi_snapshot_preview1.fd_read, fd_write, environ_get, environ_sizes_get, proc_exit).

Missing any of these raises SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE with context.missing: string[] BEFORE the plugin is offered to the host. The six direct-export ABI functions MUST NOT be required under this variant.

Scenario: WASI-stdio plugin missing _start is rejected at bridge-init

  • WHEN createSandboxedWasmPluginModule({ abiVariant: "wasi-stdio", ... }) is called against a sandbox whose WASM lacks _start
  • THEN the call rejects with code === "SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE" AND context.missing === ["_start"]

Verified by: src/abi-wasi-stdio/abi-wasi-stdio.test.ts::"missing _start rejects at bridge-init"


Requirement: WASI-stdio frame format is length-prefixed JSON

Every envelope exchanged between bridge and plugin MUST be a [u32 BE length][length bytes of UTF-8 JSON] frame. The bridge MUST:

  1. Length-prefix encode outgoing envelopes (PluginEnvelopeIn) before writing to fd 0.
  2. Length-prefix decode incoming envelopes (PluginEnvelopeOut) read from fd 1.
  3. Validate each decoded envelope against the PluginEnvelopeOut Zod schema before acting on it.
  4. Enforce a 16 MiB maximum frame size per direction. Larger frames raise SAND_WASM_BRIDGE.FRAME_OVERSIZE with context.byteLen.

Scenario: 17 MiB outgoing request rejected

  • WHEN the host attempts to send a request whose serialized envelope exceeds 16 MiB
  • THEN the call rejects with code === "SAND_WASM_BRIDGE.FRAME_OVERSIZE" AND context.byteLen > 16777216 AND no bytes are written to fd 0

Verified by: src/wasi-stdio/wasi-stdio-frame.test.ts::"oversize frame rejected before write"


Requirement: Route discovery uses {kind:"routes"} envelope

At bridge-init, the bridge MUST send a {kind: "routes"} envelope to the plugin AND await a {kind: "routes", routes: SerializedRoute[]} reply. The returned routes feed into the BridgedPlugin's routes field. If the plugin replies with {kind: "error", code, message} instead, the bridge MUST raise a SuaveplanError carrying that code (the plugin's own code, not bridge-mapped).

Scenario: Route discovery round-trip

  • WHEN a WASI-stdio plugin exports two routes via {kind:"routes"} reply
  • THEN the BridgedPlugin's routes field contains both AND host.installPlugin(...) registers them

Verified by: tests/integration/wasi-stdio-flow.test.ts::"route discovery via envelope"


Requirement: Request round-trip preserves bytes through WASI-stdio

For each request, the bridge MUST:

  1. Marshal the host Request into SerializedRequest.
  2. Wrap it in {kind: "request", request} envelope.
  3. Length-prefix encode and write to fd 0.
  4. Read a length-prefixed envelope from fd 1.
  5. Validate the envelope as PluginEnvelopeOut.
  6. If {kind: "response", response}, unmarshal into a host Response.
  7. If {kind: "error", code, message}, throw a SuaveplanError carrying the plugin's code.
  8. Any other kind → SAND_WASM_BRIDGE.PROTOCOL_MISMATCH with context.received.

Scenario: 1 KiB body round-trips through WASI-stdio

  • WHEN a POST with a 1 KiB JSON body reaches a WASI-stdio-sandboxed handler
  • THEN the handler observes the exact 1024 bytes (base64-decoded from bodyBase64) AND its returned response body reaches the caller intact

Verified by: tests/integration/wasi-stdio-flow.test.ts::"1 KiB body round-trips through WASI-stdio"


Requirement: Lifecycle commands flow through envelopes

PluginModule.start() MUST send {kind: "start"} to the plugin and await {kind: "ok"}. PluginModule.dispose() MUST send {kind: "dispose"} and await {kind: "ok"}. Both forwarders are idempotent: repeated calls send AT MOST ONE envelope. Any non-ok reply surfaces as SAND_WASM_BRIDGE.LIFECYCLE_FAILED.

Scenario: dispose twice is a no-op

  • WHEN PluginModule.dispose() is invoked twice
  • THEN exactly one {kind:"dispose"} envelope reaches the plugin

Verified by: src/lifecycle/lifecycle.test.ts::"dispose is idempotent across repeated calls"


Requirement: WASM traps still classify per the seven trap kinds

Traps during _start or during any in-loop request handling MUST propagate as SAND_WASM_BRIDGE.HANDLER_TRAPPED with context.trapKind. The kind set MUST be unchanged from the direct variant (unreachable, out_of_bounds, div_by_zero, integer_overflow, invalid_conversion, stack_overflow, other).

Scenario: unreachable mid-request surfaces HANDLER_TRAPPED

Verified by: tests/integration/wasi-stdio-flow.test.ts::"WASI-stdio unreachable mid-request surfaces HANDLER_TRAPPED"


Requirement: Capability gate continues to route denials through SAND_PERM.DENIED

Even under WASI-stdio, the sandbox's capability proxy SHALL surface denied capability calls as SAND_PERM.DENIED to the host. The bridge MUST NOT silently translate them to its own error namespace.

Scenario: Denied fs.write surfaces SAND_PERM.DENIED

Verified by: tests/integration/wasi-stdio-flow.test.ts::"denied capability under WASI-stdio surfaces SAND_PERM.DENIED"


Requirement: Conformance harness gains abiVariant parameter

runWasmHostBridgeConformance SHALL accept { buildBridge, abiVariant }. Both "direct-i32-i64" and "wasi-stdio" MUST pass all five existing invariants (route-registration, round-trip, lifecycle, trap-propagation, capability-gate) when the harness is run against the matching fixture WASM.

Scenario: harness fails on a WASI-stdio bridge that swallows protocol errors

Verified by: src/testing/wasm-host-bridge-conformance/wasm-host-bridge-conformance.test.ts::"harness rejects WASI-stdio bridge that swallows PROTOCOL_MISMATCH"


Requirement: OTel instruments distinguish variants via abi_variant attribute

Every existing counter and histogram in @suaveplan/api-sandbox-wasm-host-bridge MUST carry an abi_variant attribute on every emission whose value is either "direct-i32-i64" or "wasi-stdio". No new instruments — only an additional attribute on each existing instrument.

Scenario: WASI-stdio request emits with abi_variant="wasi-stdio"

Verified by: src/instruments/instruments.test.ts::"abi_variant attribute on every request counter emission"