mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
20
.env.example
20
.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
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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.
|
||||
|
||||
479
src/lib/skills/containerProvider.ts
Normal file
479
src/lib/skills/containerProvider.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user