first commit
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
# `wasi-stdio-frame`
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the WASI-stdio wire format: a single length-prefixed JSON frame
|
||||
exchanged on the guest's standard streams. Encodes outgoing
|
||||
`PluginEnvelopeIn` values into `[u32 BE length][JSON bytes]` byte
|
||||
buffers and decodes incoming frames back into Zod-validated
|
||||
`PluginEnvelopeOut` values. Every failure mode surfaces as a stable
|
||||
`SAND_WASM_BRIDGE.*` code so the caller can branch on `code` without
|
||||
parsing messages.
|
||||
|
||||
The encoder refuses oversize frames BEFORE allocating the output
|
||||
buffer, so no partial writes ever reach fd 0. The decoder enforces the
|
||||
same 16 MiB ceiling on incoming frames AND validates the JSON payload
|
||||
against the schema for `PluginEnvelopeOut`, preventing a malformed
|
||||
sandbox reply from propagating into the host pipeline.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeWasiStdioFrame(envelope, pluginName)` — emit a single frame
|
||||
ready to write into fd 0 in one syscall.
|
||||
- `decodeWasiStdioFrame(bytes, pluginName)` — parse one frame and
|
||||
validate its envelope against the strict Zod schema. Returns a
|
||||
type-narrowed `PluginEnvelopeOut`.
|
||||
- `PluginEnvelopeInSchema` / `PluginEnvelopeOutSchema` — Zod schemas
|
||||
re-exported for callers that need to validate other byte sources
|
||||
(e.g. integration tests, replay tooling).
|
||||
- `MAX_WASI_STDIO_FRAME_BYTES` — 16 MiB ceiling per direction.
|
||||
- Six distinct decoder failure reasons each carry a specific
|
||||
`context.reason`: `short_header`, `length_mismatch`, `invalid_utf8`,
|
||||
`invalid_json`, `shape_mismatch`, plus the oversize case under its
|
||||
own code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeWasiStdioFrame,
|
||||
encodeWasiStdioFrame,
|
||||
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
|
||||
|
||||
const frame = encodeWasiStdioFrame({ kind: "routes" }, "example");
|
||||
// → write frame to fd 0
|
||||
|
||||
declare const incoming: Uint8Array;
|
||||
const reply = decodeWasiStdioFrame(incoming, "example");
|
||||
if (reply.kind === "routes") {
|
||||
for (const route of reply.routes) {
|
||||
// route.method, route.path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeWasiStdioFrame,
|
||||
encodeWasiStdioFrame,
|
||||
MAX_WASI_STDIO_FRAME_BYTES,
|
||||
PluginEnvelopeOutSchema,
|
||||
} from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
|
||||
import { SandboxWasmBridgeError } from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes";
|
||||
|
||||
// Catch the oversize-frame case BEFORE the write hits the guest:
|
||||
try {
|
||||
const frame = encodeWasiStdioFrame(
|
||||
{ kind: "request", request: buildHugeRequest() },
|
||||
"example",
|
||||
);
|
||||
void frame;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof SandboxWasmBridgeError &&
|
||||
err.code === "SAND_WASM_BRIDGE.FRAME_OVERSIZE"
|
||||
) {
|
||||
// Surface a 413 to the caller without touching the guest.
|
||||
}
|
||||
}
|
||||
|
||||
// Use the raw schema to validate replay-buffer bytes outside the
|
||||
// runtime path:
|
||||
declare const replayBytes: Uint8Array;
|
||||
declare function buildHugeRequest(): never;
|
||||
const view = new DataView(
|
||||
replayBytes.buffer,
|
||||
replayBytes.byteOffset,
|
||||
replayBytes.byteLength,
|
||||
);
|
||||
const length = view.getUint32(0, false);
|
||||
if (length <= MAX_WASI_STDIO_FRAME_BYTES) {
|
||||
const result = PluginEnvelopeOutSchema.safeParse(
|
||||
JSON.parse(
|
||||
new TextDecoder().decode(replayBytes.subarray(4, 4 + length)),
|
||||
),
|
||||
);
|
||||
void result;
|
||||
}
|
||||
|
||||
declare function decodeRoundtrip(): void;
|
||||
void decodeRoundtrip;
|
||||
void decodeWasiStdioFrame;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `encodeWasiStdioFrame(envelope, pluginName): Uint8Array`
|
||||
|
||||
Throws:
|
||||
|
||||
- `SAND_WASM_BRIDGE.MARSHAL_FAILED` — `JSON.stringify` rejected the
|
||||
envelope (e.g. it carried a `BigInt`).
|
||||
- `SAND_WASM_BRIDGE.FRAME_OVERSIZE` — payload exceeds
|
||||
`MAX_WASI_STDIO_FRAME_BYTES`.
|
||||
|
||||
### `decodeWasiStdioFrame(bytes, pluginName): PluginEnvelopeOut`
|
||||
|
||||
Throws `SAND_WASM_BRIDGE.PROTOCOL_MISMATCH` for `short_header`,
|
||||
`length_mismatch`, `invalid_utf8`, `invalid_json`, `shape_mismatch`;
|
||||
`SAND_WASM_BRIDGE.FRAME_OVERSIZE` when the declared length exceeds
|
||||
the ceiling.
|
||||
|
||||
### `MAX_WASI_STDIO_FRAME_BYTES`
|
||||
|
||||
`16 * 1024 * 1024` — fixed by the protocol.
|
||||
|
||||
### `PluginEnvelopeInSchema` / `PluginEnvelopeOutSchema`
|
||||
|
||||
Zod schemas for the two envelope unions. Strict object shapes;
|
||||
`safeParse` returns the discriminated union after passing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Length is encoded big-endian (`DataView.setUint32(..., false)`) to
|
||||
match every other length-prefix wire format in the Suaveplan stack
|
||||
(CBOR, drive-template manifests).
|
||||
- The encoder refuses oversize frames BEFORE allocating the output
|
||||
buffer — emitting a partial header followed by truncated payload
|
||||
would leave the guest's fd 0 in an unrecoverable state and force a
|
||||
sandbox restart.
|
||||
- The decoder rejects extra bytes implicitly by ignoring anything past
|
||||
`HEADER_BYTES + length` — the runner is expected to slice exactly
|
||||
one frame's worth of bytes per call. Extra bytes after a valid
|
||||
frame are the caller's responsibility (they belong to the NEXT
|
||||
frame).
|
||||
- Zod schemas are pinned at module-load time so each
|
||||
encode/decode is allocation-stable past the first invocation.
|
||||
- The `PluginEnvelopeIn` schema is exported for callers that need to
|
||||
validate envelopes they constructed by hand (e.g. test factories
|
||||
authoring corrupt payloads); the production encode path does NOT
|
||||
validate outgoing envelopes — type-narrowed callers cannot
|
||||
construct an invalid `PluginEnvelopeIn`.
|
||||
Reference in New Issue
Block a user