405 lines
14 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|