Initial Commit
This commit is contained in:
81
openspec/changes/api-sandbox-wasm-host-bridge/proposal.md
Normal file
81
openspec/changes/api-sandbox-wasm-host-bridge/proposal.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Sandbox WASM Host Bridge
|
||||
|
||||
**Package**: `@suaveplan/api-sandbox-wasm-host-bridge`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (host-integration layer)
|
||||
|
||||
## Why
|
||||
|
||||
Same shape as `api-sandbox-vm-host-bridge` but for the WASM tier. `@suaveplan/api-sandbox-wasm` ships a `WasmSandboxHandle` — a `WebAssembly.Instance` running under WASI `snapshot_preview1` with permission-gated `suaveplan_caps.*` imports. `@suaveplan/api-host.installPlugin(spec)` takes a `PluginModule`. The bridge between them does not exist, so gate 2.8's "deploy under wasm tier" requirement is unreachable.
|
||||
|
||||
## Concrete consumer
|
||||
|
||||
- `@suaveplan/api-host.installPlugin(spec)` — consumes the `PluginModule` produced by this bridge.
|
||||
- `@suaveplan/e2e-foundation-stack-full` Stage-8 closure smoke — deploys the WASM artifact produced by `@suaveplan/wasm-compiler-javy` (sibling change) through this bridge under the wasm tier.
|
||||
|
||||
## Host bridge
|
||||
|
||||
This package IS the host-integration for `api-sandbox-wasm`. Required by §19.
|
||||
|
||||
## End-to-end test
|
||||
|
||||
- `e2e/foundation-stack-full/tests/sandbox-wasm-deployment.spec.ts` — deploys a WASM-compiled `api-plugin-example` via this bridge, drives an HTTP request through the host, asserts response was produced by the WASM-sandboxed plugin.
|
||||
|
||||
## What changes
|
||||
|
||||
### Package surface
|
||||
|
||||
```ts
|
||||
import { createWasmSandbox } from "@suaveplan/api-sandbox-wasm";
|
||||
import { createSandboxedWasmPluginModule } from "@suaveplan/api-sandbox-wasm-host-bridge";
|
||||
|
||||
const sandbox = createWasmSandbox({
|
||||
pluginName: "example",
|
||||
policy,
|
||||
capabilities,
|
||||
checker,
|
||||
wasmModule: await WebAssembly.compile(wasmBytes),
|
||||
});
|
||||
|
||||
const module = await createSandboxedWasmPluginModule({
|
||||
sandbox,
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
|
||||
await host.installPlugin({ kind: "wasm-sandboxed", module, manifest });
|
||||
```
|
||||
|
||||
### Marshalling
|
||||
|
||||
Same `SerializedRequest` / `SerializedResponse` shapes from `@suaveplan/api-types/sandbox-bridge` (authored by the `api-sandbox-vm-host-bridge` change as the shared contract). Both bridges use the same wire format.
|
||||
|
||||
WASM-specific marshalling uses linear-memory buffer passing:
|
||||
1. Bridge allocates a buffer via the WASM `__suaveplan_alloc(len)` export.
|
||||
2. Writes the JSON-encoded `SerializedRequest` bytes into linear memory at the allocated offset.
|
||||
3. Calls the WASM `__suaveplan_handle_request(ptr, len)` export, which returns `(responsePtr, responseLen)`.
|
||||
4. Reads the response bytes from linear memory.
|
||||
5. Frees both buffers via `__suaveplan_free(ptr)`.
|
||||
6. Parses the `SerializedResponse` and returns the host `Response`.
|
||||
|
||||
The WASM plugin must export `__suaveplan_alloc`, `__suaveplan_free`, `__suaveplan_handle_request`, `__suaveplan_routes` (returns a JSON-encoded routes array at init time), `__suaveplan_start`, `__suaveplan_dispose`. The `@suaveplan/wasm-compiler-javy` sibling produces artifacts that conform to this ABI.
|
||||
|
||||
### Error codes
|
||||
|
||||
`SAND_WASM_BRIDGE.*` registry:
|
||||
- `INVALID_PLUGIN_SURFACE` — WASM module didn't export required functions.
|
||||
- `MEMORY_ALLOC_FAILED` — `__suaveplan_alloc` returned 0 or trapped.
|
||||
- `MARSHAL_FAILED` — JSON-encoding of the request failed.
|
||||
- `UNMARSHAL_FAILED` — WASM returned non-conformant response bytes.
|
||||
- `HANDLER_TRAPPED` — WASM execution trapped.
|
||||
- `CAPABILITY_DENIED` — capability import returned WASI ACCES.
|
||||
- `LIFECYCLE_FAILED` — start/dispose export call trapped.
|
||||
|
||||
### Conformance harness
|
||||
|
||||
`runWasmHostBridgeConformance({ buildBridge })` with five invariants mirroring the vm-bridge harness: route-registration, request-round-trip, lifecycle, error-propagation, capability-gate.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The WASM compilation pipeline itself (sibling change `wasm-compiler-javy`).
|
||||
- Component-model-based WASM plugins (this bridge targets WASI `snapshot_preview1`; component-model is a follow-up).
|
||||
- Cross-instance shared memory (WASM linear-memory isolation is by design).
|
||||
@@ -0,0 +1,94 @@
|
||||
# Capability Spec Delta: api-sandbox-wasm-host-bridge
|
||||
|
||||
**Package**: `@suaveplan/api-sandbox-wasm-host-bridge`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Wraps a `WasmSandboxHandle` as a `PluginModule`
|
||||
|
||||
`createSandboxedWasmPluginModule({ sandbox, manifest })` SHALL return a `PluginModule` compatible with `@suaveplan/api-host.installPlugin`. The returned module's `routes` field is sourced by calling the WASM `__suaveplan_routes` export at bridge-init time, which returns a JSON-encoded routes array via linear memory.
|
||||
|
||||
#### Scenario: WASM-exported routes register with the host
|
||||
|
||||
- **WHEN** a WASM plugin exports `__suaveplan_routes` returning a JSON array of two routes
|
||||
- **THEN** the bridge's PluginModule.routes contains those two routes AND `host.installPlugin(...)` registers both with the host router
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.test.ts::"WASM-exported routes register with the host"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Validates the WASM ABI at bridge-init time
|
||||
|
||||
Before returning a PluginModule, the bridge MUST validate that the WASM instance exports ALL of: `__suaveplan_alloc`, `__suaveplan_free`, `__suaveplan_handle_request`, `__suaveplan_routes`, `__suaveplan_start`, `__suaveplan_dispose`, AND `memory`. Missing any one MUST fail with `SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE` BEFORE the plugin is offered to the host.
|
||||
|
||||
#### Scenario: WASM missing `__suaveplan_handle_request` rejects at bridge-init
|
||||
|
||||
- **WHEN** `createSandboxedWasmPluginModule` is called with a sandbox whose WASM module lacks `__suaveplan_handle_request`
|
||||
- **THEN** the call rejects with `code === "SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACE"` AND `context.missing === ["__suaveplan_handle_request"]`
|
||||
|
||||
**Verified by:** `src/wasm-abi/abi-validator.test.ts::"missing __suaveplan_handle_request rejects at bridge-init"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Request round-trip via linear-memory buffer passing
|
||||
|
||||
For each request, the bridge MUST:
|
||||
1. JSON-encode `SerializedRequest` to UTF-8 bytes.
|
||||
2. Call `__suaveplan_alloc(len)` to obtain a buffer offset; trap-protect against return value 0.
|
||||
3. Write the bytes into linear memory at the returned offset.
|
||||
4. Call `__suaveplan_handle_request(ptr, len)`; receive `(responsePtr, responseLen)` (packed `i64` or two-`i32` return).
|
||||
5. Read response bytes from linear memory at the returned offset.
|
||||
6. Free both buffers via `__suaveplan_free`.
|
||||
7. JSON-decode the response bytes into `SerializedResponse`.
|
||||
8. Unmarshal into a host `Response`.
|
||||
|
||||
#### Scenario: full round-trip preserves bytes
|
||||
|
||||
- **WHEN** a POST with a 1 KiB JSON body reaches a WASM-sandboxed handler
|
||||
- **THEN** the handler observes the exact 1024 bytes AND its returned response body reaches the caller intact
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.test.ts::"1 KiB body round-trips through WASM bridge"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Lifecycle delegation is idempotent
|
||||
|
||||
`PluginModule.start()` MUST call `__suaveplan_start` exactly once across repeated invocations; `dispose()` MUST call `__suaveplan_dispose` exactly once. Both forwarders catch traps and re-surface as `SAND_WASM_BRIDGE.LIFECYCLE_FAILED`.
|
||||
|
||||
#### Scenario: dispose twice is a no-op
|
||||
|
||||
**Verified by:** `src/lifecycle/lifecycle.test.ts::"dispose is idempotent across repeated calls"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WASM traps surface as `HANDLER_TRAPPED`
|
||||
|
||||
If `__suaveplan_handle_request` traps mid-execution, the bridge MUST catch the trap and surface a `SandboxWasmBridgeError` with `code === "SAND_WASM_BRIDGE.HANDLER_TRAPPED"` AND `cause` pointing at the original trap error AND `context.trapKind` (one of `"unreachable"`, `"out_of_bounds"`, `"div_by_zero"`, `"integer_overflow"`, `"invalid_conversion"`, `"stack_overflow"`, `"other"`).
|
||||
|
||||
#### Scenario: unreachable instruction surfaces HANDLER_TRAPPED
|
||||
|
||||
- **WHEN** a WASM handler executes `unreachable`
|
||||
- **THEN** the bridge surfaces a SandboxWasmBridgeError with code `HANDLER_TRAPPED` AND `context.trapKind === "unreachable"`
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.test.ts::"WASM unreachable instruction surfaces HANDLER_TRAPPED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Capability gate routes through `suaveplan_caps.*` imports
|
||||
|
||||
A WASM-side call to `suaveplan_caps.<name>.<method>` MUST be intercepted by the sandbox's permission checker. Denied capabilities return WASI `ACCES` errno to the WASM caller AND the bridge MUST surface this to the host as the canonical `SAND_PERM.DENIED` code (NOT a silent return).
|
||||
|
||||
#### Scenario: denied fs.write surfaces SAND_PERM.DENIED
|
||||
|
||||
**Verified by:** `tests/integration/full-flow.test.ts::"denied capability surfaces SAND_PERM.DENIED through bridge"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conformance harness `runWasmHostBridgeConformance`
|
||||
|
||||
The package SHALL ship `runWasmHostBridgeConformance({ buildBridge })` from `@suaveplan/api-sandbox-wasm-host-bridge/testing` with five invariants (route-registration, round-trip, lifecycle, trap-propagation, capability-gate). The harness uses a hand-coded minimal WASM fixture committed under `tests/fixtures/`.
|
||||
|
||||
#### Scenario: harness fails on a bridge that silently retries traps
|
||||
|
||||
**Verified by:** `src/testing/wasm-host-bridge-conformance/wasm-host-bridge-conformance.test.ts::"harness rejects silent-retry bridge"`
|
||||
51
openspec/changes/api-sandbox-wasm-host-bridge/tasks.md
Normal file
51
openspec/changes/api-sandbox-wasm-host-bridge/tasks.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Tasks — Sandbox WASM Host Bridge
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree and no in-flight worktrees with uncommitted work
|
||||
- [x] 0.2 Account for every worktree under `.claude/worktrees/`
|
||||
- [x] 0.3 `MANIFEST.md` package count matches `find packages -maxdepth 3 -name package.json`
|
||||
- [x] 0.4 §0a Sync: worktree-isolated agents fast-forward to orchestrator-supplied SHA
|
||||
|
||||
## 1. Dependency check
|
||||
|
||||
- [x] 1.1 `@suaveplan/api-types/sandbox-bridge` subpath exists (authored by this change ahead of the sibling vm-bridge merge).
|
||||
- [x] 1.2 `@suaveplan/api-sandbox-wasm` exists with `WasmSandboxHandle` surface.
|
||||
- [x] 1.3 `@suaveplan/api-host.installPlugin` accepts the `PluginModule` shape (bridge returns `SandboxedWasmPluginInstall` carrying both `module` and `routes`).
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [x] 2.1 `packages/api-stack/api/api-sandbox-wasm-host-bridge/` with package.json, tsconfig, vite, vitest configs.
|
||||
- [x] 2.2 Deps: `@suaveplan/api-sandbox-wasm`, `@suaveplan/api-host`, `@suaveplan/api-types`, `@suaveplan/api-sandbox-permissions`, `@suaveplan/error`, `tslib`, `zod`.
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
- [x] 3.1 `src/bridge/bridge.ts` — `createSandboxedWasmPluginModule({ sandbox, manifest })` factory.
|
||||
- [x] 3.2 `src/wasm-abi/abi.ts` — `__suaveplan_alloc/free/handle_request/routes/start/dispose` export-name constants + signatures.
|
||||
- [x] 3.3 `src/wasm-abi/abi-validator.ts` — validates the WASM module exports all required functions before bridge init.
|
||||
- [x] 3.4 `src/marshal/marshal.ts` — JSON-encode `SerializedRequest` into linear memory, decode `SerializedResponse` out.
|
||||
- [x] 3.5 `src/memory/memory.ts` — alloc/free wrappers with trap protection.
|
||||
- [x] 3.6 `src/route-discovery/route-discovery.ts` — calls `__suaveplan_routes` at init time.
|
||||
- [x] 3.7 `src/lifecycle/lifecycle.ts` — `__suaveplan_start` / `__suaveplan_dispose` forwarders with idempotency.
|
||||
- [x] 3.8 `src/error-codes/error-codes.ts` — 7 SAND_WASM_BRIDGE codes + SandboxWasmBridgeError with public constructor.
|
||||
- [x] 3.9 `src/instruments/instruments.ts` — bridge OTel counters.
|
||||
- [x] 3.10 Paired `.md` per implementation file.
|
||||
|
||||
## 4. Conformance harness
|
||||
|
||||
- [x] 4.1 `src/testing/wasm-host-bridge-conformance/wasm-host-bridge-conformance.ts` with 5 invariants.
|
||||
- [x] 4.2 Sibling .md, .types.ts, .test.ts, index.ts.
|
||||
|
||||
## 5. Tests
|
||||
|
||||
- [x] 5.1 `tests/fixtures/build-wasm.ts` — hand-coded minimal WASM binary that exports the suaveplan ABI (returns canned routes + response). No external compile dep.
|
||||
- [x] 5.2 Real `api-sandbox-wasm` + real `api-host` integration test in `tests/integration/full-flow.test.ts` against the fixture WASM.
|
||||
- [x] 5.3 100% istanbul coverage on touched modules.
|
||||
- [x] 5.4 Public-constructor lint clean.
|
||||
- [x] 5.5 `bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-sandbox-wasm-host-bridge` green.
|
||||
|
||||
## 6. Archive
|
||||
|
||||
- [ ] 6.1 Fold delta into `openspec/specs/api-sandbox-wasm-host-bridge/spec.md`. (deferred to merge step)
|
||||
- [ ] 6.2 Move folder → `openspec/archive/2026-05-27-api-sandbox-wasm-host-bridge/`. (deferred to merge step)
|
||||
- [x] 6.3 Refresh `MANIFEST.md`.
|
||||
Reference in New Issue
Block a user