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

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 the PluginModule produced by this bridge.
  • @suaveplan/e2e-foundation-stack-full Stage-8 closure smoke — deploys api-plugin-example through 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 — deploys api-plugin-example via 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:

  • routes array is sourced from the sandboxed plugin's __suaveplan.routes() export (called at bridge-init time). Each route's handler is a closure that:
    1. Marshals the host's RequestContext into a serialisable SerializedRequest (URL, method, headers, body bytes, principal id, tenant id, capability snapshot).
    2. Calls into the sandbox via sandbox.invoke("handleRequest", serialized).
    3. Unmarshals the returned SerializedResponse into a host Response.
    4. Propagates errors via SuaveplanError (wrapped from SandboxVmError if the sandbox rejected).
  • start() / stop() / dispose() delegate to sandbox.start/dispose (with idempotent guards).
  • metrics() reports sandbox-side OTel counters via sandbox.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:

  1. Route registration — host receives all routes the sandbox declared.
  2. Request marshalling round-trip — request body, headers, principal, tenant all reach the sandbox AND a response round-trips back.
  3. Lifecycle — start fires sandbox.start; dispose fires sandbox.dispose; idempotent.
  4. Error propagation — sandbox-side throw surfaces as a host SuaveplanError with cause pointing at the sandbox's SandboxVmError.
  5. 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-bridge sibling 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).