5.2 KiB
Sandbox VM Host Bridge
Package: @suaveplan/api-sandbox-vm-host-bridge
Status: PROPOSED
Type: new-package (host-integration layer)
Why
@suaveplan/api-sandbox-vm ships a VmSandboxHandle — a vm.SourceTextModule running inside an isolated vm.Context with frozen globals, permission-gated __suaveplan.fs/fetch/capabilities imports, and CPU/memory budgets. @suaveplan/api-host's installPlugin(spec) takes a PluginModule — a plain TypeScript object exporting routes, lifecycle hooks (start/stop/dispose), and a manifest.
These two surfaces do not meet. A VmSandboxHandle is not a PluginModule. There is no way today to deploy a plugin's code inside a vm-sandbox AND have the host route requests to it. The closure-smoke gate 2.8 ("api-plugin-example deploys under every isolation tier including vm") is unreachable without this bridge.
Concrete consumer
@suaveplan/api-host.installPlugin(spec)— consumes thePluginModuleproduced by this bridge.@suaveplan/e2e-foundation-stack-fullStage-8 closure smoke — deploysapi-plugin-examplethrough this bridge under the vm tier.- Any third-party plugin operator wanting to deploy moderately-trusted plugins (e.g. customer-provided JS) under vm-tier isolation.
Host bridge
This package IS the host bridge for api-sandbox-vm. §19 requires every primitive to ship with its host-integration; this proposal is that integration.
End-to-end test
e2e/foundation-stack-full/tests/sandbox-vm-deployment.spec.ts— deploysapi-plugin-examplevia this bridge, drives an HTTP request through the host, asserts response was produced by the sandboxed plugin (not by the host directly).
What changes
Package surface
import { createVmSandbox, type VmSandboxHandle } from "@suaveplan/api-sandbox-vm";
import { createSandboxedVmPluginModule } from "@suaveplan/api-sandbox-vm-host-bridge";
const sandbox = createVmSandbox({
pluginId: "example",
policy: { fetch: ["api.example.com"], fs: ["/data"] },
pluginModule: { kind: "source", source: pluginSource, identifier: "example.mjs" },
proxies: { fs: hostFs, fetch: hostFetch, capabilities: hostCaps },
checker: createPermissionChecker({ policy }),
});
const module = await createSandboxedVmPluginModule({
sandbox,
manifest: pluginManifest,
});
await host.installPlugin({ kind: "vm-sandboxed", module, manifest: pluginManifest });
createSandboxedVmPluginModule returns a PluginModule whose:
routesarray is sourced from the sandboxed plugin's__suaveplan.routes()export (called at bridge-init time). Each route'shandleris a closure that:- Marshals the host's
RequestContextinto a serialisableSerializedRequest(URL, method, headers, body bytes, principal id, tenant id, capability snapshot). - Calls into the sandbox via
sandbox.invoke("handleRequest", serialized). - Unmarshals the returned
SerializedResponseinto a hostResponse. - Propagates errors via
SuaveplanError(wrapped fromSandboxVmErrorif the sandbox rejected).
- Marshals the host's
start()/stop()/dispose()delegate tosandbox.start/dispose(with idempotent guards).metrics()reports sandbox-side OTel counters viasandbox.instruments.
Error codes
SAND_VM_BRIDGE.* registry:
INVALID_PLUGIN_SURFACE— sandboxed plugin didn't export__suaveplan.routes().MARSHAL_FAILED— request shape couldn't be serialised (e.g. unsupported header type).UNMARSHAL_FAILED— sandbox returned non-conformant response shape.HANDLER_REJECTED— sandbox handler threw uncaught.LIFECYCLE_FAILED— start/stop call into sandbox failed.
Marshalling contract
SerializedRequest and SerializedResponse are defined in @suaveplan/api-types/sandbox-bridge (new subpath) so both this bridge and the wasm bridge (sibling change api-sandbox-wasm-host-bridge) consume the SAME shape. Zero runtime, zero deps.
Capability proxy passthrough
The sandbox's __suaveplan.capabilities proxy is wired through to the host's RequestContext.capabilities — so a sandboxed handler that calls __suaveplan.capabilities.db.query(...) reaches the host's tenant-scoped db adapter via the permission gate.
Conformance harness
runVmHostBridgeConformance({ buildBridge }) from @suaveplan/api-sandbox-vm-host-bridge/testing:
- Route registration — host receives all routes the sandbox declared.
- Request marshalling round-trip — request body, headers, principal, tenant all reach the sandbox AND a response round-trips back.
- Lifecycle — start fires sandbox.start; dispose fires sandbox.dispose; idempotent.
- Error propagation — sandbox-side throw surfaces as a host
SuaveplanErrorwithcausepointing at the sandbox'sSandboxVmError. - Capability gate — a sandboxed handler that calls a denied capability sees
SAND_PERM.DENIED, NOT a silent failure.
Out of scope
- TS→WASM compilation (the
api-sandbox-wasm-host-bridgesibling handles WASM-tier; this package is vm-tier only). - Cross-context shared memory (vm.Context isolation is by design — sandboxed plugins talk to the host ONLY through the marshalled bridge).
- Hot-reload of vm-sandboxed plugins (covered by
api-host's existing hot-reload + this bridge's idempotent dispose/re-install).