import { createWasmSandbox } from "@suaveplan/api-sandbox-wasm"; import type { PluginManifest } from "@suaveplan/api-types/manifest"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { SAND_WASM_BRIDGE_CODES, SandboxWasmBridgeError, } from "../../src/error-codes/error-codes.js"; import { createSandboxedWasmPluginModule } from "../../src/index.js"; import { buildFixtureWasm } from "../fixtures/build-wasm.js"; import { buildWasiStdioFixtureWasm } from "../fixtures/wasi-stdio/build-wasm.js"; const MANIFEST: PluginManifest = { name: "wasi-stdio-flow", version: "0.1.0", apiHost: "@suaveplan/api-host@0", isolation: "sandboxed", trustLevel: "third-party", requiredCapabilities: [], }; const ALLOW = { checkCapability: () => ({ allow: true as const }) }; async function buildWasiStdioInstall( fixture: Parameters[0] = {} ) { // Sandbox is built against the OLD direct-variant fixture so the // dispose chain has a real handle to drive. Its `instance` is not // used by the wasi-stdio bridge — the bridge builds its own // instance from `wasmModule` below. const sandbox = await createWasmSandbox({ pluginName: MANIFEST.name, policy: { fs: [], fetch: [], budgets: {} }, capabilities: [], checker: ALLOW, wasmBytes: buildFixtureWasm({}), }); const wasmModule = await WebAssembly.compile( buildWasiStdioFixtureWasm(fixture) ); return createSandboxedWasmPluginModule({ abiVariant: "wasi-stdio", sandbox, manifest: MANIFEST, wasmModule, }); } function ctx(method: string, path: string, body?: string): never { const url = `https://app.example.com${path}`; const request = body === undefined ? new Request(url, { method }) : new Request(url, { method, body, headers: { "content-type": "application/json" }, }); return { request, principal: null, tenant: null, capabilities: {}, requestId: "rid", } as never; } describe("wasi-stdio flow integration", () => { it("route discovery via envelope", { timeout: 30000 }, async () => { const install = await buildWasiStdioInstall(); try { expect(install.routes.map((r) => r.method)).toEqual([ "GET", "POST", ]); expect(install.routes.map((r) => r.path)).toEqual([ "/hello", "/echo", ]); expect( install.routes.every((r) => r.plugin === MANIFEST.name) ).toBe(true); } finally { install.module.stop(); } }); it("1 KiB body round-trips through WASI-stdio", { timeout: 30000, }, async () => { const install = await buildWasiStdioInstall(); try { install.module.start(); const route = install.routes[0]; if (route === undefined) throw new Error("no route"); const body = "x".repeat(1024); const response = (await route.handler( ctx("POST", "/hello", body) )) as Response; expect(response.status).toBe(200); expect(await response.text()).toBe("ok"); } finally { install.module.stop(); } }); it("WASI-stdio unreachable mid-request surfaces HANDLER_TRAPPED", { timeout: 30000, }, async () => { const install = await buildWasiStdioInstall({ trapOnCall: 2 }); try { install.module.start(); const route = install.routes[0]; if (route === undefined) throw new Error("no route"); await expect( route.handler(ctx("GET", "/hello")) ).rejects.toMatchObject({ code: SAND_WASM_BRIDGE_CODES.HANDLER_TRAPPED, context: { trapKind: "unreachable" }, }); } finally { install.module.stop(); } }); it("denied capability under WASI-stdio surfaces SAND_PERM.DENIED", { timeout: 30000, }, async () => { const install = await buildWasiStdioInstall({ requestReply: JSON.stringify({ kind: "error", code: "SAND_PERM.DENIED", message: "fs.write denied", }), }); try { install.module.start(); const route = install.routes[0]; if (route === undefined) throw new Error("no route"); await expect( route.handler(ctx("GET", "/hello")) ).rejects.toMatchObject({ code: "SAND_PERM.DENIED", }); } finally { install.module.stop(); } }); it("non-SAND_PERM plugin error surfaces CAPABILITY_DENIED via WASI-stdio", { timeout: 30000, }, async () => { const install = await buildWasiStdioInstall({ requestReply: JSON.stringify({ kind: "error", code: "PLUGIN.RATE_LIMITED", message: "too many", }), }); try { install.module.start(); const route = install.routes[0]; if (route === undefined) throw new Error("no route"); await expect( route.handler(ctx("GET", "/hello")) ).rejects.toMatchObject({ code: SAND_WASM_BRIDGE_CODES.CAPABILITY_DENIED, }); } finally { install.module.stop(); } }); it("WASI-stdio ABI validator rejects missing _start", { timeout: 30000, }, async () => { const sandbox = await createWasmSandbox({ pluginName: MANIFEST.name, policy: { fs: [], fetch: [], budgets: {} }, capabilities: [], checker: ALLOW, wasmBytes: buildFixtureWasm({}), }); const wasmModule = await WebAssembly.compile( buildWasiStdioFixtureWasm({ dropExports: ["_start"] }) ); try { createSandboxedWasmPluginModule({ abiVariant: "wasi-stdio", sandbox, manifest: MANIFEST, wasmModule, }); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(SandboxWasmBridgeError); const e = err as SandboxWasmBridgeError; expect(e.code).toBe(SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE); const ctxMissing = (e.context as { missing?: readonly string[] }) .missing; expect(ctxMissing).toContain("_start"); } finally { sandbox.dispose(); } }); it("WASI-stdio ABI validator rejects missing fd_write import", { timeout: 30000, }, async () => { const sandbox = await createWasmSandbox({ pluginName: MANIFEST.name, policy: { fs: [], fetch: [], budgets: {} }, capabilities: [], checker: ALLOW, wasmBytes: buildFixtureWasm({}), }); const wasmModule = await WebAssembly.compile( buildWasiStdioFixtureWasm({ dropImports: ["fd_write"] }) ); try { createSandboxedWasmPluginModule({ abiVariant: "wasi-stdio", sandbox, manifest: MANIFEST, wasmModule, }); throw new Error("expected throw"); } catch (err) { const e = err as SandboxWasmBridgeError; expect(e.code).toBe(SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE); const ctxMissing = (e.context as { missing?: readonly string[] }) .missing; expect(ctxMissing).toContain("wasi_snapshot_preview1.fd_write"); } finally { sandbox.dispose(); } }); it("dispose twice exchanges only one envelope", { timeout: 30000, }, async () => { // Configure the fixture so the dispose slot lines up with the // call counter even when no request is sent. With no request, // dispose lands at call counter 2, so we steer slot 2's reply // to `{kind:"ok"}` instead of the default request reply. const install = await buildWasiStdioInstall({ requestReply: JSON.stringify({ kind: "ok" }), }); install.module.start(); install.module.stop(); install.module.stop(); expect(install.lifecycle.disposed()).toBe(true); expect(install.sandbox.disposed).toBe(true); }); it("start twice runs once (idempotent)", { timeout: 30000, }, async () => { const install = await buildWasiStdioInstall({ requestReply: JSON.stringify({ kind: "ok" }), }); try { install.module.start(); install.module.start(); expect(install.lifecycle.started()).toBe(true); } finally { install.module.stop(); } }); it("malformed reply bytes from guest surface PROTOCOL_MISMATCH (decode failure)", { timeout: 30000, }, async () => { // Force the guest to emit a non-JSON byte sequence in the // request slot. The runner's decoder rejects with // PROTOCOL_MISMATCH (reason=invalid_json), and the bridge's // route handler routes the throw through the `marshal_failure` // instrument branch (HANDLER_TRAPPED is reserved for WASM // traps, not decoder failures). const install = await buildWasiStdioInstall({ requestReplyRaw: new TextEncoder().encode("not json at all"), }); try { install.module.start(); const route = install.routes[0]; if (route === undefined) throw new Error("no route"); await expect( route.handler(ctx("GET", "/hello")) ).rejects.toMatchObject({ code: SAND_WASM_BRIDGE_CODES.PROTOCOL_MISMATCH, }); } finally { install.sandbox.dispose(); } }); it("request reply with `ok` kind surfaces PROTOCOL_MISMATCH", { timeout: 30000, }, async () => { const install = await buildWasiStdioInstall({ requestReply: JSON.stringify({ kind: "ok" }), }); try { install.module.start(); const route = install.routes[0]; if (route === undefined) throw new Error("no route"); await expect( route.handler(ctx("GET", "/hello")) ).rejects.toMatchObject({ code: SAND_WASM_BRIDGE_CODES.PROTOCOL_MISMATCH, context: { expected: "response", received: "ok" }, }); } finally { install.sandbox.dispose(); } }); it("bridge accepts logger + stdioTimeoutMs + extraImports options", { timeout: 30000, }, async () => { const sandbox = await createWasmSandbox({ pluginName: MANIFEST.name, policy: { fs: [], fetch: [], budgets: {} }, capabilities: [], checker: ALLOW, wasmBytes: buildFixtureWasm({}), }); const wasmModule = await WebAssembly.compile( buildWasiStdioFixtureWasm({ requestReply: JSON.stringify({ kind: "ok" }), }) ); const lines: string[] = []; const install = createSandboxedWasmPluginModule({ abiVariant: "wasi-stdio", sandbox, manifest: MANIFEST, wasmModule, logger: { warn: (line) => lines.push(line) }, stdioTimeoutMs: 9999, extraImports: { foo_namespace: {} }, }); try { install.module.start(); } finally { install.module.stop(); } expect(install.lifecycle.disposed()).toBe(true); }); it("missing requiredCapabilities falls back to empty array", { timeout: 30000, }, async () => { const sandbox = await createWasmSandbox({ pluginName: MANIFEST.name, policy: { fs: [], fetch: [], budgets: {} }, capabilities: [], checker: ALLOW, wasmBytes: buildFixtureWasm({}), }); const wasmModule = await WebAssembly.compile( buildWasiStdioFixtureWasm({}) ); const manifestNoCap = { ...MANIFEST }; delete (manifestNoCap as { requiredCapabilities?: readonly string[] }) .requiredCapabilities; const install = createSandboxedWasmPluginModule({ abiVariant: "wasi-stdio", sandbox, manifest: manifestNoCap as never, wasmModule, }); expect(install.module.requires).toEqual([]); install.sandbox.dispose(); }); it("install.lifecycle adapter exposes start/dispose handles", { timeout: 30000, }, async () => { const install = await buildWasiStdioInstall({ requestReply: JSON.stringify({ kind: "ok" }), }); install.lifecycle.start(); expect(install.lifecycle.started()).toBe(true); install.lifecycle.dispose(); expect(install.lifecycle.disposed()).toBe(true); }); it("protocol mismatch — start replied with routes surfaces LIFECYCLE_FAILED", { timeout: 30000, }, async () => { const install = await buildWasiStdioInstall({ startReply: JSON.stringify({ kind: "routes", routes: [] }), }); try { expect(() => install.module.start()).toThrow(); } catch { // ignored } finally { // Skip stop() — the start failed mid-exchange and dispose // would land on the wrong fixture slot; we already proved // idempotency in the other tests. install.sandbox.dispose(); } }); });