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>
This commit is contained in:
KooshaPari
2026-07-09 12:48:49 -07:00
committed by GitHub
parent e697670046
commit abfced8b28
5 changed files with 759 additions and 46 deletions

View File

@@ -211,6 +211,17 @@ NODE_ENV=production
# gives the correct fix instructions (podman unshare chown vs sudo chown).
CONTAINER_HOST=docker
# Container runtime override for skill sandboxing.
# Used by: src/lib/skills/sandbox.ts + src/lib/skills/containerProvider.ts
# Values: auto | docker | apple | wsl | orbstack | podman
# - auto: OS-aware auto-detect (apple/orbstack on macOS, wsl on Windows, podman on Linux)
# - apple: Apple Container (native OCI on macOS 26+)
# - wsl: WSL Container CLI (wslc.exe on Windows)
# - orbstack: OrbStack (high-perf Linux VM + docker shim on macOS)
# - podman: Podman (rootless, daemonless)
# - docker: Docker (default fallback)
SKILLS_SANDBOX_RUNTIME=auto
# ═══════════════════════════════════════════════════════════════════════════════
# 4. SECURITY & AUTHENTICATION
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1821,6 +1832,15 @@ APP_LOG_TO_FILE=true
# SKILLS_SANDBOX_NETWORK_ENABLED=0
# SKILLS_ALLOWED_SANDBOX_IMAGES=
# Container runtime used by the skill sandbox. Accepted values:
# auto — pick the best installed runtime per host OS (default)
# docker — Docker Engine / Docker Desktop
# apple — Apple Container (macOS native, micro-VM)
# wsl — WSL Container (Windows native via wslc.exe)
# orbstack — OrbStack (high-perf Linux VM + docker shim on macOS)
# podman — Podman (rootless, daemonless)
# SKILLS_SANDBOX_RUNTIME=auto
# ═══════════════════════════════════════════════════════════════════════════════
# 25. TEST & E2E
# ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -990,6 +990,7 @@ Limits and safety knobs applied when the Skills framework (`src/lib/skills/`) ex
| `SKILLS_SANDBOX_NETWORK_ENABLED` | `false` | `src/lib/skills/builtins.ts` | Set `1`/`true` to allow outbound network from inside the sandbox. Defaults to **isolated** for safety. |
| `SKILLS_ALLOWED_SANDBOX_IMAGES` | _(empty)_ | `src/lib/skills/builtins.ts` | Comma-separated allowlist of container images permitted for sandbox execution. Empty means built-in default only. |
| `SKILLS_SANDBOX_DOCKER_IMAGE` | _(built-in default)_ | `src/lib/skills/` | Container image used when spawning a Docker-backed sandbox. Override to pin a custom hardened base image. |
| `SKILLS_SANDBOX_RUNTIME` | `auto` | `src/lib/skills/sandbox.ts`, `src/lib/skills/containerProvider.ts` | Container runtime for skill sandboxing: `auto` \| `docker` \| `apple` \| `wsl` \| `orbstack` \| `podman`. `auto` picks the best installed runtime per host OS (Apple Container/OrbStack on macOS, WSL Container on Windows, Podman on Linux), falling back to Docker. |
> [!CAUTION]
> Enabling `SKILLS_SANDBOX_NETWORK_ENABLED=true` opens an egress path from arbitrary skill code. Pair with `OUTBOUND_SSRF_GUARD_ENABLED=true` and a strict `CORS_ORIGIN`/proxy policy in shared deployments.

View File

@@ -0,0 +1,479 @@
/**
* Container runtime providers for the OmniRoute skill sandbox.
*
* The sandbox historically hardcoded the `docker` CLI. This module abstracts
* the container runtime so OmniRoute can pick the most performant / native
* runtime available on each host:
*
* - macOS: Apple Container (`container` CLI) > OrbStack (docker shim) > Podman > Docker
* - Windows: WSL Container (`wslc` CLI) > Docker Desktop > Podman
* - Linux: Podman (rootless, daemonless) > Docker
*
* The user can override the auto-detected choice with `SKILLS_SANDBOX_RUNTIME`
* (`auto | docker | apple | wsl | orbstack | podman`). Each provider maps the
* sandbox's intent (resource caps, network isolation, capability drops,
* read-only fs, tmpfs workspaces) onto the runtime's native flag set.
*/
import { createRequire } from "module";
import os from "os";
const require = createRequire(import.meta.url);
const childProcess = require("child_process") as typeof import("child_process");
export type SandboxRuntimeId = "docker" | "apple" | "wsl" | "orbstack" | "podman";
export interface SandboxConfig {
cpuLimit: number;
memoryLimit: number;
timeout: number;
networkEnabled: boolean;
readOnly: boolean;
}
export interface ResolvedContainerCommand {
/** Absolute command to spawn (e.g. `"docker"`, `"container"`, `"wslc"`). */
command: string;
/** Arguments for the command. */
args: string[];
/** Arguments appended for the `kill` cleanup path. */
killArgs: (containerName: string) => string[];
}
export interface ContainerProvider {
readonly id: SandboxRuntimeId;
readonly displayName: string;
/** Returns true when this runtime is installed and usable on the host. */
detect(): boolean;
/** Build a run command for the given image, command, and config. */
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand;
/** Build a kill/stop command for a running container. */
killCommand: string;
buildKillArgs(name: string): string[];
}
// ----------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------
const SANDBOX_NAME = (sandboxId: string) => `omniroute-${sandboxId}`;
/**
* Probe whether a CLI binary exists on PATH.
* Uses `where` on Windows, `which` on *nix — both via spawnSync so existing
* test mocks on `spawn` (but not `spawnSync`) are not disturbed.
*/
function probeCommand(binary: string): boolean {
const args =
process.platform === "win32" ? ["where", binary] : ["which", binary];
const r = childProcess.spawnSync(args[0], args.slice(1), {
encoding: "utf8",
stdio: "ignore",
});
return r.status === 0;
}
/**
* Probe whether a binary responds to `--version` with exit 0 and
* a plausible version string.
*/
function probeVersion(binary: string, expects = "v"): boolean {
const r = childProcess.spawnSync(binary, ["--version"], {
encoding: "utf8",
stdio: "pipe",
});
return r.status === 0 && !!r.stdout?.trim()?.includes(expects);
}
// ----------------------------------------------------------------
// DockerProvider
// ----------------------------------------------------------------
class DockerProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "docker";
readonly displayName = "Docker";
readonly killCommand = "docker";
detect(): boolean {
return probeCommand("docker") && probeVersion("docker");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit / 100}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--pids-limit",
"100",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "docker",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// AppleContainerProvider (native Apple Container on macOS)
// ----------------------------------------------------------------
class AppleContainerProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "apple";
readonly displayName = "Apple Container";
readonly killCommand = "container";
detect(): boolean {
return probeCommand("container") && probeVersion("container", "c");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "container",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// WslContainerProvider (WSL 2 container CLI on Windows)
// ----------------------------------------------------------------
class WslContainerProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "wsl";
readonly displayName = "WSL Container";
readonly killCommand = "wslc";
detect(): boolean {
return probeCommand("wslc") && probeVersion("wslc");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "wslc",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// OrbStackProvider (high-perf Linux VM on macOS)
// ----------------------------------------------------------------
class OrbStackProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "orbstack";
readonly displayName = "OrbStack";
readonly killCommand = "orbstack";
detect(): boolean {
return probeCommand("orbstack") && probeVersion("orbstack");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
// OrbStack wraps Docker inside a Linux VM. We invoke the `orbstack`
// binary which shims `docker` transparently.
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "orbstack",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// PodmanProvider (rootless Linux alternative)
// ----------------------------------------------------------------
class PodmanProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "podman";
readonly displayName = "Podman";
readonly killCommand = "podman";
detect(): boolean {
return probeCommand("podman") && probeVersion("podman");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit / 100}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "podman",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// Registry & auto-detection
// ----------------------------------------------------------------
export const ALL_PROVIDERS: ContainerProvider[] = [
new DockerProvider(),
new AppleContainerProvider(),
new WslContainerProvider(),
new OrbStackProvider(),
new PodmanProvider(),
];
export const PROVIDER_BY_ID = new Map<SandboxRuntimeId, ContainerProvider>(
ALL_PROVIDERS.map((p) => [p.id, p]),
);
/** Priority order for auto-detection on each platform. */
export function platformPriority(): SandboxRuntimeId[] {
switch (os.platform()) {
case "darwin":
// Apple Container is the native micro-VM runtime on Apple Silicon —
// fastest startup, lowest overhead. OrbStack provides a Docker shim
// inside a tuned Linux VM; better than stock Docker Desktop.
return ["apple", "orbstack", "podman", "docker"];
case "win32":
// WSL Container CLI (wslc.exe) is Windows-native via WSL 2.
return ["wsl", "docker", "podman"];
default:
// Linux — podman is rootless + daemonless and therefore preferred.
return ["podman", "docker"];
}
}
// Detect-once memoization
let detectionInFlight: Promise<void> | null = null;
const detectionCache = new Map<SandboxRuntimeId, boolean>();
function clearDetectionCache(): void {
detectionInFlight = null;
detectionCache.clear();
}
async function runDetection(): Promise<void> {
// Run all probes in parallel for speed
await Promise.all(
ALL_PROVIDERS.map(async (provider) => {
const ok = await Promise.resolve(provider.detect());
detectionCache.set(provider.id, ok);
}),
);
}
function normaliseRuntimeOverride(
raw: string | undefined,
): SandboxRuntimeId | null {
if (!raw || raw === "auto") return null;
const lowered = raw.toLowerCase().trim();
if (PROVIDER_BY_ID.has(lowered as SandboxRuntimeId))
return lowered as SandboxRuntimeId;
return null;
}
/**
* Resolves which runtime the sandbox should use for the current host.
*
* Resolution rules (in order):
* 1. Explicit override via `SKILLS_SANDBOX_RUNTIME`.
* 2. Auto-detect: walk the platform priority list and pick the first
* runtime whose `detect()` succeeds.
* 3. Fall back to the Docker provider (the historical default) even if
* detection fails — the spawn will surface a clear "docker not
* found" error if Docker really is missing.
*/
export async function resolveProvider(): Promise<ContainerProvider> {
if (!detectionInFlight) {
detectionInFlight = runDetection();
}
await detectionInFlight;
const override = normaliseRuntimeOverride(
process.env.SKILLS_SANDBOX_RUNTIME,
);
if (override) {
const provider = PROVIDER_BY_ID.get(override)!;
if (detectionCache.get(provider.id)) return provider;
// Honour the explicit override even if detection failed — the user may
// be running inside an environment where the runtime is reachable but
// our probe failed (e.g. very locked-down CI).
return provider;
}
for (const id of platformPriority()) {
if (detectionCache.get(id)) return PROVIDER_BY_ID.get(id)!;
}
return PROVIDER_BY_ID.get("docker")!;
}
/** Exposed for tests — forces a fresh detection pass. */
export function _resetProviderCacheForTests(): void {
clearDetectionCache();
}
/**
* Returns the kill command for the given provider, parameterised with the
* sandbox's container name. Used by SandboxRunner.kill/killAll.
*/
export function buildKillCommand(
provider: ContainerProvider,
sandboxId: string,
): { command: string; args: string[] } {
const name = SANDBOX_NAME(sandboxId);
return {
command: provider.killCommand,
args: provider.buildKillArgs(name),
};
}

View File

@@ -1,20 +1,20 @@
import { createRequire } from "module";
import type { ChildProcess } from "child_process";
import { randomUUID } from "crypto";
import {
resolveProvider,
buildKillCommand,
type ContainerProvider,
type SandboxConfig,
type SandboxRuntimeId,
} from "./containerProvider.ts";
const require = createRequire(import.meta.url);
const childProcess = require("child_process") as typeof import("child_process");
interface SandboxConfig {
cpuLimit: number;
memoryLimit: number;
timeout: number;
networkEnabled: boolean;
readOnly: boolean;
}
interface SandboxResult {
id: string;
runtime: SandboxRuntimeId;
exitCode: number | null;
stdout: string;
stderr: string;
@@ -34,6 +34,7 @@ class SandboxRunner {
private static instance: SandboxRunner;
private runningContainers: Map<string, ChildProcess> = new Map();
private config: SandboxConfig;
private cachedProvider: ContainerProvider | null = null;
private constructor(config: Partial<SandboxConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
@@ -50,6 +51,19 @@ class SandboxRunner {
this.config = { ...this.config, ...config };
}
/**
* Returns the container provider that the next `run()` call will use.
* Resolution is async (it shells out to probe installed runtimes) so the
* caller must `await`. The result is cached on the runner for the
* remainder of the process so subsequent `run()` calls stay sync-friendly.
*/
async getProvider(): Promise<ContainerProvider> {
if (!this.cachedProvider) {
this.cachedProvider = await resolveProvider();
}
return this.cachedProvider;
}
async run(
image: string,
command: string[],
@@ -59,40 +73,11 @@ class SandboxRunner {
const sandboxId = randomUUID();
const startTime = Date.now();
const config = { ...this.config, ...configOverride };
const dockerArgs = [
"run",
"--rm",
"--name",
`omniroute-sandbox-${sandboxId}`,
"--cpus",
`${config.cpuLimit / 1000}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--pids-limit",
"100",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) {
dockerArgs.push("--read-only");
}
dockerArgs.push(image, ...command);
const provider = await this.getProvider();
const resolved = provider.buildRun(image, command, sandboxId, config);
return new Promise((resolve) => {
const proc = childProcess.spawn("docker", dockerArgs, {
const proc = childProcess.spawn(resolved.command, resolved.args, {
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
@@ -120,6 +105,7 @@ class SandboxRunner {
resolve({
id: sandboxId,
runtime: provider.id,
exitCode: code,
stdout,
stderr,
@@ -134,6 +120,7 @@ class SandboxRunner {
resolve({
id: sandboxId,
runtime: provider.id,
exitCode: -1,
stdout,
stderr: err.message,
@@ -149,18 +136,32 @@ class SandboxRunner {
if (proc) {
proc.kill("SIGTERM");
this.runningContainers.delete(sandboxId);
childProcess.spawn("docker", ["kill", `omniroute-sandbox-${sandboxId}`], {
stdio: "ignore",
});
const provider = this.cachedProvider;
if (provider) {
const kill = buildKillCommand(provider, sandboxId);
childProcess.spawn(kill.command, kill.args, { stdio: "ignore" });
} else {
childProcess.spawn("docker", ["kill", `omniroute-${sandboxId}`], {
stdio: "ignore",
});
}
return true;
}
return false;
}
killAll(): void {
const provider = this.cachedProvider;
for (const [id, proc] of this.runningContainers) {
proc.kill("SIGTERM");
childProcess.spawn("docker", ["kill", `omniroute-sandbox-${id}`], { stdio: "ignore" });
if (provider) {
const kill = buildKillCommand(provider, id);
childProcess.spawn(kill.command, kill.args, { stdio: "ignore" });
} else {
childProcess.spawn("docker", ["kill", `omniroute-${id}`], {
stdio: "ignore",
});
}
}
this.runningContainers.clear();
}
@@ -175,4 +176,4 @@ class SandboxRunner {
}
export const sandboxRunner = SandboxRunner.getInstance();
export type { SandboxConfig, SandboxResult };
export type { SandboxConfig, SandboxResult };

View File

@@ -1,4 +1,4 @@
import test from "node:test";
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
@@ -56,6 +56,11 @@ function createFakeProcess({ onKill } = {}) {
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 {
@@ -65,6 +70,11 @@ async function withSandboxModule(fakeSpawn, fn) {
return await fn(module);
} finally {
childProcess.spawn = originalSpawn;
if (originalRuntime === undefined) {
delete process.env["SKILLS_SANDBOX_RUNTIME"];
} else {
process.env["SKILLS_SANDBOX_RUNTIME"] = originalRuntime;
}
}
}
@@ -350,3 +360,205 @@ test("sandboxRunner handles success, spawn errors, timeouts, and killAll cleanup
}
);
});
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)
});