Initial Commit
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# 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"`
|
||||
@@ -0,0 +1,55 @@
|
||||
# Capability Spec Delta: api-types (sandbox-bridge WASI-stdio additive)
|
||||
|
||||
**Package**: `@suaveplan/api-types` (additive)
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `api-types/sandbox-bridge` exports the WASI-stdio envelope union
|
||||
|
||||
The subpath `@suaveplan/api-types/sandbox-bridge` SHALL additionally export `PluginEnvelopeIn`, `PluginEnvelopeOut`, AND `WASI_STDIO_PROTOCOL_VERSION` (literal type `1`). The envelopes are discriminated unions keyed by `kind`.
|
||||
|
||||
`PluginEnvelopeIn`:
|
||||
- `{ kind: "request"; request: SerializedRequest }`
|
||||
- `{ kind: "routes" }`
|
||||
- `{ kind: "start" }`
|
||||
- `{ kind: "dispose" }`
|
||||
|
||||
`PluginEnvelopeOut`:
|
||||
- `{ kind: "response"; response: SerializedResponse }`
|
||||
- `{ kind: "routes"; routes: readonly SerializedRoute[] }`
|
||||
- `{ kind: "ok" }`
|
||||
- `{ kind: "error"; code: string; message: string }`
|
||||
|
||||
#### Scenario: Envelope types round-trip through the JSON wire shape
|
||||
|
||||
- **WHEN** an instance of `PluginEnvelopeIn` is JSON-stringified, length-prefixed, and JSON-parsed
|
||||
- **THEN** the result is `===`-deep-equal to the original (modulo readonly array identity) AND validates against the Zod schema `PluginEnvelopeInSchema` (which lives in the bridge package — Zod schemas don't ship from api-types because the package is zero-runtime)
|
||||
|
||||
**Verified by:** `packages/api-stack/api/api-types/src/sandbox-bridge/wasi-stdio-protocol.test.ts::"PluginEnvelopeIn JSON round-trip"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `WASI_STDIO_PROTOCOL_VERSION` is a typed literal constant exported as `1`
|
||||
|
||||
The constant SHALL be declared `export const WASI_STDIO_PROTOCOL_VERSION = 1 as const;` so consumers can guard for protocol-version drift at compile time via literal-type matching. Future protocol revisions get a new constant value and a corresponding bridge-side rejection if the plugin's version doesn't match.
|
||||
|
||||
#### Scenario: Compile-time literal narrowing on the constant
|
||||
|
||||
- **WHEN** a consumer writes `const v: 1 = WASI_STDIO_PROTOCOL_VERSION`
|
||||
- **THEN** TypeScript accepts the assignment with no widening
|
||||
|
||||
**Verified by:** `packages/api-stack/api/api-types/src/sandbox-bridge/wasi-stdio-protocol.test.ts::"protocol version is the literal type 1"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `api-types` remains zero-runtime, zero-deps
|
||||
|
||||
The new file MUST contain ONLY `export type` declarations and `export const` literal values (no functions, no class bodies, no Zod schemas). The package's existing byte-size guard (`tests/byte-size.test.ts`) MUST continue to pass after the addition.
|
||||
|
||||
#### Scenario: byte-size guard catches accidental runtime additions
|
||||
|
||||
- **WHEN** a contributor adds a function body to `wasi-stdio-protocol.types.ts`
|
||||
- **THEN** the byte-size guard fails the build
|
||||
|
||||
**Verified by:** `packages/api-stack/api/api-types/tests/byte-size.test.ts::"compiled output stays under N bytes"`
|
||||
Reference in New Issue
Block a user