6.0 KiB
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) andcreateSandboxedVmPluginModuleis called - THEN the returned PluginModule's
routesarray 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"andtenant.id="t1"reaches a sandboxed handler - THEN the handler observes
serialized.principal.id === "u1"andserialized.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
SandboxVmBridgeErrorwhosecode === "SAND_VM_BRIDGE.HANDLER_REJECTED"AND whosecauseis the originalSandboxVmError
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
SandboxVmBridgeErrorwithcode === "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.writeAND 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
runVmHostBridgeConformanceruns against a stub bridge whose marshal-failure path returnsnullinstead 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"