92 lines
5.2 KiB
Markdown
92 lines
5.2 KiB
Markdown
# 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
|
|
|
|
```ts
|
|
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).
|