Initial Commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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"`
|
||||
@@ -0,0 +1,49 @@
|
||||
# Tasks — Sandbox VM Host Bridge
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work
|
||||
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each
|
||||
- [x] 0.3 Confirm `MANIFEST.md` package count matches `find packages -maxdepth 3 -name package.json`
|
||||
- [x] 0.4 §0a Sync: worktree-isolated agents fast-forward to orchestrator-supplied SHA
|
||||
|
||||
## 1. New shared types in api-types
|
||||
|
||||
- [x] 1.1 `packages/api-stack/api/api-types/src/sandbox-bridge/sandbox-bridge.types.ts` — `SerializedRequest`, `SerializedResponse`, `SerializedHeaders`, `SerializedPrincipal`, `SerializedTenant`. Zero runtime.
|
||||
- [x] 1.2 Sibling `.md`, `index.ts`, `.test.ts`.
|
||||
- [x] 1.3 `./sandbox-bridge` subpath added to api-types package.json exports.
|
||||
|
||||
## 2. Bridge package scaffold
|
||||
|
||||
- [x] 2.1 `packages/api-stack/api/api-sandbox-vm-host-bridge/` with `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`.
|
||||
- [x] 2.2 Deps: `@suaveplan/api-sandbox-vm`, `@suaveplan/api-host`, `@suaveplan/api-types`, `@suaveplan/error`, `tslib`, `zod`.
|
||||
|
||||
## 3. Implementation (feature folders)
|
||||
|
||||
- [x] 3.1 `src/bridge/bridge.ts` — `createSandboxedVmPluginModule({ sandbox, manifest, registry })` factory.
|
||||
- [x] 3.2 `src/marshal/marshal-request.ts` — host RequestContext → SerializedRequest.
|
||||
- [x] 3.3 `src/marshal/marshal-response.ts` — SerializedResponse → host Response.
|
||||
- [x] 3.4 `src/error-codes/error-codes.ts` — 5 SAND_VM_BRIDGE codes + SandboxVmBridgeError with public constructor.
|
||||
- [x] 3.5 `src/lifecycle/lifecycle.ts` — start/dispose forwarders with idempotency.
|
||||
- [x] 3.6 `src/route-discovery/route-discovery.ts` — `bridge.registerRoute` capability + registry-capture + non-empty enforcement.
|
||||
- [x] 3.7 `src/instruments/instruments.ts` — bridge-level OTel counters (requests-marshalled, marshal-failures, handler-rejections, invocation-duration histogram).
|
||||
- [x] 3.8 Paired `.md` per implementation file (≥200 words, 6 sections).
|
||||
|
||||
## 4. Conformance harness
|
||||
|
||||
- [x] 4.1 `src/testing/vm-host-bridge-conformance/vm-host-bridge-conformance.ts` — `runVmHostBridgeConformance({ buildBridge })` with 5 invariants.
|
||||
- [x] 4.2 Sibling `.md`, `.types.ts`, `.test.ts`, `index.ts`.
|
||||
- [x] 4.3 Re-export via `./testing` subpath.
|
||||
|
||||
## 5. Tests
|
||||
|
||||
- [x] 5.1 Real vm-sandbox + bridged-plugin integration test in `tests/integration/full-flow.test.ts`.
|
||||
- [x] 5.2 100% istanbul coverage on touched modules.
|
||||
- [x] 5.3 Public-constructor lint clean.
|
||||
- [x] 5.4 `bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-sandbox-vm-host-bridge` green.
|
||||
|
||||
## 6. Archive
|
||||
|
||||
- [x] 6.1 Fold delta into `openspec/specs/api-sandbox-vm-host-bridge/spec.md`.
|
||||
- [x] 6.2 Move folder → `openspec/archive/2026-05-27-api-sandbox-vm-host-bridge/`.
|
||||
- [x] 6.3 Refresh `MANIFEST.md`.
|
||||
Reference in New Issue
Block a user