Files
suaveplan/openspec/specs/api-sandbox-vm-host-bridge/spec.md
2026-05-27 23:29:53 +01:00

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) 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"