From abfced8b2806d71670be989e6ce1c70471f346dd Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:48:49 -0700 Subject: [PATCH] feat(sandbox): native Apple Container, WSL, OrbStack, Podman runtime support (#6611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .env.example | 20 + docs/reference/ENVIRONMENT.md | 1 + src/lib/skills/containerProvider.ts | 479 +++++++++++++++++++++ src/lib/skills/sandbox.ts | 91 ++-- tests/unit/skills-builtins-sandbox.test.ts | 214 ++++++++- 5 files changed, 759 insertions(+), 46 deletions(-) create mode 100644 src/lib/skills/containerProvider.ts diff --git a/.env.example b/.env.example index 734402a092..31fe954c12 100644 --- a/.env.example +++ b/.env.example @@ -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 # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 1a987faff9..3bc0371570 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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. diff --git a/src/lib/skills/containerProvider.ts b/src/lib/skills/containerProvider.ts new file mode 100644 index 0000000000..129ac2f2ae --- /dev/null +++ b/src/lib/skills/containerProvider.ts @@ -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( + 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 | null = null; +const detectionCache = new Map(); + +function clearDetectionCache(): void { + detectionInFlight = null; + detectionCache.clear(); +} + +async function runDetection(): Promise { + // 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 { + 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), + }; +} \ No newline at end of file diff --git a/src/lib/skills/sandbox.ts b/src/lib/skills/sandbox.ts index 820d58c80c..556506b5f1 100644 --- a/src/lib/skills/sandbox.ts +++ b/src/lib/skills/sandbox.ts @@ -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 = new Map(); private config: SandboxConfig; + private cachedProvider: ContainerProvider | null = null; private constructor(config: Partial = {}) { 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 { + 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 }; \ No newline at end of file diff --git a/tests/unit/skills-builtins-sandbox.test.ts b/tests/unit/skills-builtins-sandbox.test.ts index 9f70ea219e..a696d3bf7a 100644 --- a/tests/unit/skills-builtins-sandbox.test.ts +++ b/tests/unit/skills-builtins-sandbox.test.ts @@ -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) +});