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
createSandboxedWasmPluginModuleis called withoutabiVariant - 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:
- Exports
memoryAND_start. - 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"ANDcontext.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:
- Length-prefix encode outgoing envelopes (
PluginEnvelopeIn) before writing to fd 0. - Length-prefix decode incoming envelopes (
PluginEnvelopeOut) read from fd 1. - Validate each decoded envelope against the
PluginEnvelopeOutZod schema before acting on it. - Enforce a 16 MiB maximum frame size per direction. Larger frames raise
SAND_WASM_BRIDGE.FRAME_OVERSIZEwithcontext.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"ANDcontext.byteLen > 16777216AND 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
routesfield contains both ANDhost.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:
- Marshal the host
RequestintoSerializedRequest. - Wrap it in
{kind: "request", request}envelope. - Length-prefix encode and write to fd 0.
- Read a length-prefixed envelope from fd 1.
- Validate the envelope as
PluginEnvelopeOut. - If
{kind: "response", response}, unmarshal into a hostResponse. - If
{kind: "error", code, message}, throw aSuaveplanErrorcarrying the plugin's code. - Any other kind →
SAND_WASM_BRIDGE.PROTOCOL_MISMATCHwithcontext.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"