# 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"`