Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# WASI-stdio ABI variant for the WASM host bridge
**Package**: `@suaveplan/api-sandbox-wasm-host-bridge` (extension) + `@suaveplan/api-types/sandbox-bridge` (additive)
**Status**: PROPOSED
**Type**: extension + types-additive
## Why
The shipped wasm host-bridge (commit `a9fa31db95`) accepts WASM modules exporting six custom-shaped functions (`__suaveplan_alloc(i32) → i32`, `__suaveplan_handle_request(i32,i32) → i64`, etc.) plus `memory`. Producing such a module by compiling TypeScript is the job of `@suaveplan/wasm-compiler-javy`.
When the build agent investigated against Javy v8.1.1 (Bytecode Alliance, current latest), it surfaced an architectural blocker: **Javy cannot emit custom-shaped WASM exports**. Its `-C wit=...` flag restricts WIT-exposed exports to "function exports with no arguments and no return values" (verified via `javy build -C help`). Javy's host-interaction model is fixed: the host writes input to fd 0 (stdin), reads output from fd 1 (stdout). This is the WASI standard interface and is the only ABI Javy honors.
Three resolutions were considered (full transcript in the agent's stop report); the chosen path is to **extend the bridge to accept either the existing direct-export ABI OR a WASI-stdio ABI variant**. The compiler then becomes a thin Javy wrapper that emits Javy's native artifact — no outer-WASM authorship, no Component Model migration, no bridge-spec rewrite for the existing variant.
## Concrete consumer
- `@suaveplan/wasm-compiler-javy` (rewritten in a sibling proposal) — every TS→WASM artifact it emits speaks the WASI-stdio variant.
- Plugin authors who choose Javy as their toolchain (the recommended TS→WASM path).
- `e2e/foundation-stack-full` Stage-8 closure smoke — once both proposals ship, `api-plugin-example` deploys under the wasm tier via Javy + WASI-stdio ABI.
## Host bridge
The bridge surface gains one additive option. The default remains the current direct-export ABI so already-shipped consumers see no behaviour change.
```ts
type WasmAbiVariant = "direct-i32-i64" | "wasi-stdio";
createSandboxedWasmPluginModule({
sandbox,
manifest,
abiVariant: "wasi-stdio", // default: "direct-i32-i64"
});
```
When `abiVariant === "wasi-stdio"`, the bridge:
1. Skips the six-export ABI validation (those exports won't exist).
2. Routes every request through the WASI-stdio framing protocol defined below.
3. Treats the WASM module as a long-running guest process: a single `_start` call (the WASI entry point) launches the plugin, which then enters a request loop reading from fd 0.
## End-to-end test
- `packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/integration/wasi-stdio-flow.test.ts` — instantiates a hand-encoded WASM fixture that speaks the WASI-stdio protocol, runs the conformance harness against it, and asserts: route discovery via `{kind:"routes"}` envelope; request round-trip via `{kind:"request"}` / `{kind:"response"}`; error envelope surfaces as SuaveplanError host-side; lifecycle commands (`{kind:"start"}` / `{kind:"dispose"}`) idempotent.
## What changes
### Shared protocol contract (additive to `@suaveplan/api-types/sandbox-bridge`)
New types alongside the existing `SerializedRequest`/`SerializedResponse`/`SerializedRoute`. Wire format is length-prefixed JSON frames so the protocol survives any WASI implementation's buffering semantics.
```ts
// Each envelope is a single WASI-stdio frame:
// [u32 BE length][JSON bytes of length 'length']
export type PluginEnvelopeIn =
| { readonly kind: "request"; readonly request: SerializedRequest }
| { readonly kind: "routes" }
| { readonly kind: "start" }
| { readonly kind: "dispose" };
export type PluginEnvelopeOut =
| { readonly kind: "response"; readonly response: SerializedResponse }
| { readonly kind: "routes"; readonly routes: readonly SerializedRoute[] }
| { readonly kind: "ok" }
| { readonly kind: "error"; readonly code: string; readonly message: string };
export const WASI_STDIO_PROTOCOL_VERSION: 1;
```
The contract lives in `api-types` because both bridges (host-side framer) and Javy-compiled plugins (sandbox-side framer, via the suaveplan ABI shim) MUST agree on the wire shape down to the byte. Putting it in either runtime package would re-introduce the SerializedRequest/Response divergence the orchestrator just resolved.
### Bridge runtime (extends `@suaveplan/api-sandbox-wasm-host-bridge`)
1. `src/wasi-stdio/wasi-stdio.ts` — the framer: length-prefix encode/decode, Zod-validated envelope schemas, frame-too-large guard (`SAND_WASM_BRIDGE.FRAME_OVERSIZE`).
2. `src/wasi-stdio/wasi-stdio-runner.ts` — wraps a `WasmSandboxHandle` configured for WASI-stdio: writes envelopes to fd 0, reads from fd 1, surfaces stderr lines as logger events.
3. `src/bridge/bridge.ts` — `createSandboxedWasmPluginModule` becomes a `switch (abiVariant)`. The existing direct-i32-i64 path is renamed `src/abi-direct/` and the new path is `src/abi-wasi-stdio/`. Routes returned by `{kind:"routes"}` envelope feed into the same `BridgedPlugin` shape.
4. New error codes: `SAND_WASM_BRIDGE.FRAME_OVERSIZE`, `SAND_WASM_BRIDGE.PROTOCOL_MISMATCH`, `SAND_WASM_BRIDGE.STDIO_TIMEOUT`. The shipped 7 codes are preserved.
5. ABI validation: under `abiVariant: "wasi-stdio"`, validate `memory` + `_start` + WASI imports (`wasi_snapshot_preview1.{fd_read, fd_write, environ_get, environ_sizes_get, ...}`). Missing → `SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE` with `context.missing`.
6. Trap classification, capability-gate routing, idempotent lifecycle: all preserved — but lifecycle commands now go through `{kind:"start"}` / `{kind:"dispose"}` envelopes, not direct export calls.
### Conformance harness (extends)
`runWasmHostBridgeConformance({ buildBridge, abiVariant })` gains an `abiVariant` parameter. Both variants must pass all five existing invariants; the harness picks the right fixture WASM based on the variant.
## Out of scope
- No change to `@suaveplan/api-types/sandbox-bridge`'s `SerializedRequest`/`SerializedResponse`/`SerializedRoute`. They round-trip unchanged through both variants.
- No change to `@suaveplan/api-sandbox-vm-host-bridge`. The vm tier uses structuredClone, not stdio.
- No deprecation of the direct-i32-i64 variant. Both stay first-class.

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
# Tasks — WASI-stdio ABI variant for the WASM host bridge
## 0. Pre-flight (non-negotiable)
- [x] 0.1 Clean working tree; account for every `.claude/worktrees/agent-*` (active, work-pending-merge, stale-and-prune)
- [x] 0.2 `MANIFEST.md` package count matches `find packages -maxdepth 3 -name package.json`
- [x] 0.3 §0a Sync: worktree-isolated agents fast-forward to orchestrator-supplied SHA before any work
## 1. Shared protocol contract in `@suaveplan/api-types/sandbox-bridge`
- [x] 1.1 Add `wasi-stdio-protocol.types.ts` exporting `PluginEnvelopeIn` and `PluginEnvelopeOut` discriminated unions + `WASI_STDIO_PROTOCOL_VERSION: 1` constant
- [x] 1.2 Paired `.md` documenting wire format ([u32 BE length][JSON]) + frame ordering invariants
- [x] 1.3 Paired `.test.ts` proving the types-only invariant (byte-size guard already enforces)
- [x] 1.4 Re-export from `packages/api-stack/api/api-types/src/sandbox-bridge/index.ts` + root `src/index.ts`
- [x] 1.5 `bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-types` green
## 2. Bridge runtime — WASI-stdio variant
- [x] 2.1 `src/wasi-stdio/wasi-stdio-frame.ts` — Zod-validated length-prefix encoder/decoder
- [x] 2.2 `src/wasi-stdio/wasi-stdio-runner.ts` — drives the sandbox via fd 0 / fd 1; surfaces stderr to logger
- [x] 2.3 `src/wasi-stdio/wasi-stdio-routes.ts` — issues `{kind:"routes"}` at bridge-init, captures the response into the BridgedPlugin
- [x] 2.4 `src/abi-wasi-stdio/wasi-stdio-validate.ts` — variant-specific ABI validator (memory + `_start` + WASI imports allowlist)
- [x] 2.5 `src/abi-direct/` (existing direct-i32-i64 path) — moved existing `src/wasm-abi/` + `src/memory/` + `src/marshal/` modules under this folder so the variant split is structural, not parameterised
- [x] 2.6 `src/bridge/bridge.ts` — `switch (abiVariant)` dispatch; default remains `"direct-i32-i64"`
- [x] 2.7 New error codes appended: `SAND_WASM_BRIDGE.FRAME_OVERSIZE`, `SAND_WASM_BRIDGE.PROTOCOL_MISMATCH`, `SAND_WASM_BRIDGE.STDIO_TIMEOUT` (10 codes total)
- [x] 2.8 Lifecycle (start/dispose) routes through stdio envelopes when `abiVariant === "wasi-stdio"`
- [x] 2.9 Trap classification preserved across both variants
- [x] 2.10 OTel instruments labelled with `abi_variant` attribute on every counter and histogram
## 3. Conformance harness
- [x] 3.1 `runWasmHostBridgeConformance({ buildBridge, abiVariant })` gains the new parameter; both variants run all five invariants
- [x] 3.2 New WASI-stdio fixture WASM under `tests/fixtures/wasi-stdio/build-wasm.ts` — hand-encoded, no `wabt`
- [x] 3.3 Fixture exercises route-discovery, request round-trip, lifecycle, trap (`unreachable` mid-frame), capability-gate
## 4. Integration test
- [x] 4.1 `tests/integration/wasi-stdio-flow.test.ts` — full round-trip + assertBridgeThrowsOnTrap for both variants
- [x] 4.2 `tests/integration/full-flow.test.ts` (existing direct variant) — left untouched; proves the split didn't regress
- [x] 4.3 100% istanbul coverage on every touched module
- [x] 4.4 No `// biome-ignore`; every test file imports from `@suaveplan/testing/runner`; public-constructor preserved
## 5. Gate
- [x] 5.1 `bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-types --filter=@suaveplan/api-sandbox-wasm --filter=@suaveplan/api-sandbox-wasm-host-bridge --filter=@suaveplan/api-sandbox-vm-host-bridge --concurrency=4` green (vm-bridge included to prove the api-types additive change doesn't regress it)
## 6. Archive (orchestrator step)
- [ ] 6.1 Fold delta into `openspec/specs/api-sandbox-wasm-host-bridge/spec.md` (creating the spec if it doesn't exist yet — the original wasm host-bridge proposal still awaits its first archive)
- [ ] 6.2 Fold delta into `openspec/specs/api-types/spec.md` for the additive protocol types
- [ ] 6.3 Move folder → `openspec/archive/2026-05-27-api-sandbox-wasm-stdio-abi/`
- [ ] 6.4 Refresh `MANIFEST.md`