Files
suaveplan/packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/integration/wasi-stdio-flow.test.ts
2026-05-27 23:29:53 +01:00

405 lines
14 KiB
TypeScript

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<typeof buildWasiStdioFixtureWasm>[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();
}
});
});