From 302f2ee5c13ecbffb67fd4aa8a97280d61a86f15 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sat, 11 Jul 2026 17:20:15 +0700 Subject: [PATCH] fix(proxy): preserve existing notes fields on relay repair; fix cpu limit /1000 across all 5 container providers --- .../proxies/[id]/repair-relay/route.ts | 8 +- src/lib/skills/containerProvider.ts | 952 +++++++++--------- 2 files changed, 479 insertions(+), 481 deletions(-) diff --git a/src/app/api/settings/proxies/[id]/repair-relay/route.ts b/src/app/api/settings/proxies/[id]/repair-relay/route.ts index 476f1041d1..3c2b075866 100644 --- a/src/app/api/settings/proxies/[id]/repair-relay/route.ts +++ b/src/app/api/settings/proxies/[id]/repair-relay/route.ts @@ -73,8 +73,10 @@ export async function POST( // mode === "recovered": read the still-encrypted blob, decrypt, write plaintext. let enc: string | undefined; + let existingNotes: Record = {}; try { - const parsed = JSON.parse(proxy.notes ?? "{}") as { relayAuthEnc?: string }; + const parsed = JSON.parse(proxy.notes ?? "{}") as Record; + existingNotes = parsed; if (typeof parsed.relayAuthEnc === "string") enc = parsed.relayAuthEnc; } catch { enc = undefined; @@ -90,7 +92,9 @@ export async function POST( }); } - await updateProxy(id, { notes: JSON.stringify({ relayAuth: decrypted }) }); + // Merge relayAuth into existing notes; drop relayAuthEnc since plaintext is now present. + const { relayAuthEnc: _dropped, ...rest } = existingNotes as { relayAuthEnc?: unknown }; + await updateProxy(id, { notes: JSON.stringify({ ...rest, relayAuth: decrypted }) }); return Response.json({ repaired: true, mode: "recovered" }); } catch (error) { return createErrorResponseFromUnknown(error, "Failed to repair relay"); diff --git a/src/lib/skills/containerProvider.ts b/src/lib/skills/containerProvider.ts index 129ac2f2ae..2f0e749b30 100644 --- a/src/lib/skills/containerProvider.ts +++ b/src/lib/skills/containerProvider.ts @@ -1,479 +1,473 @@ -/** - * 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 +/** + * 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 / 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) 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 / 1000}`, + "--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 / 1000}`, + "--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 / 1000}`, + "--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 / 1000}`, + "--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), + }; +}