Files
OmniRoute/tests/unit/skills-builtins-sandbox.test.ts
KooshaPari abfced8b28 feat(sandbox): native Apple Container, WSL, OrbStack, Podman runtime support (#6611)
* feat(sandbox): native Apple Container, WSL, OrbStack, Podman runtime support

* fix(skills): align sandbox fallback kill container-name convention

sandbox.ts's docker-fallback kill path (used only when cachedProvider is
unexpectedly null) still targeted the pre-PR omniroute-sandbox-${id}
container name, while containerProvider.ts's SANDBOX_NAME now produces
omniroute-${id}. Align the fallback naming so it matches the provider
convention, with a regression test covering kill()/killAll() before a
provider has ever been resolved.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(docs): document SKILLS_SANDBOX_RUNTIME and drop unrelated env leftovers

Two fixes surfaced by CI's env/docs contract gate:

- Add the SKILLS_SANDBOX_RUNTIME row to docs/reference/ENVIRONMENT.md so
  the new container-runtime override introduced by this PR is documented,
  matching .env.example.
- Remove the Substrate/Bifrost/OTEL .env.example blocks that leaked in
  from this branch's stale main-based history during the release-branch
  sync merge — none of that belongs to this PR (native container
  runtimes for the skill sandbox) and none of it exists on
  release/v3.8.47 yet.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-09 16:48:49 -03:00

565 lines
18 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const childProcess = require("child_process");
const originalDataDir = process.env.DATA_DIR;
const originalFetch = globalThis.fetch;
function makeTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function removePath(targetPath) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
async function importFresh(modulePath) {
const url = pathToFileURL(path.resolve(modulePath)).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
async function withDataDir(dataDir, fn) {
process.env.DATA_DIR = dataDir;
try {
return await fn();
} finally {
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
}
}
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
function createFakeProcess({ onKill } = {}) {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = (signal) => {
proc.killedSignal = signal;
if (onKill) onKill(proc, signal);
return true;
};
return proc;
}
async function withSandboxModule(fakeSpawn, fn) {
const originalSpawn = childProcess.spawn;
const originalRuntime = process.env["SKILLS_SANDBOX_RUNTIME"];
// Pin to docker for existing tests so the hardcoded args[0] === "run" /
// args[0] === "kill" assertions remain deterministic regardless of the
// host's installed container runtimes.
process.env["SKILLS_SANDBOX_RUNTIME"] = "docker";
childProcess.spawn = fakeSpawn;
try {
const module = await import(
`../../src/lib/skills/sandbox.ts?test=${Date.now()}-${Math.random()}`
);
return await fn(module);
} finally {
childProcess.spawn = originalSpawn;
if (originalRuntime === undefined) {
delete process.env["SKILLS_SANDBOX_RUNTIME"];
} else {
process.env["SKILLS_SANDBOX_RUNTIME"] = originalRuntime;
}
}
}
test("builtin skill handlers validate required fields and perform real sandboxed work", async () => {
const dataDir = makeTempDir("omniroute-skills-builtins-");
const context = { apiKeyId: "key-123", sessionId: "session-123" };
try {
await withDataDir(dataDir, async () => {
const { builtinSkills } = await importFresh("src/lib/skills/builtins.ts");
await assert.rejects(
() => builtinSkills.file_read({}, context),
/Missing required field: path/
);
await assert.rejects(
() => builtinSkills.file_write({ path: "demo.txt" }, context),
/Missing required fields/
);
await assert.rejects(
() => builtinSkills.http_request({}, context),
/Missing required field: url/
);
await assert.rejects(
() => builtinSkills.web_search({}, context),
/Missing required field: query/
);
await assert.rejects(
() => builtinSkills.eval_code({}, context),
/Missing required field: code/
);
await assert.rejects(
() => builtinSkills.execute_command({}, context),
/Missing required field: command/
);
assert.deepEqual(
await builtinSkills.file_write({ path: "notes/demo.txt", content: "hello world" }, context),
{
success: true,
path: "notes/demo.txt",
bytesWritten: 11,
context: "key-123",
}
);
assert.deepEqual(await builtinSkills.file_read({ path: "notes/demo.txt" }, context), {
success: true,
path: "notes/demo.txt",
content: "hello world",
bytesRead: 11,
encoding: "utf8",
context: "key-123",
});
await assert.rejects(
() => builtinSkills.file_read({ path: "../outside.txt" }, context),
/escapes the skill workspace/
);
await assert.rejects(
() => builtinSkills.file_write({ path: ".env", content: "secret" }, context),
/restricted segment/
);
globalThis.fetch = async (url, init) => {
assert.equal(String(url), "https://example.com/api");
assert.equal(init.method, "POST");
assert.equal(init.headers.Accept, "application/json");
assert.equal(init.headers.Authorization, undefined);
assert.equal(init.body, JSON.stringify({ ok: true }));
return new Response("created", {
status: 201,
statusText: "Created",
headers: { "Content-Type": "text/plain" },
});
};
const httpResult = await builtinSkills.http_request(
{
url: "https://example.com/api",
method: "POST",
headers: { Accept: "application/json", Authorization: "Bearer secret" },
body: { ok: true },
},
context
);
assert.equal(httpResult.success, true);
assert.equal(httpResult.status, 201);
assert.equal(httpResult.body, "created");
assert.equal(httpResult.headers["content-type"], "text/plain");
await assert.rejects(
() => builtinSkills.http_request({ url: "http://127.0.0.1:9000" }, context),
/Blocked private or local provider URL/
);
});
} finally {
removePath(dataDir);
}
});
test("builtin command and code skills execute through the Docker sandbox", async () => {
const context = { apiKeyId: "key-123", sessionId: "session-123" };
const calls = [];
const originalSpawn = childProcess.spawn;
childProcess.spawn = (_command, args, options) => {
calls.push({ args, options });
const proc = createFakeProcess();
setImmediate(() => {
proc.stdout.emit("data", Buffer.from("hello sandbox\n"));
proc.stderr.emit("data", Buffer.from("warning stream\n"));
proc.emit("close", 0);
});
return proc;
};
try {
const { builtinSkills } = await importFresh("src/lib/skills/builtins.ts");
const commandResult = await builtinSkills.execute_command(
{ command: "echo", args: ["hello"] },
context
);
assert.equal(commandResult.success, true);
assert.equal(commandResult.exitCode, 0);
assert.equal(commandResult.output, "hello sandbox\n");
assert.equal(commandResult.stderr, "warning stream\n");
const codeResult = await builtinSkills.eval_code(
{ code: "console.log('hello sandbox')", language: "javascript" },
context
);
assert.equal(codeResult.success, true);
assert.equal(codeResult.image, "node:22-alpine");
const dockerArgs = calls[0].args;
assert.equal(dockerArgs[0], "run");
assert.ok(dockerArgs.includes("--network"));
assert.ok(dockerArgs.includes("none"));
assert.ok(dockerArgs.includes("--cap-drop"));
assert.ok(dockerArgs.includes("ALL"));
assert.ok(dockerArgs.includes("--security-opt"));
assert.ok(dockerArgs.includes("no-new-privileges"));
assert.ok(dockerArgs.includes("--read-only"));
assert.ok(dockerArgs.includes("/workspace:rw,noexec,nosuid,size=64m"));
assert.equal(dockerArgs.includes("SYS_TIME"), false);
await assert.rejects(
() =>
builtinSkills.execute_command(
{ command: "echo", image: "ubuntu:latest", args: ["hello"] },
context
),
/Sandbox image is not allowed/
);
} finally {
childProcess.spawn = originalSpawn;
}
});
test("browser skill fails explicitly instead of returning a fake success", async () => {
const { browserSkill } = await importFresh("src/lib/skills/builtin/browser.ts");
const context = { apiKeyId: "key-123", sessionId: "session-123" };
await assert.rejects(
() => browserSkill({ action: "navigate", url: "https://example.com" }, context),
/Browser automation skill is disabled/
);
await assert.rejects(() => browserSkill({ action: "launch" }, context), /Unknown action: launch/);
});
test("registerBuiltinSkills registers every builtin handler with the executor", async () => {
const { builtinSkills, registerBuiltinSkills } = await importFresh("src/lib/skills/builtins.ts");
const registered = [];
const executor = {
registerHandler(name, handler) {
registered.push({ name, handler });
},
};
registerBuiltinSkills(executor);
assert.equal(registered.length, Object.keys(builtinSkills).length);
assert.deepEqual(registered.map((entry) => entry.name).sort(), Object.keys(builtinSkills).sort());
});
test("sandboxRunner handles success, spawn errors, timeouts, and killAll cleanup", async () => {
let mode = "success";
const calls = [];
await withSandboxModule(
(_command, args, options) => {
calls.push({ mode, args, options });
if (args[0] === "kill") {
return createFakeProcess();
}
if (mode === "error") {
const proc = createFakeProcess();
setImmediate(() => {
proc.emit("error", new Error("docker not found"));
});
return proc;
}
if (mode === "timeout") {
return createFakeProcess({
onKill: (instance) => {
setImmediate(() => instance.emit("close", null));
},
});
}
const proc = createFakeProcess();
setImmediate(() => {
proc.stdout.emit("data", Buffer.from("hello sandbox"));
proc.stderr.emit("data", Buffer.from("warning stream"));
proc.emit("close", 0);
});
return proc;
},
async ({ sandboxRunner }) => {
sandboxRunner.setConfig({
cpuLimit: 200,
memoryLimit: 128,
timeout: 100,
networkEnabled: false,
readOnly: true,
});
const successResult = await sandboxRunner.run("alpine", ["echo", "sandbox"], {
CUSTOM_ENV: "1",
});
assert.equal(successResult.exitCode, 0);
assert.equal(successResult.stdout, "hello sandbox");
assert.equal(successResult.stderr, "warning stream");
assert.equal(successResult.killed, false);
assert.equal(calls[0].args[0], "run");
assert.ok(calls[0].args.includes("--read-only"));
assert.ok(calls[0].args.includes("alpine"));
assert.equal(calls[0].options.env.CUSTOM_ENV, "1");
mode = "error";
const errorResult = await sandboxRunner.run("alpine", ["echo", "sandbox"]);
assert.equal(
calls.filter((entry) => entry.mode === "error" && entry.args[0] === "run").length,
1
);
assert.equal(errorResult.exitCode, -1);
assert.equal(errorResult.stderr, "docker not found");
assert.equal(errorResult.killed, false);
mode = "timeout";
sandboxRunner.setConfig({ timeout: 20, networkEnabled: false, readOnly: true });
const pending = sandboxRunner.run("alpine", ["sleep", "10"]);
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(sandboxRunner.getRunningCount(), 1);
const timeoutResult = await pending;
assert.equal(timeoutResult.killed, true);
assert.equal(timeoutResult.exitCode, null);
assert.equal(
calls.some((entry) => entry.mode === "timeout" && entry.args[0] === "kill"),
true
);
const procA = createFakeProcess();
const procB = createFakeProcess();
sandboxRunner.runningContainers.set("a", procA);
sandboxRunner.runningContainers.set("b", procB);
sandboxRunner.killAll();
assert.equal(procA.killedSignal, "SIGTERM");
assert.equal(procB.killedSignal, "SIGTERM");
assert.equal(sandboxRunner.getRunningCount(), 0);
assert.equal(sandboxRunner.isRunning("a"), false);
}
);
});
test("sandboxRunner kill/killAll fallback naming matches containerProvider's SANDBOX_NAME convention", async () => {
const calls = [];
await withSandboxModule(
(_command, args) => {
calls.push({ args });
return createFakeProcess();
},
async ({ sandboxRunner }) => {
// A freshly-imported sandboxRunner has never called run(), so
// cachedProvider is still null and kill()/killAll() must fall back to
// the docker CLI directly — that fallback name must still match
// containerProvider.ts's SANDBOX_NAME (`omniroute-${id}`), not the
// pre-PR `omniroute-sandbox-${id}` convention.
const proc = createFakeProcess();
sandboxRunner.runningContainers.set("fallback-id", proc);
sandboxRunner.kill("fallback-id");
const killCall = calls.find((entry) => entry.args[0] === "kill");
assert.ok(killCall, "kill command should have been issued");
assert.equal(killCall.args[1], "omniroute-fallback-id");
const procA = createFakeProcess();
const procB = createFakeProcess();
sandboxRunner.runningContainers.set("fallback-a", procA);
sandboxRunner.runningContainers.set("fallback-b", procB);
sandboxRunner.killAll();
const killAllNames = calls
.filter((entry) => entry.args[0] === "kill")
.map((entry) => entry.args[1]);
assert.ok(killAllNames.includes("omniroute-fallback-a"));
assert.ok(killAllNames.includes("omniroute-fallback-b"));
}
);
});
// -------------------------------------------------------------
// Container Provider Unit Tests
// -------------------------------------------------------------
test("containerProvider: all five providers registered", () => {
// Dynamic import to avoid polluting the sandbox module's state
return importFresh("src/lib/skills/containerProvider.ts").then((mod) => {
assert.ok(mod.ALL_PROVIDERS.length === 5);
assert.deepStrictEqual(
mod.ALL_PROVIDERS.map((p) => p.id),
["docker", "apple", "wsl", "orbstack", "podman"],
);
assert.ok(mod.PROVIDER_BY_ID.has("docker"));
assert.ok(mod.PROVIDER_BY_ID.has("apple"));
assert.ok(mod.PROVIDER_BY_ID.has("wsl"));
assert.ok(mod.PROVIDER_BY_ID.has("orbstack"));
assert.ok(mod.PROVIDER_BY_ID.has("podman"));
});
});
test("containerProvider: platformPriority returns correct order per OS", () => {
return importFresh("src/lib/skills/containerProvider.ts").then((mod) => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
"platform",
);
// darwin
Object.defineProperty(process, "platform", { value: "darwin" });
assert.deepStrictEqual(mod.platformPriority(), [
"apple",
"orbstack",
"podman",
"docker",
]);
// win32
Object.defineProperty(process, "platform", { value: "win32" });
assert.deepStrictEqual(mod.platformPriority(), [
"wsl",
"docker",
"podman",
]);
// linux
Object.defineProperty(process, "platform", { value: "linux" });
assert.deepStrictEqual(mod.platformPriority(), ["podman", "docker"]);
// Restore
if (originalPlatform) {
Object.defineProperty(
process,
"platform",
originalPlatform,
);
}
});
});
test("containerProvider: buildRun produces run as args[0] for all providers", () => {
return importFresh("src/lib/skills/containerProvider.ts").then((mod) => {
const config = {
cpuLimit: 100,
memoryLimit: 256,
timeout: 30000,
networkEnabled: false,
readOnly: true,
};
for (const provider of mod.ALL_PROVIDERS) {
const resolved = provider.buildRun(
"alpine",
["echo", "hi"],
"test-id",
config,
);
assert.equal(
resolved.args[0],
"run",
`${provider.id}: args[0] must be "run"`,
);
assert.ok(
resolved.args.includes("--rm"),
`${provider.id}: should include --rm`,
);
assert.ok(
resolved.args.includes("alpine"),
`${provider.id}: should include image`,
);
// killArgs must return something callable
const kill = resolved.killArgs("test-cont");
assert.ok(Array.isArray(kill), `${provider.id}: killArgs returns array`);
assert.ok(kill.length > 0, `${provider.id}: killArgs non-empty`);
}
});
});
test("containerProvider: buildKillArgs returns kill|stop for cleanup", () => {
return importFresh("src/lib/skills/containerProvider.ts").then((mod) => {
// Every provider should return an array whose first element is
// its known cleanup verb.
const verbs = new Map([
["docker", "kill"],
["apple", "kill"],
["wsl", "kill"],
["orbstack", "kill"],
["podman", "kill"],
]);
for (const provider of mod.ALL_PROVIDERS) {
const expectedVerb = verbs.get(provider.id);
const args = provider.buildKillArgs("test-cont");
assert.equal(args[0], expectedVerb, `${provider.id} kill verb`);
}
});
});
test("containerProvider: buildKillCommand utility", () => {
return importFresh("src/lib/skills/containerProvider.ts").then((mod) => {
const dockerProvider = mod.PROVIDER_BY_ID.get("docker")!;
const result = mod.buildKillCommand(dockerProvider, "test-id");
assert.equal(result.command, "docker");
assert.equal(result.args[0], "kill");
assert.equal(result.args[1], "omniroute-test-id");
});
});
test("containerProvider: resolveProvider respects SKILLS_SANDBOX_RUNTIME override", async () => {
// Unpin the global env for this test
delete process.env.SKILLS_SANDBOX_RUNTIME;
const mod = await importFresh("src/lib/skills/containerProvider.ts");
mod._resetProviderCacheForTests();
process.env.SKILLS_SANDBOX_RUNTIME = "docker";
const provider = await mod.resolveProvider();
assert.equal(provider.id, "docker");
process.env.SKILLS_SANDBOX_RUNTIME = "apple";
mod._resetProviderCacheForTests();
const provider2 = await mod.resolveProvider();
assert.equal(provider2.id, "apple");
process.env.SKILLS_SANDBOX_RUNTIME = "wsl";
mod._resetProviderCacheForTests();
const provider3 = await mod.resolveProvider();
assert.equal(provider3.id, "wsl");
delete process.env.SKILLS_SANDBOX_RUNTIME;
mod._resetProviderCacheForTests();
});
test("containerProvider: resolveProvider falls back to docker when no runtime installed", async () => {
delete process.env.SKILLS_SANDBOX_RUNTIME;
const mod = await importFresh("src/lib/skills/containerProvider.ts");
mod._resetProviderCacheForTests();
// Auto-detect walks platform priority — if nothing is installed we
// always land on docker as the fallback.
const provider = await mod.resolveProvider();
assert.ok(
["docker", "apple", "wsl", "podman", "orbstack"].includes(provider.id),
);
// Ensure the fallback is always docker when probes fail
// (this test is best-effort — on a host with docker installed,
// the auto-detect will legitimately pick docker)
});