139 lines
7.4 KiB
Markdown
139 lines
7.4 KiB
Markdown
# 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"`
|