103 lines
6.0 KiB
Markdown
103 lines
6.0 KiB
Markdown
# Capability Spec Delta: api-sandbox-vm-host-bridge
|
|
|
|
**Package**: `@suaveplan/api-sandbox-vm-host-bridge`
|
|
**Status**: PROPOSED
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: Wraps a `VmSandboxHandle` as a `PluginModule`
|
|
|
|
`createSandboxedVmPluginModule({ sandbox, manifest })` SHALL return a `PluginModule` compatible with `@suaveplan/api-host.installPlugin(spec)`. The returned module's `routes` field is sourced from the sandboxed plugin's `__suaveplan.routes()` export, called once at bridge-init time. Each route's `handler` is a closure that marshals the request, invokes the sandbox, and unmarshals the response.
|
|
|
|
#### Scenario: routes from sandbox match host registration
|
|
|
|
- **WHEN** the sandboxed plugin exports two routes (`GET /foo`, `POST /bar`) and `createSandboxedVmPluginModule` is called
|
|
- **THEN** the returned PluginModule's `routes` array contains exactly those two routes with matching paths and methods
|
|
|
|
#### Scenario: host registers the routes via installPlugin
|
|
|
|
- **WHEN** `host.installPlugin({ kind: "vm-sandboxed", module, manifest })` is called
|
|
- **THEN** the host's router (queried via `host.routes()`) reports both routes as installed
|
|
|
|
**Verified by:** `src/bridge/bridge.test.ts::"PluginModule.routes mirrors sandboxed plugin's exported routes"` AND `tests/integration/full-flow.test.ts::"host registers sandboxed routes via installPlugin"`
|
|
|
|
---
|
|
|
|
### Requirement: Round-trips a real request through the sandbox
|
|
|
|
A request that reaches a sandboxed route handler MUST be fully marshalled (URL, method, headers, body bytes, principal id, tenant id, capability snapshot) into a `SerializedRequest`, invoked via the sandbox boundary, and the returned `SerializedResponse` unmarshalled into a host `Response`.
|
|
|
|
#### Scenario: request body and headers traverse the bridge intact
|
|
|
|
- **WHEN** a POST with JSON body + a custom header reaches a sandboxed handler
|
|
- **THEN** the sandbox-side handler observes the exact body bytes and the header value, and its returned response (status + body) reaches the host caller
|
|
|
|
#### Scenario: principal and tenant context are visible to the sandbox
|
|
|
|
- **WHEN** an authenticated request with `principal.id="u1"` and `tenant.id="t1"` reaches a sandboxed handler
|
|
- **THEN** the handler observes `serialized.principal.id === "u1"` and `serialized.tenant.id === "t1"`
|
|
|
|
**Verified by:** `tests/integration/full-flow.test.ts::"POST body and custom header traverse the bridge"` AND `tests/integration/full-flow.test.ts::"principal and tenant reach the sandbox handler"`
|
|
|
|
---
|
|
|
|
### Requirement: Lifecycle delegation is idempotent
|
|
|
|
The bridge's `PluginModule.start()` MUST delegate to `sandbox.start()`; `dispose()` MUST delegate to `sandbox.dispose()`. Both must be idempotent — calling twice is a no-op (no double-start, no double-dispose error).
|
|
|
|
#### Scenario: dispose called twice is a no-op
|
|
|
|
- **WHEN** the bridge's `dispose()` is called twice
|
|
- **THEN** the second call returns without error AND the sandbox's `dispose()` was invoked exactly once
|
|
|
|
**Verified by:** `src/lifecycle/lifecycle.test.ts::"dispose is idempotent"` AND `src/lifecycle/lifecycle.test.ts::"start delegates to sandbox.start exactly once"`
|
|
|
|
---
|
|
|
|
### Requirement: Errors propagate through SuaveplanError with cause-chain
|
|
|
|
When a sandboxed handler throws, the bridge MUST catch the throw, wrap it in a `SandboxVmBridgeError` with `code: HANDLER_REJECTED` and `cause` pointing at the original `SandboxVmError` (or the raw thrown value). The host's error boundary sees the bridge error AND can walk the cause-chain to surface the sandbox's stack.
|
|
|
|
#### Scenario: sandbox throw surfaces as a HANDLER_REJECTED bridge error
|
|
|
|
- **WHEN** a sandboxed handler throws a `SandboxVmError`
|
|
- **THEN** the bridge surfaces a `SandboxVmBridgeError` whose `code === "SAND_VM_BRIDGE.HANDLER_REJECTED"` AND whose `cause` is the original `SandboxVmError`
|
|
|
|
#### Scenario: marshal failures surface as MARSHAL_FAILED
|
|
|
|
- **WHEN** the bridge cannot serialise a request (e.g. body is an unsupported type)
|
|
- **THEN** the bridge surfaces a `SandboxVmBridgeError` with `code === "SAND_VM_BRIDGE.MARSHAL_FAILED"` AND the route handler does NOT reach the sandbox
|
|
|
|
**Verified by:** `src/bridge/bridge.test.ts::"sandbox throw surfaces as HANDLER_REJECTED"` AND `src/marshal/marshal-request.test.ts::"unsupported body type surfaces as MARSHAL_FAILED"`
|
|
|
|
---
|
|
|
|
### Requirement: Capability proxy passthrough
|
|
|
|
A sandboxed handler that calls `__suaveplan.capabilities.<name>.<method>(...)` MUST reach the host's tenant-scoped capability via the sandbox's permission gate. Denied capabilities surface `SAND_PERM.DENIED` from `@suaveplan/api-sandbox-permissions` — NOT a silent failure or a host-side crash.
|
|
|
|
#### Scenario: allowed capability reaches the host
|
|
|
|
- **WHEN** the sandbox policy includes `capabilities: ["db.query"]` AND a handler calls `__suaveplan.capabilities.db.query(...)`
|
|
- **THEN** the host's db adapter receives the query AND the response reaches the handler
|
|
|
|
#### Scenario: denied capability surfaces SAND_PERM.DENIED
|
|
|
|
- **WHEN** the sandbox policy excludes `capabilities.fs.write` AND a handler attempts `__suaveplan.capabilities.fs.write(...)`
|
|
- **THEN** the handler observes a thrown error with `code === "SAND_PERM.DENIED"`
|
|
|
|
**Verified by:** `tests/integration/full-flow.test.ts::"allowed capability reaches host db adapter"` AND `tests/integration/full-flow.test.ts::"denied capability surfaces SAND_PERM.DENIED"`
|
|
|
|
---
|
|
|
|
### Requirement: Conformance harness `runVmHostBridgeConformance`
|
|
|
|
The package SHALL ship `runVmHostBridgeConformance({ buildBridge })` from `@suaveplan/api-sandbox-vm-host-bridge/testing` with five invariants (route-registration, request-round-trip, lifecycle, error-propagation, capability-gate). Every concrete bridge configuration MUST pass.
|
|
|
|
#### Scenario: harness rejects a bridge that swallows errors silently
|
|
|
|
- **WHEN** `runVmHostBridgeConformance` runs against a stub bridge whose marshal-failure path returns `null` instead of throwing
|
|
- **THEN** the harness reports the error-propagation invariant failure
|
|
|
|
**Verified by:** `src/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.test.ts::"harness rejects silent-failure bridge"`
|