diff --git a/AGENTS.md b/AGENTS.md index b278cf768..53505991e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,9 @@ ## Sandbox -- `src/sandbox/sbx.ts` is the only module invoking the `sbx` CLI; route through its `SandboxRuntime` facade. `src/sandbox/process.ts` is the only child-process spawner; all shell execution goes through `runCommand`. +- `src/sandbox/sbx.ts` is the only module invoking the `sbx` CLI and `src/sandbox/smolvm.ts` the only one invoking `smolvm`; both are constructed exclusively through `createSandboxRuntime` in `src/sandbox/runtime-factory.ts`, which is the single mode-dispatch point. `src/sandbox/process.ts` is the only child-process spawner; all shell execution goes through `runCommand`. +- `buildShimScript` in `src/sandbox/shell-shim.ts` is the second place backend CLI shapes are encoded (as generated shell text, not a spawn) and must stay mode-aware and fail-closed. +- `buildSmolvmRootWrapper` in `src/sandbox/smolvm.ts` is the only guest-user elevation, shared by `buildSmolvmExecArgs` and the shim's smolvm branch: smolvm virtiofs has no uid mapping, so only root can write the mounts. Never add a second elevation path, and never elevate in `sbx` mode — it id-maps mounts to `agent`. - `getSandboxState` is the only liveness primitive; four states: `running`, `stopped` (reusable, never create/evict), `unknown` (query failed), `missing` (may create or evict). `registerActiveSandbox` is the only place a usable sandbox is recorded. - `container/Dockerfile` must derive from `docker.io/docker/sandbox-templates:shell-docker`; no `ENTRYPOINT`, `CMD`, or `WORKDIR`. diff --git a/docs/configuration.md b/docs/configuration.md index 24ce7ff0d..2af47660b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,7 +75,7 @@ Configured rules are layered into the ruleset in this order: 1. Blanket allow-all (worktree/audit isolation). 2. Blanket `external_directory` deny. -3. `external_directory` allows (opencode's tool-output directory, then `loop.allowExternalDirectories`). +3. `external_directory` allows (opencode's tool-output and temp directories, then `loop.allowExternalDirectories`). 4. Configured `deny` rules. 5. Forge structural denies. @@ -244,15 +244,15 @@ See [Sandbox](sandbox.md) for detailed behavior and security notes. | Option | Default | Description | |---|---:|---| -| `sandbox.enabled` | `true` | Enable sandboxed execution when the `sbx` daemon is available. | -| `sandbox.mode` | `"sbx"` | Sandbox mode. `sbx` is currently the only supported mode. | -| `sandbox.image` | `"oc-forge-sandbox:latest"` | sbx template tag used for sandboxed execution. | +| `sandbox.enabled` | `true` | Enable sandboxed execution when a sandbox backend is available. | +| `sandbox.mode` | `"sbx"` | Sandbox backend: `"sbx"` (default; CLI + daemon) or `"smolvm"` (the smolvm CLI, no daemon). Unknown or legacy values fall back to `"sbx"`. | +| `sandbox.image` | `"oc-forge-sandbox:latest"` | Template tag used for sandboxed execution: loaded via `sbx template load`, or stored as `/smolvm-images/.tar` and passed to `smolvm machine create --image`. Under smolvm a registry-qualified ref containing `/` is passed through for smolvm to pull. | | `sandbox.imageFeatures.browserControl` | `false` | Include Chromium, the Browser Control CLI/MCP server, and its extension when building the bundled sandbox image. Rebuild the template after changing it. | -| `sandbox.resources.memory` | `"8g"` | Sandbox memory limit (`sbx create --memory`). | -| `sandbox.resources.cpus` | `"4"` | CPU count (`sbx create --cpus`; integer-only). | +| `sandbox.resources.memory` | `"8g"` | Sandbox memory limit (`sbx create --memory`; smolvm `--mem`, converted to integer MiB). | +| `sandbox.resources.cpus` | `"4"` | CPU count (`sbx create --cpus` / smolvm `--cpus`; integer-only). | | `sandbox.mountProjectReadonly` | `true` | Mount the source project read-only at its identical host path. | | `sandbox.mounts` | `[]` | Additional host directories to mount at their identical host path. | -| `sandbox.network.allow` | `[]` | Hosts the sandbox may reach (deny-by-default proxy). | +| `sandbox.network.allow` | `[]` | Hosts the sandbox may reach. sbx: deny-by-default proxy, applied via `sbx policy allow network` at sandbox start. smolvm: per-machine `--allow-host` flags applied at create time (changing the list requires recreating the sandbox); an empty list means unrestricted egress. | | `sandbox.network.env` | `[]` | Host environment variables to pass into each sandbox command via the env file. | ## Bundled Assets & Installer diff --git a/docs/modules.md b/docs/modules.md index 3066129d0..52648253c 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -268,36 +268,40 @@ Source: [src/services/execution.ts](../src/services/execution.ts) --- -## `sandbox/` — sbx Sandboxing +## `sandbox/` — Sandbox Runtimes -Drives the `sbx` CLI to provision isolated sandboxes for loop execution. +Drives the `sbx` and `smolvm` CLIs to provision isolated sandboxes for loop execution. ### Files | File | Purpose | |------|---------| | `sbx.ts` | `SandboxRuntime` facade over the `sbx` CLI (create/exec/remove/list, availability probe) | +| `smolvm.ts` | Pure helpers plus the `SandboxRuntime` facade over the `smolvm` CLI (argv builders, image-store paths, stopped-machine recovery) | +| `runtime-factory.ts` | `SandboxMode` resolution and the single `createSandboxRuntime` construction point | | `process.ts` | Child-process runner (`runCommand`) shared by the sandbox helpers | -| `template.ts` | Template build/save/load helper (`docker build`/`docker save`/`sbx template load`) | +| `template.ts` | Template build/save/load helper (`docker build`/`docker save`/backend `loadTemplate`) | | `config-warnings.ts` | Warnings for legacy Docker-era sandbox config keys | | `manager.ts` | `SandboxManager` lifecycle management (start/stop/getActive/isLive) | | `reconcile.ts` | Sandbox reconciliation with loop states | | `context.ts` | `SandboxContext`, `isSandboxEnabled()` | | `path.ts` | Sandbox path utilities | -| `exec-fs.ts` | Filesystem operations through `sbx exec` | +| `exec-fs.ts` | Filesystem operations executed inside the sandbox (backend-agnostic) | ### SandboxRuntime Interface ```typescript interface SandboxRuntime { checkAvailable(): Promise + describeUnavailable(result: Extract): string templateExists(ref: string): Promise - loadTemplate(tarPath: string): Promise + templateLoadHint(ref: string): string + loadTemplate(tarPath: string, ref: string): Promise createSandbox(name: string, workspaces: SandboxWorkspace[], opts?: CreateSandboxOpts): Promise removeSandbox(name: string): Promise exec(name: string, command: string, opts?: SandboxExecOpts): Promise - execPipe(name: string, command: string, stdin: string, opts?: ...): Promise - isRunning(name: string): Promise + execPipe(name: string, command: string, stdin: string, opts?: { timeout?: number; abort?: AbortSignal; envFile?: string }): Promise + getSandboxState(name: string): Promise sandboxContainerName(worktreeName: string): string listSandboxesByPrefix(prefix: string): Promise allowNetworkHost(host: string): Promise diff --git a/docs/sandbox.md b/docs/sandbox.md index d0b4add49..4087a7b53 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1,6 +1,6 @@ # Sandbox -Forge can run loop iterations or one selected host session inside an isolated `sbx` sandbox while keeping the active project directory mounted at its identical host path for fast host/sandbox file sharing. +Forge can run loop iterations or one selected host session inside an isolated sandbox — either an `sbx` sandbox (CLI + daemon) or a `smolvm` machine (see [smolvm Mode](#smolvm-mode)) — while keeping the active project directory mounted at its identical host path for fast host/sandbox file sharing. See also: [Configuration](configuration.md), [Tools](tools.md), [Loop System](loop-system.md). @@ -164,6 +164,80 @@ Security note: read-write custom mounts give the sandbox write access to host pa Each sbx sandbox has its own Docker daemon natively, so loops can build and run containers (for example end-to-end tests) without touching the host Docker daemon. Every sandbox gets isolated image and container storage. +## Keep-Alive + +`sbx` auto-stops a sandbox roughly 35 seconds after the last exec session ends. Forge holds one long-lived "sentinel" exec per active sandbox — an in-container `sleep 600` — and renews it when it returns. `sbx` keeps a sandbox running as long as an exec session is in flight, so the sentinel holds it warm with no polling. The 10-minute bound means that if the forge process dies without cleanup, the sandbox stops within that bound rather than staying up forever. On plugin cleanup the sentinel is aborted and the sandboxes are left alone, matching Forge's contract of preserving active loops across restarts. Holding a session is the same "sentinel connection" approach Docker's own `sbx cp` and `sbx kit add` use. + +Cold starts are cheap: roughly 0.9s for the first command after a stop, vs ~0.16s warm. Keep-alive is not about latency — a stop is a full VM reboot that destroys in-memory state, while on-disk state (Docker images, containers, and files) persists across it. And because `sbx exec` auto-starts a stopped sandbox, keep-alive is never required for correctness of a single command. + +## smolvm Mode + +Forge can run sandboxed loops on the `smolvm` CLI (smolmachines.com) instead of the `sbx` daemon. Enable it with: + +```jsonc +{ + "sandbox": { + "mode": "smolvm" + } +} +``` + +### Requirements + +- The `smolvm` CLI installed: `curl -sSL https://smolmachines.com/install.sh | bash`. There is no daemon — the `smolvm` binary embeds libkrun and drives the local hypervisor directly. +- A supported platform: macOS 11+ on Apple silicon, Linux with `/dev/kvm`, or Windows x86_64 with Windows Hypervisor Platform. +- Docker, used only to build the sandbox template (see below). + +### Template Flow + +The bundled template is still built with Docker; only the final step differs. Forge keeps a managed image store at `/smolvm-images/`: + +```bash +docker build -t oc-forge-sandbox:latest container/ +docker save oc-forge-sandbox:latest -o forge-sandbox.tar +# Forge stores the tar as /smolvm-images/.tar +``` + +Each sandbox create resolves `--image` from that store and passes the tar to `smolvm machine create --image `, which consumes the `docker save` archive directly — there is no template-store command to run. A registry-qualified ref containing `/` (for example `docker.io/library/oc-forge-sandbox:latest`) is passed through to `machine create` unchanged, letting smolvm pull it. The "Build sandbox template" palette command builds and stores the tar under the active mode. + +### Network Semantics + +smolvm machines are created with `--net` and have no global proxy: egress is unrestricted by default. `sandbox.network.allow` maps to per-machine `--allow-host` flags applied at create time, so changing the list after a sandbox exists requires recreating the sandbox. Under smolvm an empty (or absent) allow list means unrestricted egress — the opposite of sbx's deny-by-default proxy. + +smolvm resolves every `--allow-host` as a literal hostname when the machine starts, and an unresolvable one fails the create outright — there is no wildcard. A wildcard entry (for example sbx's allow-everything `**`) therefore drops **all** `--allow-host` flags, which is the faithful translation: no flags already means unrestricted egress. + +Inbound is closed and guest ports never collide with host ports. Forge passes no `-p`, so a guest listener publishes nothing and binds nothing on the host. The machine has its own kernel and network stack, so a guest process binds a port the host is already using, each side keeps serving its own process on that number, and inside the guest `127.0.0.1:` resolves to the guest's own listener. + +**Host loopback is reachable for ports the guest is not using.** smolvm's default `tsi` network backend impersonates guest sockets on the host, so a guest connection to `127.0.0.1:` falls through to a *host* service on that port whenever the guest has nothing bound there — guest listeners take precedence, but they are the only thing shadowing the host. sbx's proxy blocks host loopback outright, so this is a smolvm-only exposure: treat host-local dev servers, databases, and unauthenticated ports as reachable from a smolvm loop. Egress filtering (`--allow-host`/`--allow-cidr`) is the only mitigation and requires the `virtio-net` backend, which bundled libkrun builds may not expose; when they do not, any `sandbox.network.allow` entry makes the machine fail to start rather than silently run unfiltered. + +### Guest User + +smolvm bind-mounts host directories through virtiofs **without uid mapping**: the guest sees the host owner's numeric uid on the worktree, while the image user (`agent`) is a different uid, so that user cannot write a single file in the mount. `smolvm machine exec` has no `--user` flag, so Forge elevates each guest command with `sudo -nE PATH="$PATH"` — root is the only guest user that can write the mounts, and virtiofsd runs as the host user, so files the guest creates are owned by the host user on the host side. `-E` plus an explicit `PATH` is required because sudoers `secure_path` would otherwise strip the image PATH and hide the preinstalled toolchains. When an image offers no passwordless sudo the command still runs, unelevated, rather than failing. + +Because the image ships an empty `/etc/hosts`, `sudo` would print `unable to resolve host` on every command's stderr; the guest bootstrap appends the machine hostname once per machine start to silence it, guarded so it can never fail the bootstrap. + +This is a smolvm-only concern. `sbx` id-maps its bind mounts to the container user, so its commands stay unprivileged as `agent`. + +### Docker in the Machine + +Each smolvm sandbox runs the image's own Docker daemon, matching the in-sandbox Docker that `sbx` provides natively. smolvm boots an image as a bare agent and never runs its entrypoint or init scripts, so Forge starts the daemon itself after every machine start (creation and transparent restart alike). Three guest details shape the command: + +- The machine root filesystem is itself an overlay and `overlay2` cannot stack on it, so the daemon's data root is pinned to the machine's ext4 `/storage` disk (`--data-root=/storage/docker`). Unlike the bind mount in smolvm's docker-in-vm example, a data root survives stop/start. +- `smolvm machine exec` applies only the user's primary group, so the default `root:docker` socket is unreachable from a loop command. The daemon is started with `--group agent`, matching the image user's primary group. +- Startup is idempotent and non-fatal: it no-ops when the image ships no `dockerd` or a daemon already answers, and a daemon that refuses to start degrades to "no Docker in this sandbox" (logged) rather than failing sandbox creation. + +### Environment Passthrough + +`sandbox.network.env` variables are written to the same host-side env file. Because `smolvm machine exec` has no `--env-file` flag, Forge mounts the env directory read-only at its identical host path and each exec sources the file in-guest before running the command. + +### Shell Routing + +The generated shell shim routes bash-tool commands through `smolvm machine exec --name -- bash -c ` instead of `sbx exec`, applying the working directory and env file inside the guest (smolvm exec has no `-w` or `--env-file` flags). The payload rides as a positional argument of the same root-elevation wrapper the runtime exec path uses, so shim and runtime commands run as the same guest user. It fails closed exactly like the sbx shim: if the machine is expected but `smolvm machine exec` fails, the command errors rather than silently running on the host. + +### Keep-Alive and Recovery + +smolvm machines do not auto-stop the way `sbx` sandboxes do (~35s idle stop), so the sentinel exec is harmless there. If a machine is stopped out-of-band, the next exec fails with a stopped-machine error and Forge restarts it transparently before retrying the command once. + ## Large Command Output Shell output truncation is handled by opencode's native bash tool: when output exceeds the tool limit, the full output is spilled to opencode's tool-output directory on the host (readable from loop sessions, see below). The worktree `.forge/` scratch directory is added to git exclude so forge-written files are not committed. @@ -173,7 +247,9 @@ Shell output truncation is handled by opencode's native bash tool: when output e opencode spills large tool outputs to its truncation directory (`/tool-output`, e.g. `~/.local/share/opencode/tool-output`) and references the saved file by absolute host path. Forge makes those overflow files readable from loop and audit sessions in two complementary ways: - **Sandbox tools** (`bash`, `glob`, `grep`): the directory is bind-mounted **read-only at the identical sandbox path**, so the same absolute path opencode reports resolves inside the sandbox. The mount is added automatically when the directory exists; it is skipped when missing or already covered by the workspace mount. -- **Host file tools** (`read`): the directory is granted an `external_directory` allow rule in the loop/audit permission ruleset (layered after the blanket external-directory deny), so reads succeed without prompting in the unattended loop. All other external directories remain denied unless added via `loop.allowExternalDirectories`. +- **Host file tools** (`read`): the directory is granted an `external_directory` allow rule in the loop/audit permission ruleset (layered after the blanket external-directory deny), so reads succeed without prompting in the unattended loop — the ruleset's blanket allow covers the `read` permission itself, but a `loop.permissions` rule that denies or asks for `read` is layered after these grants and still applies. All other external directories remain denied unless added via `loop.allowExternalDirectories`. + +opencode's temp directory (`/opencode` — the path opencode's bash tool advertises to agents as pre-approved scratch space) is handled the same way, but for writes: it is granted an `external_directory` allow rule for host file tools **and** bind-mounted read-write at the identical sandbox path, so scratch files an agent writes at that path resolve identically on the host and inside the sandbox. It is opencode's own directory — Forge provides no separate scratch directory, and agents can use the advertised OS temp path without issue. ## Resource Defaults diff --git a/forge-config.jsonc b/forge-config.jsonc index ab466222c..89587b70a 100644 --- a/forge-config.jsonc +++ b/forge-config.jsonc @@ -99,10 +99,12 @@ }, // Sandbox configuration. Sandbox is optional: loops always run in an isolated git worktree, and - // when the sbx CLI and daemon are available a sandbox is provisioned automatically. Set - // "enabled": false to force worktree-only mode even when the sbx daemon is running. + // when a sandbox backend is available a sandbox is provisioned automatically. Set + // "enabled": false to force worktree-only mode even when a backend is available. "sandbox": { "enabled": true, + // "mode" selects the sandbox backend: "sbx" (default; CLI + daemon) or "smolvm" (the smolvm + // CLI, no daemon). Unknown or legacy values fall back to "sbx". "mode": "sbx", "image": "oc-forge-sandbox:latest", "imageFeatures": { @@ -110,10 +112,12 @@ } // Mount the source project directory read-only at its identical host path. Defaults to true. // "mountProjectReadonly": true, - // Network access configuration. The sbx proxy is deny-by-default: host loopback is - // unreachable, and outbound access is allowed only for hosts listed in "allow". + // Network access configuration. With "sbx" the proxy is deny-by-default: host loopback is + // unreachable, and outbound access is allowed only for hosts listed in "allow". With "smolvm" + // the list becomes per-machine --allow-host flags applied at create time, and an empty list + // means unrestricted egress. // "network": { - // // Hosts the sandbox may reach. Defaults to none (deny-by-default). + // // Hosts the sandbox may reach. Defaults to none (deny-by-default with sbx). // "allow": ["registry.npmjs.org"], // // Host environment variable names passed into each sandbox exec via the env file. // "env": ["MY_VAR"] diff --git a/package.json b/package.json index 2294b0b88..cd4bc8591 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-forge", - "version": "0.8.5", + "version": "0.8.6", "type": "module", "oc-plugin": [ "server", diff --git a/src/constants/loop.ts b/src/constants/loop.ts index 3c79c63aa..448990abc 100644 --- a/src/constants/loop.ts +++ b/src/constants/loop.ts @@ -1,4 +1,4 @@ -import { resolveOpencodeToolOutputDir, resolveForgeTempDir } from '../utils/opencode-paths' +import { resolveOpencodeToolOutputDir, resolveOpencodeTmpDir } from '../utils/opencode-paths' import { isRecord } from '../utils/is-record' import type { PluginConfig, LoopPermissionsConfig } from '../types' @@ -183,16 +183,13 @@ export function collectLoopPermissionConfigWarnings(config: PluginConfig | undef } /** - * Resolves the full set of external directories loop/audit sessions may access: the shared temp - * directory (always, default `/tmp/oc-forge`) plus any user-configured `loop.allowExternalDirectories`. - * Single source of truth so every permission-ruleset call site grants the same paths regardless of - * sandbox mode. (opencode's tool-output directory is added separately inside the ruleset builder.) + * Resolves the user-configured external directories loop/audit sessions may access. Single source of + * truth so every permission-ruleset call site grants the same paths regardless of sandbox mode. + * (opencode's tool-output directory and its advertised temp directory are added separately inside + * the ruleset builder.) */ export function resolveLoopAllowedDirectories(config: PluginConfig | undefined): string[] { - return [ - resolveForgeTempDir(config?.loop?.tmpDir), - ...(config?.loop?.allowExternalDirectories ?? []), - ] + return config?.loop?.allowExternalDirectories ?? [] } export interface LoopPermissionRulesetOptions { @@ -213,15 +210,16 @@ export interface LoopPermissionRulesetOptions { * Builds `external_directory` allow rules. Each directory produces two rules: an exact-path * allow and a recursive (`/**`) allow. * - * opencode's tool-output (truncation) directory is always included: opencode spills large tool - * outputs there and references the saved file by absolute host path, so loop/audit sessions must - * be able to read it without prompting in the unattended loop. User-configured directories are - * layered on top. Both are added AFTER the blanket `external_directory` deny so last-match-wins - * resolution grants access to these paths while all others stay denied. + * opencode's tool-output (truncation) directory and its advertised temp directory (`Global.Path.tmp`, + * which its shell-tool description presents as pre-approved) are always included: loop/audit sessions + * must be able to read spilled tool outputs and use the advertised scratch dir without prompting in the + * unattended loop. User-configured directories are layered on top. All are added AFTER the blanket + * `external_directory` deny so last-match-wins resolution grants access to these paths while all + * others stay denied. */ function buildExternalDirectoryAllowRules(allowDirectories: string[] = []): PermissionRule[] { const rules: PermissionRule[] = [] - const dirs = [resolveOpencodeToolOutputDir(), ...allowDirectories] + const dirs = [resolveOpencodeToolOutputDir(), resolveOpencodeTmpDir(), ...allowDirectories] for (const dir of dirs) { if (typeof dir !== 'string') continue const trimmed = dir.trim().replace(/\/+$/, '') diff --git a/src/index.ts b/src/index.ts index 7ab539483..ceea4dc35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,17 +10,16 @@ import type { LoopChangeNotifier } from './loop' import { loadPluginConfig, resolveBundledContainerDir, resolvePromptsDir } from './setup' import { resolveLogPath } from './storage' import { createLogger, slugify } from './utils/logger' -import { createSbxRuntime, describeSbxUnavailable } from './sandbox/sbx' +import { createSandboxRuntime, resolveSandboxMode } from './sandbox/runtime-factory' import { collectLegacySandboxConfigWarnings } from './sandbox/config-warnings' import { defaultGitService } from './utils/git-service' import { resolveSandboxContextForLoop, isSandboxConfigEnabled } from './sandbox/context' -import { resolveForgeTempDir } from './utils/opencode-paths' +import { resolveOpencodeTmpDir } from './utils/opencode-paths' import { isForgeWorktreeDir } from './workspace/forge-naming' import { MAX_TOTAL_SECTIONS } from './constants/loop' import { resolveLoopPermissionOptionsForWorkspace } from './utils/loop-permission-options' import { emitLoopPermissionConfigWarnings } from './utils/loop-permission-warnings' import { publishToast } from './utils/toast' -import { mkdirSync } from 'fs' import { createSandboxManager } from './sandbox/manager' import { DEFAULT_SANDBOX_IMAGE, formatTemplateBuildCommands } from './sandbox/template' import { createSessionSandboxController, createUnavailableSandboxLifecycleManager, type ResolveActiveLoopForSession, type SessionSandboxController } from './sandbox/session-controller' @@ -315,6 +314,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { const forgeClient = createForgeClientFromPluginInput(input) const dataDir = config.dataDir || resolveDataDir() + const sandboxMode = resolveSandboxMode(config) emitLoopPermissionConfigWarnings(config, dataDir, directory, { logger, @@ -331,17 +331,8 @@ export function createForgePlugin(config: PluginConfig): Plugin { }, }) - // Shared loop scratch directory, allowed in both worktree-only and sandbox modes. Created here - // so it exists for host tools (worktree-only) and as a valid bind-mount source (sandbox). - const forgeTempDir = resolveForgeTempDir(config.loop?.tmpDir) - try { - mkdirSync(forgeTempDir, { recursive: true }) - } catch (err) { - logger.error(`Failed to create loop temp directory ${forgeTempDir}`, err) - } - let sandboxManager: ReturnType | null = null - const runtime = createSbxRuntime(logger) + const runtime = createSandboxRuntime(sandboxMode, logger, { dataDir }) if (!isSandboxConfigEnabled(config)) { logger.log('Sandbox disabled via config (sandbox.enabled=false); running in worktree-only mode') } else { @@ -350,7 +341,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { image: config.sandbox?.image ?? DEFAULT_SANDBOX_IMAGE, dataDir, toolOutputDir: resolveOpencodeToolOutputDir(), - tmpDir: forgeTempDir, + tmpDir: resolveOpencodeTmpDir(), sourceProjectDir: projectRoot, mountProjectReadonly: config.sandbox?.mountProjectReadonly, ...(config.sandbox?.mounts ? { customMounts: config.sandbox.mounts } : {}), @@ -373,7 +364,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { // would be the upgrade path. let shellShimPath: string | null = null if (sandboxManager) { - shellShimPath = process.platform === 'win32' ? null : ensureShellShim(dataDir, logger) + shellShimPath = process.platform === 'win32' ? null : ensureShellShim(dataDir, logger, sandboxMode) if (!shellShimPath) { logger.error('Sandbox shell shim unavailable; falling back to worktree-only mode') sandboxManager = null @@ -396,7 +387,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { directory, logger, title: 'Sandbox unavailable', - message: describeSbxUnavailable(available), + message: runtime.describeUnavailable(available), variant: 'warning', duration: 10_000, }) @@ -410,7 +401,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { directory, logger, title: 'Sandbox template not found', - message: `Sandbox template "${sandboxImage}" is missing. Build it from the command palette: "Build sandbox template", or run: ${formatTemplateBuildCommands(buildContextDir, sandboxImage, { browserControl })}`, + message: `Sandbox template "${sandboxImage}" is missing. Build it from the command palette: "Build sandbox template", or run: ${formatTemplateBuildCommands(buildContextDir, sandboxImage, runtime.templateLoadHint(sandboxImage), { browserControl })}`, variant: 'warning', duration: 10_000, }) @@ -537,6 +528,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { } catch (err) { logger.error('Error during session sandbox controller disposal', err) } finally { + sandboxManager?.dispose() closeDatabase(db) logger.log('Plugin cleanup complete') } diff --git a/src/sandbox/config-warnings.ts b/src/sandbox/config-warnings.ts index 768e3a8af..fe334c997 100644 --- a/src/sandbox/config-warnings.ts +++ b/src/sandbox/config-warnings.ts @@ -12,7 +12,7 @@ export function collectLegacySandboxConfigWarnings(rawSandbox: unknown): string[ const warnings: string[] = [] if (rawSandbox.mode === 'docker') { - warnings.push("sandbox.mode 'docker' is ignored: the sbx migration replaces the Docker driver; use mode 'sbx'") + warnings.push("sandbox.mode 'docker' is ignored: the sbx migration replaces the Docker driver; use mode 'sbx' (default) or 'smolvm'") } if ('projectMountPath' in rawSandbox) { warnings.push('sandbox.projectMountPath is ignored: sbx mounts the source project read-only at its own host path') diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts index 113dd9fca..4496bdc73 100644 --- a/src/sandbox/context.ts +++ b/src/sandbox/context.ts @@ -20,6 +20,7 @@ export interface SandboxContext { export const SANDBOX_CONTEXT_NOTE = [ '[Sandbox] This session runs inside a container: bash tool commands execute in that container, not on the host. OS-specific commands or tools may differ from the host system.', 'Focus on what the code does, not whether local tooling matches — this saves time and avoids false positives.', + 'Run long commands in the foreground with a raised bash timeout: if the sandbox stops while idle it reboots the VM, so backgrounded work (&, nohup, setsid) and in-memory state are not guaranteed to survive, though files on disk do.', ].join('\n') export interface SandboxLoopContextState { diff --git a/src/sandbox/exec-fs.ts b/src/sandbox/exec-fs.ts index c5e2a72fa..5cac70ed3 100644 --- a/src/sandbox/exec-fs.ts +++ b/src/sandbox/exec-fs.ts @@ -7,10 +7,21 @@ interface SandboxExecutionDeps { envFile?: string } -function quoteShellArg(value: string): string { +/** Single-quote-escapes `value` (including the wrapping quotes) so it survives a POSIX-sh round-trip. */ +export function quoteShellArg(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } +/** + * POSIX-sh loop that exports each non-empty `KEY=value` line of the env file without + * shell-interpreting values. `redirectToken` must already be shell-safe (single-quoted, or a + * quoted positional such as `"$0"`). Shared by the smolvm runtime preamble and the shell shim + * so the export semantics exist once. + */ +export function buildEnvFileExportLoop(redirectToken: string): string { + return `while IFS= read -r __fe || [ -n "$__fe" ]; do [ -n "$__fe" ] && export "$__fe"; done < ${redirectToken}; ` +} + /** * Execute a glob pattern search inside a sandbox container. * Mounts are at identical host paths, so returned paths are emitted verbatim. diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index b678d2c09..c777bf4a6 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -1,10 +1,10 @@ import type { SandboxRuntime, SandboxWorkspace } from './sbx' -import { describeSbxUnavailable, type SbxAvailability } from './sbx' +import type { SbxAvailability } from './sbx' import type { Logger, SandboxResources, SandboxMountConfig } from '../types' import { resolve, join, isAbsolute, posix as posixPath } from 'path' import { mkdirSync, existsSync, writeFileSync, chmodSync, rmSync } from 'fs' import { defaultGitService, type GitService } from '../utils/git-service' -import { canonicalizePath, isSameOrDescendantPath, type SandboxMount } from './path' +import { canonicalizePath, isSameOrDescendantPath, resolveSandboxEnvDir, type SandboxMount } from './path' import { formatTemplateBuildCommands } from './template' export interface SandboxManagerConfig { @@ -96,6 +96,16 @@ export function resolveCustomMounts( const DOCKER_AVAILABLE_TTL = 30_000 const LIVENESS_CHECK_TTL = 2_000 +/** + * A sandbox stays up while an exec session is in flight, so a bounded `sleep` holds it warm + * without polling, and the bound means a crashed plugin leaks at most this long instead of forever. + * Renewal requires the `sleep` to have actually elapsed — a fast-returning exec never renews, so + * it can never spin. + */ +export const SANDBOX_SENTINEL_SECONDS = 600 +export const SANDBOX_SENTINEL_TIMEOUT_MS = (SANDBOX_SENTINEL_SECONDS + 60) * 1000 +export const SANDBOX_SENTINEL_MIN_RENEW_MS = (SANDBOX_SENTINEL_SECONDS * 1000) / 2 + export interface ActiveSandbox { containerName: string projectDir: string @@ -114,6 +124,8 @@ export interface SandboxManager { cleanupOrphans(preserveWorktrees?: string[]): Promise restore(worktreeName: string, projectDir: string, startedAt: string): Promise ensureRunning(worktreeName: string, projectDir: string, startedAt?: string): Promise + /** Stops the keep-alive sentinels and their bookkeeping without touching sandboxes. */ + dispose(): void } /** @@ -147,6 +159,7 @@ export function createSandboxManager( const lastLivenessCheck = new Map() const ensureRunningInFlight = new Map>() const gitMountCache = new Map() + const sentinels = new Map() let runtimeAvailableCache: { value: SbxAvailability; at: number } | null = null let imageReady = false let allowListApplied = false @@ -155,14 +168,14 @@ export function createSandboxManager( const now = Date.now() if (runtimeAvailableCache && (now - runtimeAvailableCache.at) < DOCKER_AVAILABLE_TTL) { if (!runtimeAvailableCache.value.available) { - throw new Error(describeSbxUnavailable(runtimeAvailableCache.value)) + throw new Error(runtime.describeUnavailable(runtimeAvailableCache.value)) } return } const result = await runtime.checkAvailable() runtimeAvailableCache = { value: result, at: now } if (!result.available) { - throw new Error(describeSbxUnavailable(result)) + throw new Error(runtime.describeUnavailable(result)) } } @@ -173,6 +186,7 @@ export function createSandboxManager( const buildHint = ` ${formatTemplateBuildCommands( config.buildContextDir ?? '', config.image, + runtime.templateLoadHint(config.image), { browserControl: config.browserControl }, )}` throw new Error( @@ -313,7 +327,7 @@ export function createSandboxManager( } if (lines.length === 0) return undefined - const dir = join(dataDir, 'sandbox-env') + const dir = resolveSandboxEnvDir(dataDir) mkdirSync(dir, { recursive: true }) const filePath = join(dir, `${containerName}.env`) writeFileSync(filePath, lines.join('\n') + '\n', { encoding: 'utf-8' }) @@ -340,6 +354,68 @@ export function createSandboxManager( } } + /** + * Syncs the keep-alive sentinels with the active-sandbox map: one sentinel exec per active + * sandbox, dropped as soon as the map empties. + */ + function syncKeepAlive(): void { + for (const [worktreeName, active] of activeSandboxes) { + if (!sentinels.has(worktreeName)) { + startSentinel(worktreeName, active.containerName) + } + } + for (const worktreeName of sentinels.keys()) { + if (!activeSandboxes.has(worktreeName)) { + sentinels.get(worktreeName)?.abort() + sentinels.delete(worktreeName) + } + } + } + + /** + * Starts one long-lived exec that holds the sandbox warm while it is in flight, and renews it + * when it returns. Never rejects — a failed keep-alive must never fail a loop. + */ + function startSentinel(worktreeName: string, containerName: string): void { + const controller = new AbortController() + sentinels.set(worktreeName, controller) + void (async () => { + while (!controller.signal.aborted) { + if (sentinels.get(worktreeName) !== controller) return + if (!activeSandboxes.has(worktreeName)) return + try { + const startedAt = Date.now() + const result = await runtime.exec(containerName, `sleep ${SANDBOX_SENTINEL_SECONDS}`, { + timeout: SANDBOX_SENTINEL_TIMEOUT_MS, + abort: controller.signal, + }) + if (result.exitCode !== 0) { + logger.log(`Sandbox: keep-alive sentinel for ${containerName} exited ${result.exitCode}`) + if (sentinels.get(worktreeName) === controller) { + sentinels.delete(worktreeName) + } + return + } + const elapsedMs = Date.now() - startedAt + if (elapsedMs < SANDBOX_SENTINEL_MIN_RENEW_MS) { + logger.log(`Sandbox: keep-alive sentinel for ${containerName} returned after ${elapsedMs}ms; not renewing`) + if (sentinels.get(worktreeName) === controller) { + sentinels.delete(worktreeName) + } + return + } + } catch (err) { + if (controller.signal.aborted) return + logger.log(`Sandbox: keep-alive sentinel for ${containerName} failed: ${err instanceof Error ? err.message : String(err)}`) + if (sentinels.get(worktreeName) === controller) { + sentinels.delete(worktreeName) + } + return + } + } + })() + } + /** * Single point that records a usable sandbox in the active map, shared by the adopt path in * `start` and by `resolveUsableSandbox`. An existing entry's `startedAt` wins so adopting a @@ -354,6 +430,7 @@ export function createSandboxManager( mounts: buildMountPlan(projectDir).mounts, envFile: writeEnvPassthroughFile(containerName), }) + syncKeepAlive() } async function start(worktreeName: string, projectDir: string, startedAt?: string): Promise<{ containerName: string }> { @@ -378,7 +455,11 @@ export function createSandboxManager( cpus: config.resources?.cpus ?? DEFAULT_RESOURCES.cpus, } logger.log(`Creating sandbox ${containerName} for ${absoluteProjectDir} (memory=${resources.memory} cpus=${resources.cpus})`) - await runtime.createSandbox(containerName, workspaces, { template: config.image, resources }) + await runtime.createSandbox(containerName, workspaces, { + template: config.image, + resources, + networkAllowHosts: config.network?.allow, + }) const active: ActiveSandbox = { containerName, @@ -389,6 +470,7 @@ export function createSandboxManager( } activeSandboxes.set(worktreeName, active) + syncKeepAlive() logger.log(`Sandbox ${containerName} started`) return { containerName } @@ -398,6 +480,9 @@ export function createSandboxManager( const active = activeSandboxes.get(worktreeName) const containerName = active?.containerName || runtime.sandboxContainerName(worktreeName) + sentinels.get(worktreeName)?.abort() + sentinels.delete(worktreeName) + // Cleanup (env file, in-memory map entry) always runs; the removal failure is rethrown so // callers that own the container lifecycle (e.g. the session-sandbox controller) can observe // that the container may still be live instead of recording a successful stop. @@ -421,6 +506,7 @@ export function createSandboxManager( } } activeSandboxes.delete(worktreeName) + syncKeepAlive() } if (removalError) throw removalError } @@ -450,6 +536,7 @@ export function createSandboxManager( if (state === 'missing') { logger.log(`Sandbox: sandbox ${containerName} no longer exists, removing stale map entry for ${worktreeName}`) activeSandboxes.delete(worktreeName) + syncKeepAlive() return false } @@ -487,6 +574,7 @@ export function createSandboxManager( } } } + syncKeepAlive() return removed } @@ -544,6 +632,18 @@ export function createSandboxManager( return pending } + /** + * Stops the keep-alive sentinels and their bookkeeping. Sandboxes stay alive — this only stops + * forge-side bookkeeping, matching forge's contract of preserving active loops across plugin + * cleanup. + */ + function dispose(): void { + for (const controller of sentinels.values()) { + controller.abort() + } + sentinels.clear() + } + return { runtime, start, @@ -554,5 +654,6 @@ export function createSandboxManager( cleanupOrphans, restore, ensureRunning, + dispose, } } diff --git a/src/sandbox/path.ts b/src/sandbox/path.ts index c02e61cab..c8fcd8725 100644 --- a/src/sandbox/path.ts +++ b/src/sandbox/path.ts @@ -1,4 +1,5 @@ import { realpathSync } from 'fs' +import { join } from 'path' export interface SandboxMount { hostDir: string @@ -27,3 +28,12 @@ export function isInsideAnyMount(p: string, mounts: SandboxMount[]): boolean { } return false } + +/** + * Directory for per-sandbox env passthrough files. The smolvm backend mounts it read-only at its + * identical host path so in-guest execs source the same `KEY=value` file the host wrote; the sbx + * backend consumes the file host-side via `--env-file` and never mounts it. + */ +export function resolveSandboxEnvDir(dataDir: string): string { + return join(dataDir, 'sandbox-env') +} diff --git a/src/sandbox/runtime-factory.ts b/src/sandbox/runtime-factory.ts new file mode 100644 index 000000000..bdba98ca6 --- /dev/null +++ b/src/sandbox/runtime-factory.ts @@ -0,0 +1,34 @@ +/** + * Mode resolution and the single construction point for sandbox runtimes. All runtime + * construction in the plugin routes through `createSandboxRuntime`; backend-specific + * facades (`createSbxRuntime`, `createSmolvmRuntime`) are referenced only here and in tests. + */ +import type { Logger, PluginConfig, SandboxMode } from '../types' +import { createSbxRuntime, type CommandRunner, type SandboxRuntime } from './sbx' +import { createSmolvmRuntime } from './smolvm' + +export type { SandboxMode } + +/** + * Resolves the configured sandbox backend. `'smolvm'` is explicit opt-in; anything else + * (omitted, `'sbx'`, or an unknown/legacy value such as the pre-migration `'docker'`) + * falls back to `'sbx'` so existing installs are untouched. Legacy values are reported + * separately by `collectLegacySandboxConfigWarnings`. + */ +export function resolveSandboxMode(config: PluginConfig | undefined): SandboxMode { + return config?.sandbox?.mode === 'smolvm' ? 'smolvm' : 'sbx' +} + +/** + * Constructs the runtime for `mode`. The single construction point for `SandboxRuntime` + * instances; `dataDir` is forwarded so the smolvm runtime can manage its image store and + * env-passthrough directory (the sbx runtime ignores it). + */ +export function createSandboxRuntime( + mode: SandboxMode, + logger: Logger, + opts?: { dataDir?: string; run?: CommandRunner }, +): SandboxRuntime { + if (mode === 'smolvm') return createSmolvmRuntime(logger, opts) + return createSbxRuntime(logger, { run: opts?.run }) +} diff --git a/src/sandbox/sbx.ts b/src/sandbox/sbx.ts index f49f2e0d7..906b85c22 100644 --- a/src/sandbox/sbx.ts +++ b/src/sandbox/sbx.ts @@ -4,6 +4,7 @@ */ import type { Logger, SandboxResources } from '../types' import { runCommand, type CommandResult } from './process' +import { quoteShellArg } from './exec-fs' /** * Sanitizes a raw string into a name `sbx create --name` accepts. `sbx` allows only @@ -53,13 +54,43 @@ export function buildSbxExecArgs(name: string, command: string, opts?: BuildSbxE return args } +/** + * Prefixes a command with a `cd` into `cwd`, single-quote-escaping the path so arbitrary + * working directories (including ones containing single quotes) survive the shell round-trip. + * A missing `cwd` returns the command unchanged. Shared with the smolvm runtime so both + * backends apply the identical cwd semantics. + */ +export function prefixCommandWithCwd(command: string, cwd?: string): string { + if (!cwd) return command + return `cd ${quoteShellArg(cwd)} && ${command}` +} + /** A host directory to bind into the sandbox; `readOnly` maps to the `:ro` suffix. */ export interface SandboxWorkspace { hostDir: string readOnly?: boolean } -const SBX_MEMORY_RE = /^\d+(\.\d+)?[kmg]b?$/i +const MEMORY_TOKEN_RE = /^\d+(\.\d+)?[kmg]b?$/i + +/** + * Shared memory-token normalization: accepts binary units such as `1024m` and `8g` + * (optionally with a trailing `b`), lowercases and strips the trailing `b`. Logs and + * returns `undefined` for anything else. `flag` names the backend flag in the log so + * each caller's message stays accurate. + */ +export function normalizeMemoryToken( + raw: string | undefined, + logger: Logger, + flag: string, +): string | undefined { + if (raw === undefined || raw.trim() === '') return undefined + if (!MEMORY_TOKEN_RE.test(raw)) { + logger.log(`Sandbox: unrecognized ${flag} value ${JSON.stringify(raw)} ignored`) + return undefined + } + return raw.toLowerCase().replace(/b$/, '') +} /** * Coerces a raw `sbx create --cpus` value. `sbx`'s `--cpus` flag is integer-only while @@ -86,12 +117,7 @@ export function parseSbxCpus(raw: string | undefined, logger: Logger): number | * `undefined` for anything else. */ export function normalizeSbxMemory(raw: string | undefined, logger: Logger): string | undefined { - if (raw === undefined || raw.trim() === '') return undefined - if (!SBX_MEMORY_RE.test(raw)) { - logger.log(`Sandbox: unrecognized --memory value ${JSON.stringify(raw)} ignored`) - return undefined - } - return raw.toLowerCase().replace(/b$/, '') + return normalizeMemoryToken(raw, logger, '--memory') } /** @@ -242,28 +268,48 @@ export type CommandRunner = ( ) => Promise const SBX_RUNNING_RE = /^\s*status:\s*running/im -const SBX_NOT_INSTALLED_RE = /ENOENT|not found|command not found/i +/** Matches the combined output of a CLI that could not be spawned at all. Backend-neutral: shared with the smolvm runtime. */ +const NOT_INSTALLED_RE = /ENOENT|not found|command not found/i /** - * Probes sandbox availability by running `sbx daemon status`. A zero exit with a - * `Status: running` line yields `{ available: true }`; a missing CLI is distinguished from a - * stopped daemon because they need different remediation. A rejected run yields `'unknown'`. + * Shared availability probe used by both backends: runs `args` with a short timeout, treats + * `isAvailable` as the health predicate, distinguishes a missing CLI from a failing one via + * `NOT_INSTALLED_RE`, and maps everything else to `fallbackReason` (`daemon-down` for sbx's + * stopped daemon, `unknown` for smolvm which has no daemon). */ -export async function checkSbxAvailability(run: CommandRunner): Promise { +export async function probeCliAvailability( + run: CommandRunner, + opts: { + args: string[] + isAvailable: (result: CommandResult) => boolean + fallbackReason: 'daemon-down' | 'unknown' + }, +): Promise { let result: CommandResult try { - result = await run(['daemon', 'status'], { timeout: 5000 }) + result = await run(opts.args, { timeout: SBX_PROBE_TIMEOUT }) } catch { return { available: false, reason: 'unknown' } } - if (result.exitCode === 0 && SBX_RUNNING_RE.test(result.stdout)) { - return { available: true } - } + if (opts.isAvailable(result)) return { available: true } const combined = `${result.stdout}\n${result.stderr}` - if (SBX_NOT_INSTALLED_RE.test(combined)) { + if (NOT_INSTALLED_RE.test(combined)) { return { available: false, reason: 'not-installed' } } - return { available: false, reason: 'daemon-down', detail: combined.trim() } + return { available: false, reason: opts.fallbackReason, detail: combined.trim() } +} + +/** + * Probes sandbox availability by running `sbx daemon status`. A zero exit with a + * `Status: running` line yields `{ available: true }`; a missing CLI is distinguished from a + * stopped daemon because they need different remediation. + */ +export function checkSbxAvailability(run: CommandRunner): Promise { + return probeCliAvailability(run, { + args: ['daemon', 'status'], + isAvailable: (result) => result.exitCode === 0 && SBX_RUNNING_RE.test(result.stdout), + fallbackReason: 'daemon-down', + }) } /** The single source of the user-facing remediation message for an unavailable sandbox. */ @@ -284,6 +330,8 @@ export function describeSbxUnavailable( export interface CreateSandboxOpts { template?: string resources?: SandboxResources + /** Per-create egress allowlist; the sbx runtime ignores it (egress policy is global via `allowNetworkHost`). */ + networkAllowHosts?: string[] } /** Options for a non-piped sandbox exec. */ @@ -304,8 +352,10 @@ export type SandboxState = 'running' | 'stopped' | 'missing' | 'unknown' /** Runtime facade over the `sbx` CLI — the sandbox analog of the old Docker driver. */ export interface SandboxRuntime { checkAvailable(): Promise + describeUnavailable(result: Extract): string templateExists(ref: string): Promise - loadTemplate(tarPath: string): Promise + templateLoadHint(ref: string): string + loadTemplate(tarPath: string, ref: string): Promise createSandbox(name: string, workspaces: SandboxWorkspace[], opts?: CreateSandboxOpts): Promise removeSandbox(name: string): Promise exec(name: string, command: string, opts?: SandboxExecOpts): Promise @@ -323,9 +373,68 @@ export interface SandboxRuntime { */ export const SBX_DEFAULT_TIMEOUT = 120000 const SBX_TEMPLATE_LOAD_TIMEOUT = 600000 +/** Quick CLI round-trip bound for the availability probe; shared by both backends. */ +const SBX_PROBE_TIMEOUT = 5000 +/** Quick CLI round-trip bound for the inventory listing; shared by both backends. */ const SBX_LIST_TIMEOUT = 5000 const SBX_REMOVE_MISSING_RE = /not found|no such sandbox|unknown sandbox/i +/** + * Runs a backend `remove` command, tolerating non-zero exits that mean the sandbox is already + * gone. The missing-regex is backend-specific (`sbx` says `no such sandbox`, smolvm `unknown + * machine`); any other failure throws with the CLI stderr. + */ +export async function removeSandboxWith(run: CommandRunner, argv: string[], missingRe: RegExp): Promise { + const result = await run(argv) + if (result.exitCode !== 0 && !missingRe.test(`${result.stdout}\n${result.stderr}`)) { + throw new Error(`Failed to remove sandbox: ${result.stderr}`) + } +} + +/** + * Shared liveness inventory backed by a backend `ls --json` invocation. `getSandboxState` is the + * only liveness primitive: a failed or unparseable listing yields `'unknown'` (never `'missing'`, + * which would let callers destroy or duplicate a live sandbox), a parsed-but-absent entry + * `'missing'`, and `'running'`/`'stopped'` from the raw status. `listSandboxesByPrefix` degrades + * to `[]` on any failure. + */ +export function createSandboxInventory( + run: CommandRunner, + listArgs: string[], +): { + getSandboxState(name: string): Promise + listSandboxesByPrefix(prefix: string): Promise +} { + async function getSandboxState(name: string): Promise { + let result: CommandResult + try { + result = await run(listArgs, { timeout: SBX_LIST_TIMEOUT }) + } catch { + return 'unknown' + } + if (result.exitCode !== 0) return 'unknown' + const entries = parseSbxSandboxListOrNull(result.stdout) + if (!entries) return 'unknown' + const entry = entries.find((e) => e.name === name) + if (!entry) return 'missing' + return entry.running ? 'running' : 'stopped' + } + + async function listSandboxesByPrefix(prefix: string): Promise { + try { + const result = await run(listArgs, { timeout: SBX_LIST_TIMEOUT }) + if (result.exitCode !== 0) return [] + return parseSbxSandboxList(result.stdout) + .map((e) => e.name) + .filter((n) => n.startsWith(prefix)) + } catch { + return [] + } + } + + return { getSandboxState, listSandboxesByPrefix } +} + /** * Assembles a `SandboxRuntime` from the pure `sbx` helpers, routing every method through an * injectable `CommandRunner`. The default runner spawns the `sbx` binary; tests inject a fake. @@ -333,11 +442,16 @@ const SBX_REMOVE_MISSING_RE = /not found|no such sandbox|unknown sandbox/i export function createSbxRuntime(logger: Logger, opts?: { run?: CommandRunner }): SandboxRuntime { const run: CommandRunner = opts?.run ?? ((args, o) => runCommand('sbx', args, { ...o, logger, logLabel: 'sbx' })) + const inventory = createSandboxInventory(run, ['ls', '--json']) async function checkAvailable(): Promise { return checkSbxAvailability(run) } + function describeUnavailable(result: Extract): string { + return describeSbxUnavailable(result) + } + async function templateExists(ref: string): Promise { try { const result = await run(['template', 'ls']) @@ -348,7 +462,11 @@ export function createSbxRuntime(logger: Logger, opts?: { run?: CommandRunner }) } } - async function loadTemplate(tarPath: string): Promise { + function templateLoadHint(_ref: string): string { + return 'sbx template load ' + } + + async function loadTemplate(tarPath: string, _ref: string): Promise { const result = await run(['template', 'load', tarPath], { timeout: SBX_TEMPLATE_LOAD_TIMEOUT }) if (result.exitCode !== 0) { throw new Error(`Failed to load sandbox template: ${result.stderr || result.stdout}`) @@ -372,19 +490,11 @@ export function createSbxRuntime(logger: Logger, opts?: { run?: CommandRunner }) } async function removeSandbox(name: string): Promise { - const result = await run(['rm', '--force', name]) - if (result.exitCode !== 0 && !SBX_REMOVE_MISSING_RE.test(`${result.stdout}\n${result.stderr}`)) { - throw new Error(`Failed to remove sandbox: ${result.stderr}`) - } + return removeSandboxWith(run, ['rm', '--force', name], SBX_REMOVE_MISSING_RE) } async function exec(name: string, command: string, opts?: SandboxExecOpts): Promise { - let fullCommand = command - if (opts?.cwd) { - const safeCwd = opts.cwd.replace(/'/g, "'\\''") - fullCommand = `cd '${safeCwd}' && ${command}` - } - const args = buildSbxExecArgs(name, fullCommand, { envFile: opts?.envFile }) + const args = buildSbxExecArgs(name, prefixCommandWithCwd(command, opts?.cwd), { envFile: opts?.envFile }) return run(args, { timeout: opts?.timeout ?? SBX_DEFAULT_TIMEOUT, abort: opts?.abort }) } @@ -398,33 +508,6 @@ export function createSbxRuntime(logger: Logger, opts?: { run?: CommandRunner }) return run(args, { timeout: opts?.timeout ?? SBX_DEFAULT_TIMEOUT, stdin, abort: opts?.abort }) } - async function getSandboxState(name: string): Promise { - let result: CommandResult - try { - result = await run(['ls', '--json'], { timeout: SBX_LIST_TIMEOUT }) - } catch { - return 'unknown' - } - if (result.exitCode !== 0) return 'unknown' - const entries = parseSbxSandboxListOrNull(result.stdout) - if (!entries) return 'unknown' - const entry = entries.find((e) => e.name === name) - if (!entry) return 'missing' - return entry.running ? 'running' : 'stopped' - } - - async function listSandboxesByPrefix(prefix: string): Promise { - try { - const result = await run(['ls', '--json'], { timeout: SBX_LIST_TIMEOUT }) - if (result.exitCode !== 0) return [] - return parseSbxSandboxList(result.stdout) - .map((e) => e.name) - .filter((n) => n.startsWith(prefix)) - } catch { - return [] - } - } - async function allowNetworkHost(host: string): Promise { try { const result = await run(['policy', 'allow', 'network', host]) @@ -436,15 +519,17 @@ export function createSbxRuntime(logger: Logger, opts?: { run?: CommandRunner }) return { checkAvailable, + describeUnavailable, templateExists, + templateLoadHint, loadTemplate, createSandbox, removeSandbox, exec, execPipe, - getSandboxState, + getSandboxState: inventory.getSandboxState, sandboxContainerName, - listSandboxesByPrefix, + listSandboxesByPrefix: inventory.listSandboxesByPrefix, allowNetworkHost, } } diff --git a/src/sandbox/shell-shim.ts b/src/sandbox/shell-shim.ts index 26a1756bd..5f99eecaf 100644 --- a/src/sandbox/shell-shim.ts +++ b/src/sandbox/shell-shim.ts @@ -1,6 +1,9 @@ import { join, basename, isAbsolute } from 'path' import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs' import type { Logger } from '../types' +import type { SandboxMode } from './runtime-factory' +import { buildEnvFileExportLoop } from './exec-fs' +import { buildSmolvmRootWrapper } from './smolvm' export const SHELL_SHIM_FILENAME = 'forge-shell' @@ -31,23 +34,40 @@ export function resolveHostShell(env: NodeJS.ProcessEnv = process.env): string { return '/bin/sh' } -export function buildShimScript(hostShell: string): string { +export function buildShimScript(hostShell: string, mode: SandboxMode = 'sbx'): string { + const smolvm = mode === 'smolvm' + const routingVia = smolvm + ? '`smolvm machine exec`. smolvm exec has no `-w` or `--env-file`, so the cwd and env are applied inside the guest: `$PWD` and the env file resolve to the same paths in the machine because all mounts (including the env dir) are identical-path. Guest commands are elevated to root when passwordless sudo is available, because smolvm virtiofs mounts carry host uids that the image user cannot write.' + : '`sbx exec`.' + // Guest payload for the env-file branch: exports the file (which rides as bash + // positional `$0`, so it needs no shell quoting), then applies the shim cwd as + // `$1` and runs the command. + const smolvmEnvPayload = `${buildEnvFileExportLoop('"$0"')}cd "$1" && shift 1 && exec bash "$@"` + const routing = + mode === 'smolvm' + ? `if [ -n "\${${SHIM_ENV_CONTAINER}:-}" ]; then + if [ -n "\${${SHIM_ENV_ENV_FILE}:-}" ]; then + exec smolvm machine exec --name "$${SHIM_ENV_CONTAINER}" -- bash -c '${buildSmolvmRootWrapper('bash')}' '${smolvmEnvPayload}' "$${SHIM_ENV_ENV_FILE}" "$PWD" "$@" + fi + exec smolvm machine exec --name "$${SHIM_ENV_CONTAINER}" -- bash -c '${buildSmolvmRootWrapper('bash')}' 'cd "$0" && exec bash "$@"' "$PWD" "$@" +fi` + : `if [ -n "\${${SHIM_ENV_CONTAINER}:-}" ]; then + if [ -n "\${${SHIM_ENV_ENV_FILE}:-}" ]; then + exec sbx exec --env-file "$${SHIM_ENV_ENV_FILE}" -w "$PWD" "$${SHIM_ENV_CONTAINER}" bash "$@" + fi + exec sbx exec -w "$PWD" "$${SHIM_ENV_CONTAINER}" bash "$@" +fi` return `#!/bin/sh # Generated by opencode-forge; regenerated on plugin start. Do not edit. # # opencode's native bash tool is pointed at this script via the \`shell\` config # key. Forge's shell.env hook sets ${SHIM_ENV_CONTAINER} for sessions that belong # to an active sandbox loop, routing the command into the loop microVM via -# \`sbx exec\`. All other sessions fall through to the host shell unchanged. +# ${routingVia} All other sessions fall through to the host shell unchanged. # -# Fail-closed: when a container is expected, any sbx failure surfaces as a +# Fail-closed: when a container is expected, any ${smolvm ? 'smolvm' : 'sbx'} failure surfaces as a # non-zero exit — the command must never silently run on the host instead. -if [ -n "\${${SHIM_ENV_CONTAINER}:-}" ]; then - if [ -n "\${${SHIM_ENV_ENV_FILE}:-}" ]; then - exec sbx exec --env-file "$${SHIM_ENV_ENV_FILE}" -w "$PWD" "$${SHIM_ENV_CONTAINER}" bash "$@" - fi - exec sbx exec -w "$PWD" "$${SHIM_ENV_CONTAINER}" bash "$@" -fi +${routing} exec "\${${SHIM_ENV_HOST_SHELL}:-${hostShell}}" "$@" ` } @@ -57,9 +77,9 @@ exec "\${${SHIM_ENV_HOST_SHELL}:-${hostShell}}" "$@" * content drifts) and returns its absolute path. Returns null on failure so the caller can * degrade to worktree-only mode instead of leaving sandbox loops routed at the host shell. */ -export function ensureShellShim(dataDir: string, logger: Logger): string | null { +export function ensureShellShim(dataDir: string, logger: Logger, mode: SandboxMode = 'sbx'): string | null { const path = join(dataDir, SHELL_SHIM_FILENAME) - const content = buildShimScript(resolveHostShell()) + const content = buildShimScript(resolveHostShell(), mode) try { mkdirSync(dataDir, { recursive: true }) let existing: string | undefined diff --git a/src/sandbox/smolvm.ts b/src/sandbox/smolvm.ts new file mode 100644 index 000000000..528e62424 --- /dev/null +++ b/src/sandbox/smolvm.ts @@ -0,0 +1,415 @@ +/** + * Pure helpers plus the runtime facade for driving the `smolvm` sandbox CLI + * (smolmachines.com). The helper functions shape argument vectors and map values so they + * are trivially testable; `createSmolvmRuntime` assembles them into a `SandboxRuntime` + * over an injectable `CommandRunner`. Shared pieces come from `./sbx` — never duplicated: + * the availability probe, the liveness inventory, remove-tolerance, name sanitizing and + * the workspace/env-passthrough conventions. + */ +import { copyFileSync, existsSync, mkdirSync } from 'fs' +import { join } from 'path' +import type { Logger } from '../types' +import { runCommand, type CommandResult } from './process' +import { isSameOrDescendantPath, resolveSandboxEnvDir } from './path' +import { + createSandboxInventory, + normalizeMemoryToken, + parseSbxCpus, + prefixCommandWithCwd, + probeCliAvailability, + removeSandboxWith, + sandboxContainerName, + sanitizeSbxName, + SBX_DEFAULT_TIMEOUT, + type CommandRunner, + type CreateSandboxOpts, + type SandboxExecOpts, + type SandboxRuntime, + type SbxAvailability, + type SandboxWorkspace, +} from './sbx' +import { buildEnvFileExportLoop, quoteShellArg } from './exec-fs' + +/** Install command surfaced when the smolvm CLI is missing. */ +export const SMOLVM_INSTALL_HINT = 'curl -sSL https://smolmachines.com/install.sh | bash' + +/** + * Probes smolvm availability by running `smolvm --version` through the shared availability + * skeleton. A zero exit yields `{ available: true }` — smolvm links libkrun into the binary + * and has no daemon to check, so there is no `daemon-down` state. + */ +export function checkSmolvmAvailability(run: CommandRunner): Promise { + return probeCliAvailability(run, { + args: ['--version'], + isAvailable: (result) => result.exitCode === 0, + fallbackReason: 'unknown', + }) +} + +/** The single source of the user-facing remediation message for an unavailable smolvm CLI. */ +export function describeSmolvmUnavailable( + result: Extract, +): string { + switch (result.reason) { + case 'not-installed': + return `The smolvm CLI is not installed. Install it with: ${SMOLVM_INSTALL_HINT}, then try again.` + case 'daemon-down': + case 'unknown': + return `Could not determine smolvm availability. ${result.detail ?? 'Unknown error.'}` + } +} + +/** + * Coerces a raw `smolvm create --mem` value to integer MiB. Accepts binary units such + * as `1024m` and `8g` (optionally with a trailing `b`), converting `k` down and `g` up + * by 1024, rounding to a whole number and flooring at 1. Logs and returns `undefined` + * for anything else. + */ +export function normalizeSmolvmMemoryMiB(raw: string | undefined, logger: Logger): number | undefined { + const token = normalizeMemoryToken(raw, logger, '--mem') + if (token === undefined) return undefined + const value = parseFloat(token) + const unit = token[token.length - 1] + let miB: number + if (unit === 'k') miB = value / 1024 + else if (unit === 'g') miB = value * 1024 + else miB = value + return Math.max(1, Math.round(miB)) +} + +/** + * Resolves the effective `--allow-host` entries: trimmed, non-empty, and empty whenever any + * entry carries a `*` wildcard. smolvm resolves every `--allow-host` as a literal hostname at + * create time (an unresolvable one fails the create outright), so it has no wildcard concept — + * while an absent allow list already means unrestricted egress. That makes "drop every flag" the + * faithful translation of a wildcard entry such as sbx's allow-everything `**`. + */ +export function resolveSmolvmAllowHosts(allowHosts: string[] | undefined): string[] { + const entries = (allowHosts ?? []).map((host) => host.trim()).filter((host) => host.length > 0) + return entries.some((host) => host.includes('*')) ? [] : entries +} + +/** + * Builds a `smolvm machine create` argument vector for a shell sandbox. Emits + * `machine create --name --net`, then `--image`, one `--allow-host` per resolved + * entry, `--cpus` and `--mem` (MiB) when set, then one `-v HOST:HOST` volume + * per workspace (`:ro` suffix when read-only). Identical-path mounting is a repo + * invariant: exec-fs and the shim rely on host paths resolving unchanged in-guest. The + * primary (worktree) workspace is required, so an empty array throws. + */ +export function buildSmolvmCreateArgs( + name: string, + workspaces: SandboxWorkspace[], + opts: { image?: string; cpus?: number; memMiB?: number; allowHosts?: string[] } = {}, +): string[] { + if (workspaces.length === 0) { + throw new Error('buildSmolvmCreateArgs requires at least one workspace') + } + const args = ['machine', 'create', '--name', name, '--net'] + if (opts.image) args.push('--image', opts.image) + for (const host of resolveSmolvmAllowHosts(opts.allowHosts)) { + args.push('--allow-host', host) + } + if (opts.cpus !== undefined) args.push('--cpus', String(opts.cpus)) + if (opts.memMiB !== undefined) args.push('--mem', String(opts.memMiB)) + for (const ws of workspaces) { + args.push('-v', ws.readOnly ? `${ws.hostDir}:${ws.hostDir}:ro` : `${ws.hostDir}:${ws.hostDir}`) + } + return args +} + +/** Builds a `smolvm machine start` argument vector. */ +export function buildSmolvmStartArgs(name: string): string[] { + return ['machine', 'start', '--name', name] +} + +/** + * Builds the ` -c` script that elevates a guest command to root when the image + * grants passwordless sudo. smolvm bind-mounts the worktree through virtiofs with no uid + * mapping, so the guest sees the host owner while the image user is a different uid and + * every write to the mount fails with EACCES — root is required to write the mounts. + * `-E` plus an explicit `PATH` is used because sudoers `secure_path` would otherwise + * strip the image PATH (losing pnpm/bun/uv); when passwordless sudo is unavailable the + * wrapper falls back to running the inner script unelevated rather than hard-failing. + * The real inner script rides as positional `$0` and its arguments as `"$@"`, so this + * wrapper is passed as the `-c` script with the command following as an argument. + */ +export function buildSmolvmRootWrapper(shell: string): string { + return `if sudo -nE true 2>/dev/null; then exec sudo -nE PATH="$PATH" ${shell} -c "$0" "$@"; fi; exec ${shell} -c "$0" "$@"` +} + +/** + * Builds a `smolvm machine exec` argument vector. Emits `machine exec`, then `-i` when + * interactive, then `--name --` and the command through `sh -c`, routed through + * `buildSmolvmRootWrapper` so guest commands run elevated against the uid-less virtiofs + * mounts. + */ +export function buildSmolvmExecArgs( + name: string, + command: string, + opts?: { interactive?: boolean }, +): string[] { + return [ + 'machine', + 'exec', + ...(opts?.interactive ? ['-i'] : []), + '--name', + name, + '--', + 'sh', + '-c', + buildSmolvmRootWrapper('sh'), + command, + ] +} + +/** Builds a `smolvm machine delete` argument vector with force removal. */ +export function buildSmolvmDeleteArgs(name: string): string[] { + return ['machine', 'delete', '--name', name, '-f'] +} + +/** + * Builds a POSIX-sh preamble that exports each non-empty `KEY=value` line of the env + * file inside the guest without shell-interpreting values. `smolvm machine exec` has no + * `--env-file` flag; the file is host-written by the manager and visible in-guest via + * the env-dir mount. The path is single-quote-escaped so arbitrary paths survive the + * shell round-trip. + */ +export function buildEnvFilePreamble(envFile: string): string { + return buildEnvFileExportLoop(quoteShellArg(envFile)) +} + +/** The forge-managed store path for a ref's `docker save` tar, e.g. `oc-forge-sandbox:latest` → `/oc-forge-sandbox-latest.tar`. */ +export function smolvmImageTarPath(imageStoreDir: string, ref: string): string { + return join(imageStoreDir, `${sanitizeSbxName(ref)}.tar`) +} + +/** + * Resolves the `--image` argument for a ref: a store tar that already exists (smolvm + * accepts a `docker save` archive directly), else a registry-qualified ref containing + * `/` (pull-through), else `null` for an unbuilt local template. + */ +export function resolveSmolvmImageArg(imageStoreDir: string | undefined, ref: string): string | null { + if (imageStoreDir && existsSync(smolvmImageTarPath(imageStoreDir, ref))) { + return smolvmImageTarPath(imageStoreDir, ref) + } + if (ref.includes('/')) return ref + return null +} + +/** + * In-guest script that runs after a machine boots: first silences sudo's "unable to + * resolve host" noise, then brings up the image's Docker daemon, giving smolvm loops the + * same in-sandbox Docker that `sbx` sandboxes get natively. Guest facts shape it: + * + * - The image ships an empty `/etc/hosts`, so every `sudo` invocation prints + * `sudo: unable to resolve host ...` on stderr; appending `127.0.0.1 ` + * silences it permanently. This step must never fail the script, so it is guarded by + * a trailing `|| true`. + * - smolvm boots an image as a bare agent and never runs its entrypoint/init, so the daemon that + * `sbx` starts for free has to be started explicitly here, once per machine start. + * - The machine root filesystem is itself an overlay, which `overlay2` cannot stack on, so the data + * root is pinned to the machine's ext4 `/storage` disk (smolvm's documented docker-in-vm flow). + * Pointing `--data-root` there also survives stop/start, unlike the bind mount the example uses. + * - `smolvm machine exec` applies only the user's primary group, so the default `root:docker` socket + * is unreachable from a loop command; `--group agent` matches the image user's primary group. + * + * Idempotent and self-limiting: it no-ops when the image ships no `dockerd` or a daemon already + * answers, and otherwise waits only until the socket responds. + */ +export const SMOLVM_GUEST_BOOTSTRAP = [ + 'grep -q "$(hostname)" /etc/hosts 2>/dev/null || echo "127.0.0.1 $(hostname)" | sudo -n tee -a /etc/hosts >/dev/null 2>&1 || true', + 'command -v dockerd >/dev/null 2>&1 || exit 0', + 'docker info >/dev/null 2>&1 && exit 0', + 'sudo -n mkdir -p /storage/docker || exit 1', + 'sudo -n dockerd --data-root=/storage/docker --storage-driver=overlay2 --group agent >/tmp/dockerd.log 2>&1 &', + '__i=0', + 'while [ "$__i" -lt 30 ]; do', + ' docker info >/dev/null 2>&1 && exit 0', + ' __i=$((__i+1))', + ' sleep 1', + 'done', + 'exit 1', +].join('\n') + +/** Matches the combined output of a `smolvm machine exec` aimed at a stopped machine. */ +const STOPPED_MACHINE_RE = /not running|is stopped|machine stopped|start the machine/i +const SMOLVM_REMOVE_MISSING_RE = /not found|no such machine|unknown machine|does not exist/i + +/** + * Assembles a `SandboxRuntime` from the pure smolvm helpers, routing every method through an + * injectable `CommandRunner`. The default runner spawns the `smolvm` binary; tests inject a fake. + * `dataDir` enables the two forge-managed paths: the image store (`/smolvm-images`, the + * `docker save` tar is passed to `machine create --image` per create — smolvm has no template + * store) and the env-passthrough directory (`/sandbox-env`, mounted read-only at its + * identical path so execs can source the per-sandbox env file the manager wrote). + */ +export function createSmolvmRuntime( + logger: Logger, + opts?: { run?: CommandRunner; dataDir?: string }, +): SandboxRuntime { + const run: CommandRunner = + opts?.run ?? ((args, o) => runCommand('smolvm', args, { ...o, logger, logLabel: 'smolvm' })) + const imageStoreDir = opts?.dataDir ? join(opts.dataDir, 'smolvm-images') : undefined + const envDir = opts?.dataDir ? resolveSandboxEnvDir(opts.dataDir) : undefined + const inventory = createSandboxInventory(run, ['machine', 'ls', '--json']) + + async function checkAvailable(): Promise { + return checkSmolvmAvailability(run) + } + + function describeUnavailable(result: Extract): string { + return describeSmolvmUnavailable(result) + } + + async function templateExists(ref: string): Promise { + return resolveSmolvmImageArg(imageStoreDir, ref) !== null + } + + function templateLoadHint(ref: string): string { + if (imageStoreDir) return `cp "${smolvmImageTarPath(imageStoreDir, ref)}"` + return 'cp /smolvm-images/' + } + + async function loadTemplate(tarPath: string, ref: string): Promise { + if (!imageStoreDir) { + throw new Error( + `Cannot load sandbox template "${ref}": the smolvm runtime has no dataDir, so there is no image store to copy the tar into`, + ) + } + mkdirSync(imageStoreDir, { recursive: true }) + copyFileSync(tarPath, smolvmImageTarPath(imageStoreDir, ref)) + } + + /** + * Bootstraps the guest after `machine start`: fixes the sudo hostname noise, then + * starts the in-machine Docker daemon. Deliberately never throws: Docker is a sandbox + * capability, not a precondition for running a loop, so an image without `dockerd` or a daemon + * that refuses to come up degrades to "no Docker in this sandbox" (surfaced in the log and by + * the agent's own `docker` failures) instead of failing sandbox creation outright. + */ + async function bootstrapGuest(name: string): Promise { + let result: CommandResult + try { + result = await run(buildSmolvmExecArgs(name, SMOLVM_GUEST_BOOTSTRAP), { timeout: SBX_DEFAULT_TIMEOUT }) + } catch (err) { + logger.log(`Sandbox: Docker bootstrap for ${name} failed: ${err instanceof Error ? err.message : String(err)}`) + return + } + if (result.exitCode !== 0) { + logger.log(`Sandbox: Docker is unavailable in ${name}: ${(result.stderr || result.stdout).trim()}`) + } + } + + /** Starts a machine and bootstraps its guest; every boot path routes through here. */ + async function startMachine(name: string): Promise { + const startResult = await run(buildSmolvmStartArgs(name), { timeout: SBX_DEFAULT_TIMEOUT }) + if (startResult.exitCode !== 0) { + throw new Error(`Failed to start sandbox: ${startResult.stderr}`) + } + await bootstrapGuest(name) + } + + async function createSandbox( + name: string, + workspaces: SandboxWorkspace[], + opts?: CreateSandboxOpts, + ): Promise { + const image = opts?.template !== undefined ? resolveSmolvmImageArg(imageStoreDir, opts.template) : undefined + if (opts?.template !== undefined && image === null) { + throw new Error(`Sandbox template "${opts.template}" not found in the smolvm image store`) + } + const allWorkspaces = [...workspaces] + if (envDir && !allWorkspaces.some((ws) => isSameOrDescendantPath(envDir, ws.hostDir))) { + mkdirSync(envDir, { recursive: true }) + allWorkspaces.push({ hostDir: envDir, readOnly: true }) + } + const createResult = await run( + buildSmolvmCreateArgs(name, allWorkspaces, { + image: image ?? undefined, + cpus: parseSbxCpus(opts?.resources?.cpus, logger), + memMiB: normalizeSmolvmMemoryMiB(opts?.resources?.memory, logger), + allowHosts: opts?.networkAllowHosts, + }), + { timeout: SBX_DEFAULT_TIMEOUT }, + ) + if (createResult.exitCode !== 0) { + throw new Error(`Failed to create sandbox: ${createResult.stderr}`) + } + await startMachine(name) + } + + async function removeSandbox(name: string): Promise { + return removeSandboxWith(run, buildSmolvmDeleteArgs(name), SMOLVM_REMOVE_MISSING_RE) + } + + /** + * Runs a machine exec and absorbs the smolvm create/start split: smolvm does not auto-resume a + * stopped machine like `sbx` does, so a stopped-machine exec failure triggers exactly one + * `machine start` followed by one retry. The failure message is only a pre-filter: the restart + * happens only when the shared liveness inventory confirms the machine is `'stopped'`, so a + * guest command that happens to print e.g. "not running" never triggers a restart, and a missing + * machine surfaces its original error. A failed restart throws rather than looping. + */ + async function execWithRecovery( + name: string, + command: string, + opts: { interactive: boolean; timeout?: number; abort?: AbortSignal; stdin?: string }, + ): Promise { + const runOnce = (): Promise => + run(buildSmolvmExecArgs(name, command, { interactive: opts.interactive }), { + timeout: opts.timeout ?? SBX_DEFAULT_TIMEOUT, + abort: opts.abort, + stdin: opts.stdin, + }) + const first = await runOnce() + if (first.exitCode !== 0 && STOPPED_MACHINE_RE.test(`${first.stdout}\n${first.stderr}`)) { + if ((await inventory.getSandboxState(name)) !== 'stopped') return first + await startMachine(name) + return runOnce() + } + return first + } + + async function exec(name: string, command: string, opts?: SandboxExecOpts): Promise { + const fullCommand = + (opts?.envFile ? buildEnvFilePreamble(opts.envFile) : '') + prefixCommandWithCwd(command, opts?.cwd) + return execWithRecovery(name, fullCommand, { interactive: false, timeout: opts?.timeout, abort: opts?.abort }) + } + + async function execPipe( + name: string, + command: string, + stdin: string, + opts?: { timeout?: number; abort?: AbortSignal; envFile?: string }, + ): Promise { + const fullCommand = (opts?.envFile ? buildEnvFilePreamble(opts.envFile) : '') + command + return execWithRecovery(name, fullCommand, { + interactive: true, + timeout: opts?.timeout, + abort: opts?.abort, + stdin, + }) + } + + async function allowNetworkHost(host: string): Promise { + logger.log(`Sandbox: smolvm applies egress policy per machine at create time; ignoring allowNetworkHost("${host}")`) + return true + } + + return { + checkAvailable, + describeUnavailable, + templateExists, + templateLoadHint, + loadTemplate, + createSandbox, + removeSandbox, + exec, + execPipe, + getSandboxState: inventory.getSandboxState, + sandboxContainerName, + listSandboxesByPrefix: inventory.listSandboxesByPrefix, + allowNetworkHost, + } +} diff --git a/src/sandbox/template.ts b/src/sandbox/template.ts index 133f9b21f..a4416fd55 100644 --- a/src/sandbox/template.ts +++ b/src/sandbox/template.ts @@ -16,7 +16,7 @@ export const DEFAULT_SANDBOX_IMAGE = 'oc-forge-sandbox:latest' export interface BuildTemplateDeps { runCommand: typeof runCommand - loadTemplate: (tar: string) => Promise + loadTemplate: (tar: string, ref: string) => Promise logger: Logger tmpDir: string } @@ -31,9 +31,14 @@ export function buildTemplateDockerArgs(options?: SandboxTemplateOptions): strin : [] } -export function formatTemplateBuildCommands(contextDir: string, tag: string, options?: SandboxTemplateOptions): string { +export function formatTemplateBuildCommands( + contextDir: string, + tag: string, + loadHint: string, + options?: SandboxTemplateOptions, +): string { const build = ['docker', 'build', ...buildTemplateDockerArgs(options), '-t', tag, `"${contextDir}"`].join(' ') - return `${build} && docker save ${tag} -o && sbx template load ` + return `${build} && docker save ${tag} -o && ${loadHint}` } function dockerStageError(stage: 'build' | 'save', result: CommandResult): Error { @@ -75,7 +80,7 @@ export async function buildAndLoadSandboxTemplate( }) if (save.exitCode !== 0) throw dockerStageError('save', save) - await deps.loadTemplate(tarPath) + await deps.loadTemplate(tarPath, tag) } finally { rmSync(tarPath, { force: true }) } diff --git a/src/services/execution.ts b/src/services/execution.ts index 274693d93..b45d5b290 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -740,8 +740,7 @@ export async function attachLoopToSession( if (!waitResult.ready) { deps.logger.error(`attachLoopToSession: sandbox not ready (${waitResult.reason}${waitResult.error ? `: ${waitResult.error}` : ''})`) try { - const { createSbxRuntime } = await import('../sandbox/sbx') - const runtime = createSbxRuntime(deps.logger as unknown as Console) + const runtime = deps.sandboxManager.runtime const cn = runtime.sandboxContainerName(loopName) if (await runtime.getSandboxState(cn) !== 'missing') { await runtime.removeSandbox(cn) diff --git a/src/tui.tsx b/src/tui.tsx index a57350b9f..a2746a385 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -7,7 +7,7 @@ import { resolveForgeDbPath, resolveDataDir } from './storage' import type { ExecutionContextCache } from './utils/tui-execution-context-cache' import { createExecutionContextCache } from './utils/tui-execution-context-cache' import type { PluginConfig } from './types' -import { createSbxRuntime } from './sandbox/sbx' +import { createSandboxRuntime, resolveSandboxMode, type SandboxMode } from './sandbox/runtime-factory' import { buildAndLoadSandboxTemplate, DEFAULT_SANDBOX_IMAGE } from './sandbox/template' import { runCommand } from './sandbox/process' import { isSandboxConfigEnabled } from './sandbox/context' @@ -237,6 +237,8 @@ function SandboxBuildDialog(props: { buildContextDir: string image: string browserControl: boolean + mode: SandboxMode + dataDir: string }) { const theme = () => props.api.theme.current @@ -249,7 +251,7 @@ function SandboxBuildDialog(props: { try { await buildAndLoadSandboxTemplate(props.buildContextDir, props.image, { runCommand, - loadTemplate: (tar) => createSbxRuntime(logger).loadTemplate(tar), + loadTemplate: (tar, ref) => createSandboxRuntime(props.mode, logger, { dataDir: props.dataDir }).loadTemplate(tar, ref), logger, tmpDir: tmpdir(), }, { browserControl: props.browserControl }) @@ -274,7 +276,9 @@ function SandboxBuildDialog(props: { - This builds the sandbox image with Docker, then loads it into sbx. + {props.mode === 'smolvm' + ? 'This builds the sandbox image with Docker, then copies it into the smolvm image store.' + : 'This builds the sandbox image with Docker, then loads it into sbx.'} @@ -330,6 +334,7 @@ const tui: TuiPlugin = async (api) => { // `dataDir` cannot leave the dashboard and the execute-plan dialog pointed at // different databases. const forgeDbPath = resolveForgeDbPath(pluginConfig.dataDir) + const effectiveDataDir = pluginConfig.dataDir || resolveDataDir() const opts: TuiOptions = { sidebar: tuiConfig?.sidebar ?? true, showVersion: tuiConfig?.showVersion ?? true, @@ -338,7 +343,7 @@ const tui: TuiPlugin = async (api) => { createEffect(() => { if (!api.state.ready) return - emitLoopPermissionConfigWarnings(pluginConfig, pluginConfig.dataDir || resolveDataDir(), directory, { + emitLoopPermissionConfigWarnings(pluginConfig, effectiveDataDir, directory, { logger: console, onWarnings: (warnings) => { api.ui.toast({ title: 'Forge loop permissions', message: warnings.join(' '), variant: 'warning', duration: 10_000 }) @@ -624,6 +629,8 @@ const tui: TuiPlugin = async (api) => { buildContextDir={buildContextDir} image={image} browserControl={browserControl} + mode={resolveSandboxMode(pluginConfig)} + dataDir={effectiveDataDir} /> )) } @@ -641,7 +648,7 @@ const tui: TuiPlugin = async (api) => { { name: 'forge.sandbox.buildImage', title: 'Build sandbox template', - desc: 'Build the sandbox template image and load it into sbx', + desc: 'Build the sandbox template image and load it into the sandbox runtime', category: 'Forge', namespace: 'palette', run: () => { runBuildSandboxImage() }, diff --git a/src/types.ts b/src/types.ts index 189f9b632..87e9ffb30 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,13 +81,6 @@ export interface LoopConfig { * host paths, so use the host path here rather than a container mount path. */ allowExternalDirectories?: string[] - /** - * Absolute path of a shared scratch/temp directory granted to loop sessions in BOTH modes. - * It is added to the `external_directory` allowlist and, for sandboxed loops, bind-mounted - * read-write at the identical container path so absolute temp paths match host↔container. - * Defaults to `/tmp/oc-forge`. The directory is created on startup if missing. - */ - tmpDir?: string /** * Inline opencode config object written as `opencode.jsonc` at the root of each freshly created * loop worktree, enabling per-loop opencode customization (primarily MCP servers). The @@ -114,19 +107,23 @@ export interface LoopConfig { export interface SandboxNetworkConfig { /** Environment variable names to pass through from host process into the sandbox. */ env?: string[] - /** Hostnames to allow through the sbx network proxy via `sbx policy allow network`. */ + /** + * Hostnames to allow for egress. Applied via `sbx policy allow network` (sbx, a global + * deny-by-default proxy) or per-machine `--allow-host` flags at create (smolvm; without + * entries the machine has unrestricted egress because smolvm has no global proxy policy). + */ allow?: string[] } /** - * Resource limits for the sandbox. Maps directly to `sbx create` flags. + * Resource limits for the sandbox. Maps to `sbx create` flags or smolvm `--cpus`/`--mem`. * sbx defaults are often too tight for many real projects — `pnpm install` * gets OOM-killed (exit 137) and shell commands run slowly. */ export interface SandboxResources { - /** Memory limit, e.g. '8g', '1024m'. Maps to `sbx create --memory`. */ + /** Memory limit, e.g. '8g', '1024m'. Maps to `sbx create --memory` / smolvm `--mem` (converted to MiB). */ memory?: string - /** Number of CPUs. `sbx create --cpus` is integer-only. */ + /** Number of CPUs. Integer-only for both backends (`sbx create --cpus`, smolvm `--cpus`). */ cpus?: string } @@ -145,12 +142,18 @@ export interface SandboxImageFeaturesConfig { browserControl?: boolean } +/** Sandbox backend selected by `SandboxConfig.mode`. */ +export type SandboxMode = 'sbx' | 'smolvm' + /** - * Configuration for the sandbox execution environment (sbx). + * Configuration for the sandbox execution environment. */ export interface SandboxConfig { - /** Sandbox mode. Currently only 'sbx' is supported. Reserved for future modes. */ - mode?: 'sbx' + /** + * Sandbox mode. `'sbx'` (default) uses the sbx daemon/CLI; `'smolvm'` uses the smolvm + * CLI (no daemon). Unknown or legacy values fall back to `'sbx'`. + */ + mode?: SandboxMode /** Enable sandboxed execution. When false, loops run in worktree-only mode even if sbx is available. Default: true. */ enabled?: boolean /** sbx template tag to use for sandboxed execution. */ diff --git a/src/utils/opencode-paths.ts b/src/utils/opencode-paths.ts index 1495726ed..3f39a538f 100644 --- a/src/utils/opencode-paths.ts +++ b/src/utils/opencode-paths.ts @@ -1,4 +1,4 @@ -import { homedir, platform } from 'os' +import { homedir, platform, tmpdir } from 'os' import { join } from 'path' /** @@ -27,6 +27,17 @@ export function resolveOpencodeToolOutputDir(): string { return join(resolveOpencodeDataDir(), 'tool-output') } +/** + * opencode's advertised scratch directory for its agents (`Global.Path.tmp`, `path.join(os.tmpdir(), app)` + * in opencode `packages/core/src/global.ts`). opencode's shell-tool description tells the agent this + * directory is pre-approved, so Forge grants it `external_directory` access for host file tools and + * bind-mounts it read-write into the sandbox at the identical host path, so the same absolute path + * resolves in both modes. + */ +export function resolveOpencodeTmpDir(): string { + return join(tmpdir(), 'opencode') +} + export function resolveLogPath(): string { return join(resolveDataDir(), 'logs', 'forge.log') } @@ -42,19 +53,3 @@ export function resolveForgeDbPath(configuredDataDir?: string): string { return join(trimmed && trimmed.length > 0 ? trimmed : resolveDataDir(), 'forge.db') } -/** - * Default absolute path for the shared loop scratch/temp directory. Used identically on the host - * (worktree-only loops) and inside the sandbox container (bind-mounted at the same path), so - * absolute temp paths resolve unchanged in both modes. Overridable via `loop.tmpDir`. - */ -export const DEFAULT_FORGE_TMP_DIR = '/tmp/oc-forge' - -/** - * Resolves the shared loop temp directory. Returns the configured override (trimmed) when present, - * otherwise {@link DEFAULT_FORGE_TMP_DIR}. The same value feeds the `external_directory` allowlist - * (both modes) and the sandbox bind-mount (mounted at the identical container path). - */ -export function resolveForgeTempDir(configuredPath?: string): string { - const trimmed = configuredPath?.trim() - return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_FORGE_TMP_DIR -} diff --git a/src/version.ts b/src/version.ts index 915954cc3..0afa7186e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.8.5' +export const VERSION = '0.8.6' diff --git a/test/constants/loop.test.ts b/test/constants/loop.test.ts index fca1578b7..ffdcc1363 100644 --- a/test/constants/loop.test.ts +++ b/test/constants/loop.test.ts @@ -1,12 +1,17 @@ import { describe, it, expect } from 'vitest' import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, resolveLoopAllowedDirectories, resolveLoopPermissionOptions, MAX_TOTAL_SECTIONS, PLAN_AUTHORING_TOOL_NAMES, FORGE_MANAGED_PERMISSIONS } from '../../src/constants/loop' -import { resolveOpencodeToolOutputDir, DEFAULT_FORGE_TMP_DIR } from '../../src/utils/opencode-paths' +import { resolveOpencodeToolOutputDir, resolveOpencodeTmpDir } from '../../src/utils/opencode-paths' const TOOL_OUTPUT_DIR = resolveOpencodeToolOutputDir() const TOOL_OUTPUT_ALLOW_RULES = [ { permission: 'external_directory', pattern: TOOL_OUTPUT_DIR, action: 'allow' as const }, { permission: 'external_directory', pattern: `${TOOL_OUTPUT_DIR}/**`, action: 'allow' as const }, ] +const OPENCODE_TMP_DIR = resolveOpencodeTmpDir() +const OPENCODE_TMP_ALLOW_RULES = [ + { permission: 'external_directory', pattern: OPENCODE_TMP_DIR, action: 'allow' as const }, + { permission: 'external_directory', pattern: `${OPENCODE_TMP_DIR}/**`, action: 'allow' as const }, +] describe('MAX_TOTAL_SECTIONS', () => { it('is the single canonical section cap', () => { @@ -35,6 +40,7 @@ describe('buildLoopPermissionRuleset', () => { { permission: '*', pattern: '*', action: 'allow' }, { permission: 'external_directory', pattern: '*', action: 'deny' }, ...TOOL_OUTPUT_ALLOW_RULES, + ...OPENCODE_TMP_ALLOW_RULES, { permission: 'review-write', pattern: '*', action: 'deny' }, { permission: 'review-delete', pattern: '*', action: 'deny' }, { permission: 'plan', pattern: '*', action: 'deny' }, @@ -99,12 +105,17 @@ describe('buildAuditSessionPermissionRuleset', () => { describe('external directory allowlist', () => { const VAULT = '/Users/chris/Documents/Obsidian/GFPRO' - it('loop ruleset allows only the tool-output directory when allowDirectories is omitted', () => { + it('loop ruleset allows only the tool-output and opencode tmp directories when allowDirectories is omitted', () => { const rules = buildLoopPermissionRuleset() const allowPatterns = rules .filter(r => r.permission === 'external_directory' && r.action === 'allow') .map(r => r.pattern) - expect(allowPatterns).toEqual([TOOL_OUTPUT_DIR, `${TOOL_OUTPUT_DIR}/**`]) + expect(allowPatterns).toEqual([ + TOOL_OUTPUT_DIR, + `${TOOL_OUTPUT_DIR}/**`, + OPENCODE_TMP_DIR, + `${OPENCODE_TMP_DIR}/**`, + ]) }) it('loop ruleset adds exact + recursive allow rules for each configured directory', () => { @@ -133,32 +144,21 @@ describe('external directory allowlist', () => { const rules = buildLoopPermissionRuleset({ allowDirectories: [`${VAULT}/`, '', ' '] }) expect(rules).toContainEqual({ permission: 'external_directory', pattern: VAULT, action: 'allow' }) const allowRules = rules.filter(r => r.permission === 'external_directory' && r.action === 'allow') - // Always-on tool-output dir (exact + recursive) + the one valid configured directory - // (exact + recursive) = 4 rules; blank/invalid entries are ignored. - expect(allowRules).toHaveLength(4) + // Always-on tool-output dir (exact + recursive) + always-on opencode tmp dir (exact + recursive) + // + the one valid configured directory (exact + recursive) = 6 rules; blank/invalid entries are ignored. + expect(allowRules).toHaveLength(6) }) }) describe('resolveLoopAllowedDirectories', () => { - it('always includes the default temp dir, even with no config', () => { - expect(resolveLoopAllowedDirectories(undefined)).toEqual([DEFAULT_FORGE_TMP_DIR]) - expect(resolveLoopAllowedDirectories({})).toEqual([DEFAULT_FORGE_TMP_DIR]) + it('returns no directories when no config is given', () => { + expect(resolveLoopAllowedDirectories(undefined)).toEqual([]) + expect(resolveLoopAllowedDirectories({})).toEqual([]) }) - it('layers configured external directories after the temp dir', () => { + it('returns only the configured external directories', () => { const config = { loop: { allowExternalDirectories: ['/vault', '/notes'] } } - expect(resolveLoopAllowedDirectories(config)).toEqual([DEFAULT_FORGE_TMP_DIR, '/vault', '/notes']) - }) - - it('honors a configured tmpDir override', () => { - const config = { loop: { tmpDir: '/scratch/forge', allowExternalDirectories: ['/vault'] } } - expect(resolveLoopAllowedDirectories(config)).toEqual(['/scratch/forge', '/vault']) - }) - - it('grants the temp dir in the loop ruleset', () => { - const rules = buildLoopPermissionRuleset({ allowDirectories: resolveLoopAllowedDirectories(undefined) }) - expect(rules).toContainEqual({ permission: 'external_directory', pattern: DEFAULT_FORGE_TMP_DIR, action: 'allow' }) - expect(rules).toContainEqual({ permission: 'external_directory', pattern: `${DEFAULT_FORGE_TMP_DIR}/**`, action: 'allow' }) + expect(resolveLoopAllowedDirectories(config)).toEqual(['/vault', '/notes']) }) }) @@ -202,12 +202,12 @@ describe('resolveLoopPermissionOptions', () => { it('resolves both the directory list and the parsed rule from config', () => { const config = { loop: { permissions: { deny: ['webfetch'] }, allowExternalDirectories: ['/vault'] } } const options = resolveLoopPermissionOptions(config) - expect(options.allowDirectories).toEqual([DEFAULT_FORGE_TMP_DIR, '/vault']) + expect(options.allowDirectories).toEqual(['/vault']) expect(options.extraRules).toEqual([{ permission: 'webfetch', pattern: '*', action: 'deny' }]) }) it('yields empty options when config is undefined', () => { - expect(resolveLoopPermissionOptions(undefined)).toEqual({ allowDirectories: [DEFAULT_FORGE_TMP_DIR], extraRules: [] }) + expect(resolveLoopPermissionOptions(undefined)).toEqual({ allowDirectories: [], extraRules: [] }) }) }) diff --git a/test/helpers/sandbox-mocks.ts b/test/helpers/sandbox-mocks.ts index 56ada5815..8e5d2c533 100644 --- a/test/helpers/sandbox-mocks.ts +++ b/test/helpers/sandbox-mocks.ts @@ -1,7 +1,31 @@ import { vi } from 'vitest' -import type { SandboxWorkspace, SandboxRuntime, SandboxState } from '../../src/sandbox/sbx' +import type { CommandRunner, SandboxWorkspace, SandboxRuntime, SandboxState } from '../../src/sandbox/sbx' import type { SandboxResources } from '../../src/types' +/** A recorded CommandRunner invocation plus the options the facade forwarded. */ +export interface RecordingCall { + args: string[] + opts?: { timeout?: number; stdin?: string } +} + +/** + * Returns a fake CommandRunner that records every invocation (args plus forwarded timeout/stdin) + * and optionally delegates each call to `handler`. Shared by the sbx and smolvm runtime facade + * suites so both backends exercise the same recording seam. + */ +export function createRecordingRunner( + handler?: (rec: RecordingCall) => { stdout: string; stderr: string; exitCode: number }, +): { calls: RecordingCall[]; runner: CommandRunner } { + const calls: RecordingCall[] = [] + const runner: CommandRunner = async (args, opts) => { + const rec = { args, opts: { timeout: opts?.timeout, stdin: opts?.stdin } } + calls.push(rec) + const res = handler ? handler(rec) : { stdout: '', stderr: '', exitCode: 0 } + return res + } + return { calls, runner } +} + /** * Mock SandboxRuntime plus the test helpers used by the manager suites. Extending * `SandboxRuntime` keeps the object statically checked against the real runtime interface @@ -9,7 +33,7 @@ import type { SandboxResources } from '../../src/types' */ export interface MockSandboxRuntime extends SandboxRuntime { getCreateSandboxCalls(): Array< - [string, SandboxWorkspace[], { template?: string; resources?: SandboxResources } | undefined] + [string, SandboxWorkspace[], { template?: string; resources?: SandboxResources; networkAllowHosts?: string[] } | undefined] > getRemoveSandboxCalls(): string[] setSandboxes(newSandboxes: string[]): void @@ -26,7 +50,7 @@ export interface MockSandboxRuntime extends SandboxRuntime { */ export function createMockSandboxRuntime(): MockSandboxRuntime { const createSandboxCalls: Array< - [string, SandboxWorkspace[], { template?: string; resources?: SandboxResources } | undefined] + [string, SandboxWorkspace[], { template?: string; resources?: SandboxResources; networkAllowHosts?: string[] } | undefined] > = [] const removeSandboxCalls: string[] = [] let sandboxes = ['forge-foo', 'forge-bar'] @@ -40,11 +64,16 @@ export function createMockSandboxRuntime(): MockSandboxRuntime { ? { available: true as const } : { available: false as const, reason: 'daemon-down' as const, detail: 'mock daemon down' }, templateExists: async () => shouldTemplateExist, - loadTemplate: async () => {}, + loadTemplate: async (_tar: string, _ref: string) => {}, + describeUnavailable: (result) => + result.reason === 'daemon-down' + ? 'The sbx daemon is not running' + : `Sandbox unavailable: ${result.reason}`, + templateLoadHint: () => 'sbx template load ', createSandbox: async ( name: string, workspaces: SandboxWorkspace[], - opts?: { template?: string; resources?: SandboxResources }, + opts?: { template?: string; resources?: SandboxResources; networkAllowHosts?: string[] }, ) => { createSandboxCalls.push([name, workspaces, opts]) sandboxStates.set(name, 'running') diff --git a/test/loop-permission-ruleset.test.ts b/test/loop-permission-ruleset.test.ts index 21214cd51..b25252c95 100644 --- a/test/loop-permission-ruleset.test.ts +++ b/test/loop-permission-ruleset.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset } from '../src/constants/loop' -import { resolveOpencodeToolOutputDir } from '../src/utils/opencode-paths' +import { resolveOpencodeToolOutputDir, resolveOpencodeTmpDir } from '../src/utils/opencode-paths' import { createLoopPermissionPatcher, __resetLoopPermissionCache } from '../src/hooks/loop-permission' import { createAuditSession } from '../src/utils/audit-session' import { createLoopSessionWithWorkspace } from '../src/utils/loop-session' @@ -11,6 +11,11 @@ const TOOL_OUTPUT_ALLOW_RULES = [ { permission: 'external_directory', pattern: TOOL_OUTPUT_DIR, action: 'allow' as const }, { permission: 'external_directory', pattern: `${TOOL_OUTPUT_DIR}/**`, action: 'allow' as const }, ] +const OPENCODE_TMP_DIR = resolveOpencodeTmpDir() +const OPENCODE_TMP_ALLOW_RULES = [ + { permission: 'external_directory', pattern: OPENCODE_TMP_DIR, action: 'allow' as const }, + { permission: 'external_directory', pattern: `${OPENCODE_TMP_DIR}/**`, action: 'allow' as const }, +] beforeEach(() => { __resetLoopPermissionCache() @@ -23,6 +28,7 @@ describe('buildLoopPermissionRuleset', () => { { permission: '*', pattern: '*', action: 'allow' }, { permission: 'external_directory', pattern: '*', action: 'deny' }, ...TOOL_OUTPUT_ALLOW_RULES, + ...OPENCODE_TMP_ALLOW_RULES, { permission: 'review-write', pattern: '*', action: 'deny' }, { permission: 'review-delete', pattern: '*', action: 'deny' }, { permission: 'plan', pattern: '*', action: 'deny' }, @@ -73,12 +79,31 @@ describe('buildLoopPermissionRuleset', () => { } }) - test('does not allow arbitrary external directories beyond tool-output and configured opt-ins', () => { + test('does not allow arbitrary external directories beyond tool-output, opencode tmp, and configured opt-ins', () => { const rules = buildLoopPermissionRuleset() const allowPatterns = rules .filter((r) => r.permission === 'external_directory' && r.action === 'allow') .map((r) => r.pattern) - expect(allowPatterns).toEqual([TOOL_OUTPUT_DIR, `${TOOL_OUTPUT_DIR}/**`]) + expect(allowPatterns).toEqual([ + TOOL_OUTPUT_DIR, + `${TOOL_OUTPUT_DIR}/**`, + OPENCODE_TMP_DIR, + `${OPENCODE_TMP_DIR}/**`, + ]) + }) + + test('always allows opencode temp dir (Global.Path.tmp) layered after the blanket deny', () => { + const rules = buildLoopPermissionRuleset() + const denyIdx = rules.findIndex( + (r) => r.permission === 'external_directory' && r.pattern === '*' && r.action === 'deny', + ) + expect(denyIdx).toBeGreaterThanOrEqual(0) + for (const allowRule of OPENCODE_TMP_ALLOW_RULES) { + const idx = rules.findIndex( + (r) => r.permission === allowRule.permission && r.pattern === allowRule.pattern && r.action === allowRule.action, + ) + expect(idx).toBeGreaterThan(denyIdx) + } }) }) @@ -130,6 +155,20 @@ describe('buildAuditSessionPermissionRuleset', () => { expect(denyIdx).toBeLessThan(allowIdx) } }) + + test('always allows opencode temp dir (Global.Path.tmp) layered after the blanket deny', () => { + const rules = buildAuditSessionPermissionRuleset() + const denyIdx = rules.findIndex( + (r) => r.permission === 'external_directory' && r.pattern === '*' && r.action === 'deny', + ) + expect(denyIdx).toBeGreaterThanOrEqual(0) + for (const allowRule of OPENCODE_TMP_ALLOW_RULES) { + const idx = rules.findIndex( + (r) => r.permission === allowRule.permission && r.pattern === allowRule.pattern && r.action === allowRule.action, + ) + expect(idx).toBeGreaterThan(denyIdx) + } + }) }) describe('createAuditSession passes audit permission rules into session creation', () => { diff --git a/test/sandbox/config-warnings.test.ts b/test/sandbox/config-warnings.test.ts index c4dc0c769..738da6974 100644 --- a/test/sandbox/config-warnings.test.ts +++ b/test/sandbox/config-warnings.test.ts @@ -13,7 +13,8 @@ describe('collectLegacySandboxConfigWarnings', () => { const warnings = collectLegacySandboxConfigWarnings(raw) expect(warnings).toHaveLength(6) const joined = warnings.join('\n') - expect(joined).toContain('sandbox.mode') + expect(joined).toContain("sandbox.mode 'docker' is ignored") + expect(joined).toContain("use mode 'sbx' (default) or 'smolvm'") expect(joined).toContain('sandbox.projectMountPath') expect(joined).toContain('sandbox.resources.shmSize') expect(joined).toContain('sandbox.resources.memorySwap') diff --git a/test/sandbox/context.test.ts b/test/sandbox/context.test.ts index 4c76c86ae..006a0ae61 100644 --- a/test/sandbox/context.test.ts +++ b/test/sandbox/context.test.ts @@ -1,7 +1,27 @@ import { describe, it, expect, vi } from 'vitest' -import { isSandboxEnabled, isSandboxConfigEnabled, resolveSandboxContextForLoop } from '../../src/sandbox/context' +import { + SANDBOX_CONTEXT_NOTE, + isSandboxEnabled, + isSandboxConfigEnabled, + resolveSandboxContextForLoop, +} from '../../src/sandbox/context' import type { SandboxMount } from '../../src/sandbox/path' +describe('SANDBOX_CONTEXT_NOTE', () => { + it('keeps the container-routing caveat and gives accurate lifetime/scratch guidance', () => { + expect(SANDBOX_CONTEXT_NOTE).toContain('bash tool commands execute in that container, not on the host') + expect(SANDBOX_CONTEXT_NOTE).toContain('foreground') + expect(SANDBOX_CONTEXT_NOTE).toMatch(/timeout/i) + expect(SANDBOX_CONTEXT_NOTE).toMatch(/reboots/i) + expect(SANDBOX_CONTEXT_NOTE).toContain('files on disk') + expect(SANDBOX_CONTEXT_NOTE).not.toContain('stops shortly after each command') + }) + + it('does not name any specific scratch directory (agents use opencode\'s advertised default)', () => { + expect(SANDBOX_CONTEXT_NOTE).not.toContain('/tmp/oc-forge') + }) +}) + describe('isSandboxEnabled', () => { it('returns true when sandboxManager is provided regardless of legacy mode value', () => { expect(isSandboxEnabled({ sandbox: { mode: 'docker' as const } }, {} as unknown)).toBe(true) diff --git a/test/sandbox/manager-keepalive.test.ts b/test/sandbox/manager-keepalive.test.ts new file mode 100644 index 000000000..417ce2c84 --- /dev/null +++ b/test/sandbox/manager-keepalive.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + createSandboxManager, + SANDBOX_SENTINEL_SECONDS, + SANDBOX_SENTINEL_TIMEOUT_MS, + type SandboxManagerConfig, +} from '../../src/sandbox/manager' +import { createMockSandboxRuntime, createMockLogger } from '../helpers/sandbox-mocks' +import type { CommandResult } from '../../src/sandbox/process' +import type { SandboxExecOpts } from '../../src/sandbox/sbx' + +describe('SandboxManager keep-alive', () => { + let mockRuntime: ReturnType + let mockLogger: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + mockRuntime = createMockSandboxRuntime() + mockRuntime.getSandboxState = vi.fn(async () => 'missing' as const) + mockRuntime.createSandbox = vi.fn(async () => {}) + mockRuntime.exec = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) + mockLogger = createMockLogger() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function makeManager(overrides: Partial = {}) { + const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', ...overrides } + return createSandboxManager(mockRuntime, config, mockLogger) + } + + it('issues exactly one sentinel exec with the sleep command, timeout, and an AbortSignal', async () => { + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + expect(mockRuntime.exec).toHaveBeenCalledWith('forge-wt', `sleep ${SANDBOX_SENTINEL_SECONDS}`, { + timeout: SANDBOX_SENTINEL_TIMEOUT_MS, + abort: expect.any(AbortSignal), + }) + }) + + it('does not poll while a sentinel is still in flight', async () => { + mockRuntime.exec = vi.fn(() => new Promise(() => {})) + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + }) + + it('renews with a fresh sentinel exec once the sleep has fully elapsed', async () => { + mockRuntime.exec = vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ stdout: '', stderr: '', exitCode: 0 }), SANDBOX_SENTINEL_SECONDS * 1000) + }), + ) + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(SANDBOX_SENTINEL_SECONDS * 1000) + expect(mockRuntime.exec).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(SANDBOX_SENTINEL_SECONDS * 1000) + expect(mockRuntime.exec).toHaveBeenCalledTimes(3) + }) + + it('does not renew and logs when the exec returns faster than the minimum', async () => { + mockRuntime.exec = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('not renewing')) + }) + + it('stops renewal and logs when the sentinel exits non-zero', async () => { + mockRuntime.exec = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })) + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('exited 1')) + }) + + it('stops renewal and logs a throwing sentinel without rejecting', async () => { + mockRuntime.exec = vi.fn(async () => { + throw new Error('keep-alive exec failed') + }) + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('keep-alive sentinel for forge-wt failed'), + ) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + }) + + it('aborts the sentinel signal before removing the sandbox on stop', async () => { + let capturedSignal: AbortSignal | undefined + let abortedAtRemoval = false + mockRuntime.exec = vi.fn( + (_name: string, _command: string, opts?: SandboxExecOpts) => { + capturedSignal = opts?.abort + return new Promise(() => {}) + }, + ) + mockRuntime.removeSandbox = vi.fn(async (_name: string) => { + abortedAtRemoval = capturedSignal?.aborted === true + }) + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(capturedSignal).toBeDefined() + + await manager.stop('wt') + + expect(abortedAtRemoval).toBe(true) + expect(capturedSignal?.aborted).toBe(true) + }) + + it('dispose aborts the sentinel without removing or stopping any sandbox', async () => { + let capturedSignal: AbortSignal | undefined + mockRuntime.exec = vi.fn( + (_name: string, _command: string, opts?: SandboxExecOpts) => { + capturedSignal = opts?.abort + return new Promise(() => {}) + }, + ) + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(capturedSignal?.aborted).toBe(false) + + manager.dispose() + + expect(capturedSignal?.aborted).toBe(true) + expect(mockRuntime.getRemoveSandboxCalls()).toEqual([]) + expect(manager.isActive('wt')).toBe(true) + }) + + it('starts a fresh sentinel when ensureRunning re-registers a worktree after renewal stopped', async () => { + mockRuntime.exec = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) + const manager = makeManager() + await manager.start('wt', '/tmp/project') + + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('not renewing')) + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000) + expect(mockRuntime.exec).toHaveBeenCalledTimes(1) + + await manager.ensureRunning('wt', '/tmp/project') + + expect(mockRuntime.exec).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/sandbox/manager-network-allow.test.ts b/test/sandbox/manager-network-allow.test.ts index 95496dd6e..56a69f776 100644 --- a/test/sandbox/manager-network-allow.test.ts +++ b/test/sandbox/manager-network-allow.test.ts @@ -29,6 +29,33 @@ describe('SandboxManager network allowlist', () => { expect(allowNetworkHost).toHaveBeenNthCalledWith(2, 'pypi.org') }) + test('each createSandbox call receives the configured hosts as networkAllowHosts', async () => { + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const manager = createSandboxManager( + runtime, + makeConfig(['registry.npmjs.org', 'pypi.org']), + logger, + ) + + await manager.start('test', '/home/user/worktrees/feature') + + const calls = runtime.getCreateSandboxCalls() + expect(calls).toHaveLength(1) + expect(calls[0][2]?.networkAllowHosts).toEqual(['registry.npmjs.org', 'pypi.org']) + }) + + test('networkAllowHosts is absent from createSandbox opts when allow is unset', async () => { + const runtime = createMockSandboxRuntime() + const manager = createSandboxManager(runtime, makeConfig(undefined), createMockLogger()) + + await manager.start('test', '/home/user/worktrees/feature') + + const calls = runtime.getCreateSandboxCalls() + expect(calls).toHaveLength(1) + expect(calls[0][2]?.networkAllowHosts).toBeUndefined() + }) + test('a false return is logged and does not fail start', async () => { const runtime = createMockSandboxRuntime() const logger = createMockLogger() diff --git a/test/sandbox/runtime-factory.test.ts b/test/sandbox/runtime-factory.test.ts new file mode 100644 index 000000000..9f4cd8842 --- /dev/null +++ b/test/sandbox/runtime-factory.test.ts @@ -0,0 +1,78 @@ +import { describe, test, expect, vi } from 'vitest' +import { createSandboxRuntime, resolveSandboxMode } from '../../src/sandbox/runtime-factory' +import type { PluginConfig, Logger } from '../../src/types' +import type { CommandRunner } from '../../src/sandbox/sbx' +import { createRecordingRunner } from '../helpers/sandbox-mocks' + +const logger: Logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } + +describe('resolveSandboxMode', () => { + test('undefined config defaults to sbx', () => { + expect(resolveSandboxMode(undefined)).toBe('sbx') + }) + + test('config without sandbox section defaults to sbx', () => { + expect(resolveSandboxMode({})).toBe('sbx') + }) + + test('explicit sbx mode resolves to sbx', () => { + expect(resolveSandboxMode({ sandbox: { mode: 'sbx' } })).toBe('sbx') + }) + + test('explicit smolvm mode resolves to smolvm', () => { + expect(resolveSandboxMode({ sandbox: { mode: 'smolvm' } })).toBe('smolvm') + }) + + test('unknown or legacy mode values fall back to sbx', () => { + expect(resolveSandboxMode({ sandbox: { mode: 'docker' as never } })).toBe('sbx') + expect(resolveSandboxMode({ sandbox: { mode: 'podman' as never } })).toBe('sbx') + }) + + test('accepts a full PluginConfig shape', () => { + const config: PluginConfig = { sandbox: { mode: 'smolvm', enabled: true } } + expect(resolveSandboxMode(config)).toBe('smolvm') + }) +}) + +describe('createSandboxRuntime', () => { + test('sbx mode dispatches to the sbx runtime (templateLoadHint)', () => { + const rt = createSandboxRuntime('sbx', logger) + expect(rt.templateLoadHint('oc-forge-sandbox:latest')).toBe('sbx template load ') + }) + + test('sbx mode describeUnavailable carries the sbx remediation string', () => { + const rt = createSandboxRuntime('sbx', logger) + expect(rt.describeUnavailable({ available: false, reason: 'not-installed' })).toMatch(/sbx login/) + }) + + test('sbx mode checkAvailable runs the sbx probe argv', async () => { + const { calls, runner } = createRecordingRunner(() => ({ stdout: 'Status: running\n', stderr: '', exitCode: 0 })) + const rt = createSandboxRuntime('sbx', logger, { run: runner }) + await rt.checkAvailable() + expect(calls[0].args).toEqual(['daemon', 'status']) + }) + + test('smolvm mode dispatches to the smolvm runtime (templateLoadHint mentions the store path)', () => { + const rt = createSandboxRuntime('smolvm', logger, { dataDir: '/forge-data' }) + expect(rt.templateLoadHint('oc-forge-sandbox:latest')).toBe( + 'cp "/forge-data/smolvm-images/oc-forge-sandbox-latest.tar"', + ) + }) + + test('smolvm mode without dataDir degrades the load hint instead of throwing', () => { + const rt = createSandboxRuntime('smolvm', logger) + expect(rt.templateLoadHint('oc-forge-sandbox:latest')).toBe('cp /smolvm-images/') + }) + + test('smolvm mode describeUnavailable carries the smolvm remediation string', () => { + const rt = createSandboxRuntime('smolvm', logger) + expect(rt.describeUnavailable({ available: false, reason: 'not-installed' })).toMatch(/smolmachines\.com/) + }) + + test('smolvm mode checkAvailable runs the smolvm probe argv', async () => { + const { calls, runner } = createRecordingRunner(() => ({ stdout: 'smolvm 0.1.0\n', stderr: '', exitCode: 0 })) + const rt = createSandboxRuntime('smolvm', logger, { run: runner }) + await rt.checkAvailable() + expect(calls[0].args).toEqual(['--version']) + }) +}) diff --git a/test/sandbox/sbx-runtime.test.ts b/test/sandbox/sbx-runtime.test.ts index d2f26e181..dc42b931b 100644 --- a/test/sandbox/sbx-runtime.test.ts +++ b/test/sandbox/sbx-runtime.test.ts @@ -12,6 +12,7 @@ import { checkSbxAvailability, describeSbxUnavailable, createSbxRuntime, + prefixCommandWithCwd, } from '../../src/sandbox/sbx' import type { CommandRunner } from '../../src/sandbox/sbx' import type { Logger } from '../../src/types' @@ -309,24 +310,11 @@ describe('availability', () => { }) }) -describe('runtime', () => { - interface Rec { - args: string[] - opts?: { timeout?: number; stdin?: string } - } - function recordingRunner(handler?: (rec: Rec) => { stdout: string; stderr: string; exitCode: number }) { - const calls: Rec[] = [] - const runner: CommandRunner = async (args, opts) => { - const rec = { args, opts: { timeout: opts?.timeout, stdin: opts?.stdin } } - calls.push(rec) - const res = handler ? handler(rec) : { stdout: '', stderr: '', exitCode: 0 } - return res - } - return { calls, runner } - } +import { createRecordingRunner } from '../helpers/sandbox-mocks' +describe('runtime', () => { test('exec with cwd prefixes the command with cd and records args', async () => { - const { calls, runner } = recordingRunner() + const { calls, runner } = createRecordingRunner() const rt = createSbxRuntime(logger, { run: runner }) await rt.exec('forge-c', 'ls', { cwd: "/w/it's" }) expect(calls[0].args).toEqual([ @@ -338,8 +326,31 @@ describe('runtime', () => { ]) }) + test('prefixCommandWithCwd escapes single quotes and leaves a missing cwd untouched', () => { + expect(prefixCommandWithCwd('ls', "/w/it's")).toBe("cd '/w/it'\\''s' && ls") + expect(prefixCommandWithCwd('ls')).toBe('ls') + }) + + test('loadTemplate accepts a ref argument that the sbx runner ignores', async () => { + const { calls, runner } = createRecordingRunner() + const rt = createSbxRuntime(logger, { run: runner }) + await rt.loadTemplate('/tmp/t.tar', 'oc-forge-sandbox:latest') + expect(calls[0].args).toEqual(['template', 'load', '/tmp/t.tar']) + expect(calls[0].opts?.timeout).toBe(600000) + }) + + test('describeUnavailable delegates to describeSbxUnavailable', () => { + const rt = createSbxRuntime(logger, { run: async () => ({ stdout: '', stderr: '', exitCode: 0 }) }) + expect(rt.describeUnavailable({ available: false, reason: 'daemon-down', detail: 'x' })).toMatch(/sbx daemon start/) + }) + + test('templateLoadHint returns the sbx template load command', () => { + const rt = createSbxRuntime(logger, { run: async () => ({ stdout: '', stderr: '', exitCode: 0 }) }) + expect(rt.templateLoadHint('oc-forge-sandbox:latest')).toBe('sbx template load ') + }) + test('exec passes envFile and timeout to the runner', async () => { - const { calls, runner } = recordingRunner() + const { calls, runner } = createRecordingRunner() const rt = createSbxRuntime(logger, { run: runner }) await rt.exec('forge-c', 'ls', { envFile: '/e.env', timeout: 3000 }) expect(calls[0].args).toContain('--env-file') @@ -347,7 +358,7 @@ describe('runtime', () => { }) test('execPipe sets interactive and passes stdin through', async () => { - const { calls, runner } = recordingRunner() + const { calls, runner } = createRecordingRunner() const rt = createSbxRuntime(logger, { run: runner }) await rt.execPipe('forge-c', 'cat', 'hello') expect(calls[0].args.slice(0, 3)).toEqual(['exec', '-i', 'forge-c']) @@ -355,7 +366,7 @@ describe('runtime', () => { }) test('createSandbox builds create args with coerced resources', async () => { - const { calls, runner } = recordingRunner() + const { calls, runner } = createRecordingRunner() const rt = createSbxRuntime(logger, { run: runner }) await rt.createSandbox('forge-c', [{ hostDir: '/work' }], { template: 't1', @@ -369,7 +380,7 @@ describe('runtime', () => { }) test('createSandbox throws on non-zero exit', async () => { - const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'boom', exitCode: 1 })) + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'boom', exitCode: 1 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.createSandbox('forge-c', [{ hostDir: '/work' }])).rejects.toThrow( 'Failed to create sandbox: boom', @@ -377,27 +388,27 @@ describe('runtime', () => { }) test('removeSandbox records rm --force', async () => { - const { calls, runner } = recordingRunner() + const { calls, runner } = createRecordingRunner() const rt = createSbxRuntime(logger, { run: runner }) await rt.removeSandbox('forge-a') expect(calls[0].args).toEqual(['rm', '--force', 'forge-a']) }) test('removeSandbox tolerates a not-found failure', async () => { - const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'no such sandbox forge-a', exitCode: 1 })) + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'no such sandbox forge-a', exitCode: 1 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.removeSandbox('forge-a')).resolves.toBeUndefined() }) test('removeSandbox throws on an unexpected failure', async () => { - const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'permission denied', exitCode: 1 })) + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'permission denied', exitCode: 1 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.removeSandbox('forge-a')).rejects.toThrow('Failed to remove sandbox') }) test('getSandboxState reports running, and missing for an absent name', async () => { const stdout = JSON.stringify({ sandboxes: [{ name: 'forge-a', status: 'running' }] }) - const { runner } = recordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('running') await expect(rt.getSandboxState('forge-b')).resolves.toBe('missing') @@ -405,19 +416,19 @@ describe('runtime', () => { test('getSandboxState reports an idle-suspended sandbox as stopped, not missing', async () => { const stdout = JSON.stringify({ sandboxes: [{ name: 'forge-a', status: 'stopped' }] }) - const { runner } = recordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('stopped') }) test('getSandboxState reports unknown on a failing ls rather than claiming missing', async () => { - const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') }) test('getSandboxState reports unknown when the ls invocation throws', async () => { - const { runner } = recordingRunner(() => { throw new Error('sbx exploded') }) + const { runner } = createRecordingRunner(() => { throw new Error('sbx exploded') }) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') }) @@ -425,32 +436,32 @@ describe('runtime', () => { test('getSandboxState reports unknown when a successful ls emits unparseable output', async () => { // A truncated or schema-changed payload must never be read as "the sandbox is gone", // or the caller would destroy or duplicate a live sandbox. - const { runner } = recordingRunner(() => ({ stdout: '{"sandboxes":[{"name":"forg', stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout: '{"sandboxes":[{"name":"forg', stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') }) test('getSandboxState reports unknown for valid JSON in an unrecognized shape', async () => { // An error object or a future nested schema is a failed inventory read, not an empty inventory - const { runner } = recordingRunner(() => ({ stdout: JSON.stringify({ error: 'daemon down' }), stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout: JSON.stringify({ error: 'daemon down' }), stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') }) test('getSandboxState reports unknown for a valid JSON scalar', async () => { - const { runner } = recordingRunner(() => ({ stdout: '123', stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout: '123', stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') }) test('getSandboxState reports missing when a successful ls returns an empty list', async () => { - const { runner } = recordingRunner(() => ({ stdout: JSON.stringify({ sandboxes: [] }), stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout: JSON.stringify({ sandboxes: [] }), stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('missing') }) test('getSandboxState treats empty output as an empty list so a missing sandbox can still be created', async () => { - const { runner } = recordingRunner(() => ({ stdout: '', stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.getSandboxState('forge-a')).resolves.toBe('missing') }) @@ -463,48 +474,48 @@ describe('runtime', () => { { name: 'other', status: 'running' }, ], }) - const { runner } = recordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.listSandboxesByPrefix('forge-')).resolves.toEqual(['forge-a', 'forge-b']) }) test('listSandboxesByPrefix returns [] on a failing ls', async () => { - const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.listSandboxesByPrefix('forge-')).resolves.toEqual([]) }) test('templateExists matches parsed template list', async () => { const stdout = ['REPOSITORY TAG', 'oc-forge-sandbox latest'].join('\n') - const { runner } = recordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.templateExists('oc-forge-sandbox:latest')).resolves.toBe(true) await expect(rt.templateExists('oc-forge-sandbox:other')).resolves.toBe(false) }) test('loadTemplate throws on non-zero exit', async () => { - const { calls, runner } = recordingRunner(() => ({ stdout: '', stderr: 'bad tar', exitCode: 1 })) + const { calls, runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'bad tar', exitCode: 1 })) const rt = createSbxRuntime(logger, { run: runner }) - await expect(rt.loadTemplate('/tmp/t.tar')).rejects.toThrow('Failed to load sandbox template') + await expect(rt.loadTemplate('/tmp/t.tar', 'oc-forge-sandbox:latest')).rejects.toThrow('Failed to load sandbox template') expect(calls[0].args).toEqual(['template', 'load', '/tmp/t.tar']) expect(calls[0].opts?.timeout).toBe(600000) }) test('checkAvailable proxies to checkSbxAvailability', async () => { - const { runner } = recordingRunner(() => ({ stdout: 'Status: running\n', stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout: 'Status: running\n', stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.checkAvailable()).resolves.toEqual({ available: true }) }) test('allowNetworkHost returns false on non-zero exit without throwing', async () => { - const { calls, runner } = recordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) + const { calls, runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.allowNetworkHost('db.internal')).resolves.toBe(false) expect(calls[0].args).toEqual(['policy', 'allow', 'network', 'db.internal']) }) test('allowNetworkHost returns true on success', async () => { - const { runner } = recordingRunner(() => ({ stdout: '', stderr: '', exitCode: 0 })) + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: '', exitCode: 0 })) const rt = createSbxRuntime(logger, { run: runner }) await expect(rt.allowNetworkHost('db.internal')).resolves.toBe(true) }) diff --git a/test/sandbox/shell-shim.test.ts b/test/sandbox/shell-shim.test.ts index e5830884b..9862a524e 100644 --- a/test/sandbox/shell-shim.test.ts +++ b/test/sandbox/shell-shim.test.ts @@ -12,6 +12,7 @@ import { SHIM_ENV_ENV_FILE, SHIM_ENV_HOST_SHELL, } from '../../src/sandbox/shell-shim' +import { buildSmolvmRootWrapper } from '../../src/sandbox/smolvm' import type { Logger } from '../../src/types' const logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger @@ -24,6 +25,13 @@ function cleanEnv(): NodeJS.ProcessEnv { return env } +// Guest-side payloads baked into the smolvm routing lines. smolvm exec has no +// `-w`/`--env-file`, so the shim passes the env file and `$PWD` as `bash -c` +// positionals that the payload applies in-guest. +const SMOLVM_EXEC_ENV_PAYLOAD = + 'while IFS= read -r __fe || [ -n "$__fe" ]; do [ -n "$__fe" ] && export "$__fe"; done < "$0"; cd "$1" && shift 1 && exec bash "$@"' +const SMOLVM_EXEC_PAYLOAD = 'cd "$0" && exec bash "$@"' + describe('ensureShellShim', () => { test('writes an executable shim and is idempotent', () => { const dir = mkdtempSync(join(tmpdir(), 'forge-shim-')) @@ -48,6 +56,17 @@ describe('ensureShellShim', () => { expect(readFileSync(path, 'utf-8')).toBe(buildShimScript(resolveHostShell())) }) + test('rewrites the shim when the sandbox mode changes', () => { + const dir = mkdtempSync(join(tmpdir(), 'forge-shim-')) + const path = ensureShellShim(dir, logger)! + expect(readFileSync(path, 'utf-8')).toBe(buildShimScript(resolveHostShell())) + + ensureShellShim(dir, logger, 'smolvm') + + expect(readFileSync(path, 'utf-8')).toBe(buildShimScript(resolveHostShell(), 'smolvm')) + expect(readFileSync(path, 'utf-8')).not.toContain('sbx exec') + }) + test('returns null and logs when the data dir is not writable', () => { const path = ensureShellShim('/dev/null/not-a-dir', logger) expect(path).toBeNull() @@ -156,6 +175,223 @@ describe('shim behavior (executed via sh)', () => { }) }) +describe('shim behavior (smolvm mode, executed via sh)', () => { + test('routes into smolvm machine exec with env file, cwd, and command when container env is set', () => { + const dir = mkdtempSync(join(tmpdir(), 'forge-shim-')) + const shim = join(dir, SHELL_SHIM_FILENAME) + writeFileSync(shim, buildShimScript('/bin/sh', 'smolvm'), { mode: 0o755 }) + // Fake smolvm on PATH that records its argv. PATH is binDir-only, so the + // real sudo can never be reached; the recorded wrapper is never executed. + const binDir = join(dir, 'bin') + mkdirSync(binDir) + const argsFile = join(dir, 'smolvm-args') + writeFileSync(join(binDir, 'smolvm'), `#!/bin/sh\nprintf '%s\\n' "$@" > ${argsFile}\n`, { mode: 0o755 }) + + const cwd = mkdtempSync(join(tmpdir(), 'forge-cwd-')) + const result = spawnSync(shim, ['-c', 'echo in-container'], { + cwd, + env: { + ...cleanEnv(), + PATH: binDir, + [SHIM_ENV_CONTAINER]: 'forge-loop-x', + [SHIM_ENV_ENV_FILE]: '/data/forge/sandbox-env/forge-loop-x.env', + }, + encoding: 'utf-8', + }) + + expect(result.status).toBe(0) + const argv = readFileSync(argsFile, 'utf-8').trim().split('\n') + expect(argv).toEqual([ + 'machine', + 'exec', + '--name', + 'forge-loop-x', + '--', + 'bash', + '-c', + buildSmolvmRootWrapper('bash'), + SMOLVM_EXEC_ENV_PAYLOAD, + '/data/forge/sandbox-env/forge-loop-x.env', + realpathSync(cwd), + '-c', + 'echo in-container', + ]) + }) + + test('routes into smolvm machine exec without the env-file branch when no env file is set', () => { + const dir = mkdtempSync(join(tmpdir(), 'forge-shim-')) + const shim = join(dir, SHELL_SHIM_FILENAME) + writeFileSync(shim, buildShimScript('/bin/sh', 'smolvm'), { mode: 0o755 }) + // Fake smolvm on PATH that records its argv. PATH is binDir-only, so the + // real sudo can never be reached; the recorded wrapper is never executed. + const binDir = join(dir, 'bin') + mkdirSync(binDir) + const argsFile = join(dir, 'smolvm-args') + writeFileSync(join(binDir, 'smolvm'), `#!/bin/sh\nprintf '%s\\n' "$@" > ${argsFile}\n`, { mode: 0o755 }) + + const cwd = mkdtempSync(join(tmpdir(), 'forge-cwd-')) + const result = spawnSync(shim, ['-c', 'echo in-container'], { + cwd, + env: { ...cleanEnv(), PATH: binDir, [SHIM_ENV_CONTAINER]: 'forge-loop-x' }, + encoding: 'utf-8', + }) + + expect(result.status).toBe(0) + const argv = readFileSync(argsFile, 'utf-8').trim().split('\n') + expect(argv).toEqual([ + 'machine', + 'exec', + '--name', + 'forge-loop-x', + '--', + 'bash', + '-c', + buildSmolvmRootWrapper('bash'), + SMOLVM_EXEC_PAYLOAD, + realpathSync(cwd), + '-c', + 'echo in-container', + ]) + }) + + test('executes the guest payload: env-file vars with spaces are exported and the command runs from $PWD', () => { + const dir = mkdtempSync(join(tmpdir(), 'forge-shim-')) + const shim = join(dir, SHELL_SHIM_FILENAME) + writeFileSync(shim, buildShimScript('/bin/sh', 'smolvm'), { mode: 0o755 }) + // Fake smolvm: drop the CLI prefix up to and including `--`, then exec the + // remaining command vector exactly as the machine would. + const binDir = join(dir, 'bin') + mkdirSync(binDir) + writeFileSync( + join(binDir, 'smolvm'), + `#!/bin/sh +i=1 +for a in "$@"; do + if [ "$a" = "--" ]; then + shift $i + break + fi + i=$((i+1)) +done +exec "$@" +`, + { mode: 0o755 }, + ) + // Stub sudo that fails the passwordless probe, so the payload runs + // unelevated. binDir is first in PATH, so the real sudo is unreachable. + writeFileSync(join(binDir, 'sudo'), `#!/bin/sh\nexit 1\n`, { mode: 0o755 }) + + const cwd = mkdtempSync(join(tmpdir(), 'forge-cwd-')) + const envFile = join(dir, 'loop.env') + writeFileSync(envFile, 'FORGE_VAR=a value with spaces\nFORGE_EMPTY=\n') + + const result = spawnSync( + shim, + ['-c', 'printf "value=[%s] pwd=[%s]" "$FORGE_VAR" "$PWD"'], + { + cwd, + env: { + ...cleanEnv(), + PATH: `${binDir}:${process.env.PATH}`, + [SHIM_ENV_CONTAINER]: 'forge-loop-x', + [SHIM_ENV_ENV_FILE]: envFile, + }, + encoding: 'utf-8', + }, + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('value=[a value with spaces]') + expect(result.stdout).toContain(`pwd=[${realpathSync(cwd)}]`) + }) + + test('executes the guest payload via root elevation when passwordless sudo is available', () => { + const dir = mkdtempSync(join(tmpdir(), 'forge-shim-')) + const shim = join(dir, SHELL_SHIM_FILENAME) + writeFileSync(shim, buildShimScript('/bin/sh', 'smolvm'), { mode: 0o755 }) + // Fake smolvm: drop the CLI prefix up to and including `--`, then exec the + // remaining command vector exactly as the machine would. + const binDir = join(dir, 'bin') + mkdirSync(binDir) + writeFileSync( + join(binDir, 'smolvm'), + `#!/bin/sh +i=1 +for a in "$@"; do + if [ "$a" = "--" ]; then + shift $i + break + fi + i=$((i+1)) +done +exec "$@" +`, + { mode: 0o755 }, + ) + // Stub sudo that passes the passwordless probe and records each invocation + // before execing the remaining arguments as the machine would: it drops the + // `-nE` flag and an optional `PATH=` assignment, then execs the rest. binDir + // is first in PATH, so the real sudo is unreachable. + const sudoArgsFile = join(dir, 'sudo-args') + writeFileSync( + join(binDir, 'sudo'), + `#!/bin/sh +printf '%s\\n' "$@" >> ${sudoArgsFile} +shift +[ "\${1#PATH=}" != "$1" ] && shift +exec "$@" +`, + { mode: 0o755 }, + ) + + const cwd = mkdtempSync(join(tmpdir(), 'forge-cwd-')) + const envFile = join(dir, 'loop.env') + writeFileSync(envFile, 'FORGE_VAR=a value with spaces\nFORGE_EMPTY=\n') + + const result = spawnSync( + shim, + ['-c', 'printf "value=[%s] pwd=[%s]" "$FORGE_VAR" "$PWD"'], + { + cwd, + env: { + ...cleanEnv(), + PATH: `${binDir}:${process.env.PATH}`, + [SHIM_ENV_CONTAINER]: 'forge-loop-x', + [SHIM_ENV_ENV_FILE]: envFile, + }, + encoding: 'utf-8', + }, + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('value=[a value with spaces]') + expect(result.stdout).toContain(`pwd=[${realpathSync(cwd)}]`) + // Recorded invocations: the `-nE true` probe first, then the elevation + // `-nE PATH=<...> bash -c -c `. + const argv = readFileSync(sudoArgsFile, 'utf-8').trim().split('\n') + expect(argv.slice(0, 2)).toEqual(['-nE', 'true']) + expect(argv.some((arg) => arg.startsWith('PATH='))).toBe(true) + const bashIdx = argv.findIndex( + (arg, i) => arg === 'bash' && argv[i + 1] === '-c' && argv[i + 2] === SMOLVM_EXEC_ENV_PAYLOAD, + ) + expect(bashIdx).toBeGreaterThan(0) + }) + + test('fail-closed: when a container is set but smolvm is unavailable, the command never runs on the host', () => { + const dir = mkdtempSync(join(tmpdir(), 'forge-shim-')) + const shim = join(dir, SHELL_SHIM_FILENAME) + writeFileSync(shim, buildShimScript('/bin/sh', 'smolvm'), { mode: 0o755 }) + const marker = join(dir, 'escaped') + const result = spawnSync(shim, ['-c', `touch ${marker}`], { + env: { ...cleanEnv(), PATH: dir, [SHIM_ENV_CONTAINER]: 'forge-some-loop' }, + encoding: 'utf-8', + }) + + expect(result.status).not.toBe(0) + expect(existsSync(marker)).toBe(false) + }) +}) + describe('shim content', () => { test('routes through sbx exec with no docker and no --user', () => { const script = buildShimScript('/bin/sh') @@ -165,6 +401,19 @@ describe('shim content', () => { expect(script).not.toContain('--user') expect(script).toContain('exec "${FORGE_HOST_SHELL:-/bin/sh}" "$@"') }) + + test('smolvm variant routes through smolvm machine exec with in-guest cwd and env handling', () => { + const script = buildShimScript('/bin/sh', 'smolvm') + expect(script).toContain('exec smolvm machine exec --name "$FORGE_SANDBOX_CONTAINER" -- bash -c') + expect(script).toContain(buildSmolvmRootWrapper('bash')) + expect(script).toContain(SMOLVM_EXEC_ENV_PAYLOAD) + expect(script).toContain(SMOLVM_EXEC_PAYLOAD) + expect(script).toContain('"$PWD" "$@"') + expect(script).toContain('exec "${FORGE_HOST_SHELL:-/bin/sh}" "$@"') + expect(script).not.toContain('sbx exec') + expect(script).toContain('no `-w` or `--env-file`') + expect(script).toContain('root') + }) }) describe('resolveHostShell', () => { diff --git a/test/sandbox/smolvm-runtime.test.ts b/test/sandbox/smolvm-runtime.test.ts new file mode 100644 index 000000000..151825042 --- /dev/null +++ b/test/sandbox/smolvm-runtime.test.ts @@ -0,0 +1,783 @@ +import { describe, test, expect, vi } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { spawnSync } from 'child_process' +import { + SMOLVM_INSTALL_HINT, + SMOLVM_GUEST_BOOTSTRAP, + buildSmolvmRootWrapper, + resolveSmolvmAllowHosts, + checkSmolvmAvailability, + describeSmolvmUnavailable, + normalizeSmolvmMemoryMiB, + buildSmolvmCreateArgs, + buildSmolvmStartArgs, + buildSmolvmExecArgs, + buildSmolvmDeleteArgs, + buildEnvFilePreamble, + smolvmImageTarPath, + resolveSmolvmImageArg, + createSmolvmRuntime, +} from '../../src/sandbox/smolvm' +import type { CommandRunner } from '../../src/sandbox/sbx' +import type { Logger } from '../../src/types' +import { createRecordingRunner } from '../helpers/sandbox-mocks' + +const logger: Logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } + +describe('availability', () => { + test('a --version exit 0 yields available', async () => { + const fake: CommandRunner = async () => ({ stdout: 'smolvm 0.1.0\n', stderr: '', exitCode: 0 }) + await expect(checkSmolvmAvailability(fake)).resolves.toEqual({ available: true }) + }) + + test('a missing CLI yields not-installed from an ENOENT spawn', async () => { + const fake: CommandRunner = async () => ({ stdout: '', stderr: 'spawn smolvm ENOENT', exitCode: 1 }) + await expect(checkSmolvmAvailability(fake)).resolves.toEqual({ + available: false, + reason: 'not-installed', + }) + }) + + test('any other non-zero exit yields unknown with trimmed detail', async () => { + const fake: CommandRunner = async () => ({ stdout: '', stderr: 'some error\n', exitCode: 2 }) + const result = await checkSmolvmAvailability(fake) + expect(result).toMatchObject({ available: false, reason: 'unknown' }) + if (!result.available) expect(result.detail).toBe('some error') + }) + + test('a rejecting runner yields unknown', async () => { + const fake: CommandRunner = async () => { + throw new Error('boom') + } + await expect(checkSmolvmAvailability(fake)).resolves.toEqual({ available: false, reason: 'unknown' }) + }) + + test('passes --version with a 5000ms timeout to the runner', async () => { + const { calls, runner } = createRecordingRunner(() => ({ stdout: 'smolvm 0.1.0\n', stderr: '', exitCode: 0 })) + await checkSmolvmAvailability(runner) + expect(calls[0].args).toEqual(['--version']) + expect(calls[0].opts?.timeout).toBe(5000) + }) + + test('describeSmolvmUnavailable carries the remediation strings', () => { + expect(describeSmolvmUnavailable({ available: false, reason: 'not-installed' })).toBe( + `The smolvm CLI is not installed. Install it with: ${SMOLVM_INSTALL_HINT}, then try again.`, + ) + expect(describeSmolvmUnavailable({ available: false, reason: 'unknown', detail: 'x' })).toBe( + 'Could not determine smolvm availability. x', + ) + expect(describeSmolvmUnavailable({ available: false, reason: 'unknown' })).toBe( + 'Could not determine smolvm availability. Unknown error.', + ) + expect(describeSmolvmUnavailable({ available: false, reason: 'daemon-down' })).toBe( + 'Could not determine smolvm availability. Unknown error.', + ) + }) +}) + +describe('create args', () => { + test('emits the minimal create vector for one read-write workspace', () => { + expect(buildSmolvmCreateArgs('forge-c', [{ hostDir: '/work' }])).toEqual([ + 'machine', + 'create', + '--name', + 'forge-c', + '--net', + '-v', + '/work:/work', + ]) + }) + + test('emits the fully-flagged create vector', () => { + expect( + buildSmolvmCreateArgs('forge-c', [{ hostDir: '/work' }, { hostDir: '/proj', readOnly: true }], { + image: 'oc-forge-sandbox:latest', + cpus: 4, + memMiB: 8192, + allowHosts: ['a.example', ' b.example ', ''], + }), + ).toEqual([ + 'machine', + 'create', + '--name', + 'forge-c', + '--net', + '--image', + 'oc-forge-sandbox:latest', + '--allow-host', + 'a.example', + '--allow-host', + 'b.example', + '--cpus', + '4', + '--mem', + '8192', + '-v', + '/work:/work', + '-v', + '/proj:/proj:ro', + ]) + }) + + test('suffixes read-only workspaces with :ro and leaves read-write bare', () => { + expect( + buildSmolvmCreateArgs('forge-c', [{ hostDir: '/work' }, { hostDir: '/proj', readOnly: true }]), + ).toEqual([ + 'machine', + 'create', + '--name', + 'forge-c', + '--net', + '-v', + '/work:/work', + '-v', + '/proj:/proj:ro', + ]) + }) + + test('drops empty allow-host entries and omits unset image/cpus/mem flags', () => { + expect( + buildSmolvmCreateArgs('forge-c', [{ hostDir: '/work' }], { + allowHosts: ['', ' '], + }), + ).toEqual(['machine', 'create', '--name', 'forge-c', '--net', '-v', '/work:/work']) + }) + + test('a wildcard allow-host entry drops every --allow-host flag', () => { + // smolvm resolves each --allow-host as a literal hostname, so sbx's allow-everything `**` + // would fail the create; an absent list already means unrestricted egress. + expect(resolveSmolvmAllowHosts(['**'])).toEqual([]) + expect(resolveSmolvmAllowHosts(['**', 'db.internal'])).toEqual([]) + expect(resolveSmolvmAllowHosts(['*.example.com'])).toEqual([]) + expect(resolveSmolvmAllowHosts([' db.internal ', ''])).toEqual(['db.internal']) + expect(resolveSmolvmAllowHosts(undefined)).toEqual([]) + expect(buildSmolvmCreateArgs('forge-c', [{ hostDir: '/work' }], { allowHosts: ['**'] })).not.toContain( + '--allow-host', + ) + }) + + test('throws on an empty workspace array', () => { + expect(() => buildSmolvmCreateArgs('forge-c', [])).toThrow('requires at least one workspace') + }) +}) + +describe('start/exec/delete args', () => { + test('buildSmolvmStartArgs emits the start vector', () => { + expect(buildSmolvmStartArgs('forge-c')).toEqual(['machine', 'start', '--name', 'forge-c']) + }) + + test('emits the minimal exec vector through the root wrapper', () => { + expect(buildSmolvmExecArgs('forge-c', 'ls')).toEqual([ + 'machine', + 'exec', + '--name', + 'forge-c', + '--', + 'sh', + '-c', + buildSmolvmRootWrapper('sh'), + 'ls', + ]) + }) + + test('adds -i when interactive', () => { + expect(buildSmolvmExecArgs('forge-c', 'cat', { interactive: true })).toEqual([ + 'machine', + 'exec', + '-i', + '--name', + 'forge-c', + '--', + 'sh', + '-c', + buildSmolvmRootWrapper('sh'), + 'cat', + ]) + }) + + test('buildSmolvmRootWrapper probes -nE sudo, preserves PATH and runs the inner script via $0', () => { + expect(buildSmolvmRootWrapper('sh')).toBe( + 'if sudo -nE true 2>/dev/null; then exec sudo -nE PATH="$PATH" sh -c "$0" "$@"; fi; exec sh -c "$0" "$@"', + ) + expect(buildSmolvmRootWrapper('bash')).toBe( + 'if sudo -nE true 2>/dev/null; then exec sudo -nE PATH="$PATH" bash -c "$0" "$@"; fi; exec bash -c "$0" "$@"', + ) + }) + + test('buildSmolvmDeleteArgs emits the delete vector', () => { + expect(buildSmolvmDeleteArgs('forge-c')).toEqual(['machine', 'delete', '--name', 'forge-c', '-f']) + }) +}) + +describe('resource coercion', () => { + test('normalizeSmolvmMemoryMiB converts binary units to integer MiB', () => { + expect(normalizeSmolvmMemoryMiB('8g', logger)).toBe(8192) + expect(normalizeSmolvmMemoryMiB('8GB', logger)).toBe(8192) + expect(normalizeSmolvmMemoryMiB('1024m', logger)).toBe(1024) + expect(normalizeSmolvmMemoryMiB('512k', logger)).toBe(1) + }) + + test('normalizeSmolvmMemoryMiB returns undefined for unrecognized input', () => { + expect(normalizeSmolvmMemoryMiB('lots', logger)).toBeUndefined() + expect(normalizeSmolvmMemoryMiB(undefined, logger)).toBeUndefined() + expect(normalizeSmolvmMemoryMiB('', logger)).toBeUndefined() + }) + + test('normalizeSmolvmMemoryMiB logs when ignoring unrecognized input', () => { + const log = vi.fn() + normalizeSmolvmMemoryMiB('lots', { ...logger, log }) + expect(log).toHaveBeenCalledWith('Sandbox: unrecognized --mem value "lots" ignored') + }) +}) + +describe('env file preamble', () => { + test('buildEnvFilePreamble emits the read-export loop with the path quoted', () => { + expect(buildEnvFilePreamble('/data/sandbox-env/forge-c.env')).toBe( + "while IFS= read -r __fe || [ -n \"$__fe\" ]; do [ -n \"$__fe\" ] && export \"$__fe\"; done < '/data/sandbox-env/forge-c.env'; ", + ) + }) + + test('buildEnvFilePreamble single-quote-escapes a path containing a quote', () => { + expect(buildEnvFilePreamble("/data/sandbox-env/forge-o'brien.env")).toBe( + "while IFS= read -r __fe || [ -n \"$__fe\" ]; do [ -n \"$__fe\" ] && export \"$__fe\"; done < '/data/sandbox-env/forge-o'\\''brien.env'; ", + ) + }) +}) + +describe('image arg resolution', () => { + test('smolvmImageTarPath sanitizes the ref into the store file name', () => { + expect(smolvmImageTarPath('/store', 'oc-forge-sandbox:latest')).toBe( + '/store/oc-forge-sandbox-latest.tar', + ) + expect(smolvmImageTarPath('/store', 'My Image!')).toBe('/store/my-image.tar') + }) + + test('resolveSmolvmImageArg returns the store tar path when present', () => { + const dir = mkdtempSync(join(tmpdir(), 'smolvm-store-')) + try { + writeFileSync(join(dir, 'oc-forge-sandbox-latest.tar'), 'not a tar') + expect(resolveSmolvmImageArg(dir, 'oc-forge-sandbox:latest')).toBe( + join(dir, 'oc-forge-sandbox-latest.tar'), + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('resolveSmolvmImageArg passes through a registry-qualified ref', () => { + expect(resolveSmolvmImageArg(undefined, 'docker.io/library/oc-forge-sandbox:latest')).toBe( + 'docker.io/library/oc-forge-sandbox:latest', + ) + expect(resolveSmolvmImageArg('/missing-store', 'docker.io/library/oc-forge-sandbox:latest')).toBe( + 'docker.io/library/oc-forge-sandbox:latest', + ) + }) + + test('resolveSmolvmImageArg returns null for an unbuilt local template', () => { + expect(resolveSmolvmImageArg(undefined, 'oc-forge-sandbox:latest')).toBeNull() + expect(resolveSmolvmImageArg('/missing-store', 'oc-forge-sandbox:latest')).toBeNull() + }) +}) + +describe('runtime', () => { + test('checkAvailable proxies to checkSmolvmAvailability', async () => { + const { runner } = createRecordingRunner(() => ({ stdout: 'smolvm 0.1.0\n', stderr: '', exitCode: 0 })) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.checkAvailable()).resolves.toEqual({ available: true }) + }) + + test('describeUnavailable delegates to describeSmolvmUnavailable', () => { + const rt = createSmolvmRuntime(logger) + expect(rt.describeUnavailable({ available: false, reason: 'not-installed' })).toContain( + 'The smolvm CLI is not installed', + ) + }) + + test('templateExists is true when the store tar exists and false otherwise', async () => { + const dir = mkdtempSync(join(tmpdir(), 'smolvm-data-')) + try { + const store = join(dir, 'smolvm-images') + mkdirSync(store, { recursive: true }) + writeFileSync(join(store, 'oc-forge-sandbox-latest.tar'), 'tar') + const rt = createSmolvmRuntime(logger, { dataDir: dir }) + await expect(rt.templateExists('oc-forge-sandbox:latest')).resolves.toBe(true) + await expect(rt.templateExists('oc-forge-sandbox:other')).resolves.toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('templateExists accepts a registry-qualified ref without a dataDir', async () => { + const rt = createSmolvmRuntime(logger) + await expect(rt.templateExists('docker.io/library/oc-forge-sandbox:latest')).resolves.toBe(true) + }) + + test('templateLoadHint quotes the store path with a dataDir and falls back otherwise', () => { + const dir = mkdtempSync(join(tmpdir(), 'smolvm-data-')) + try { + const rt = createSmolvmRuntime(logger, { dataDir: dir }) + expect(rt.templateLoadHint('oc-forge-sandbox:latest')).toBe( + `cp "${join(dir, 'smolvm-images', 'oc-forge-sandbox-latest.tar')}"`, + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + expect(createSmolvmRuntime(logger).templateLoadHint('oc-forge-sandbox:latest')).toBe( + 'cp /smolvm-images/', + ) + }) + + test('loadTemplate copies the tar into the store path', async () => { + const dir = mkdtempSync(join(tmpdir(), 'smolvm-data-')) + try { + const src = join(dir, 'src.tar') + writeFileSync(src, 'tar-bytes') + const rt = createSmolvmRuntime(logger, { dataDir: dir }) + await rt.loadTemplate(src, 'oc-forge-sandbox:latest') + expect(readFileSync(join(dir, 'smolvm-images', 'oc-forge-sandbox-latest.tar'), 'utf-8')).toBe('tar-bytes') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('loadTemplate throws without a dataDir', async () => { + const rt = createSmolvmRuntime(logger) + await expect(rt.loadTemplate('/tmp/t.tar', 'oc-forge-sandbox:latest')).rejects.toThrow('no image store') + }) + + test('createSandbox emits create-then-start with --net, env-dir mount, allow-host and resolved tar image', async () => { + const dir = mkdtempSync(join(tmpdir(), 'smolvm-data-')) + try { + const store = join(dir, 'smolvm-images') + mkdirSync(store, { recursive: true }) + writeFileSync(join(store, 'oc-forge-sandbox-latest.tar'), 'tar') + const { calls, runner } = createRecordingRunner() + const rt = createSmolvmRuntime(logger, { dataDir: dir, run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work' }], { + template: 'oc-forge-sandbox:latest', + resources: { memory: '8g', cpus: '4' }, + networkAllowHosts: ['db.internal'], + }) + expect(calls).toHaveLength(3) + expect(calls[0].args).toEqual([ + 'machine', + 'create', + '--name', + 'forge-c', + '--net', + '--image', + join(store, 'oc-forge-sandbox-latest.tar'), + '--allow-host', + 'db.internal', + '--cpus', + '4', + '--mem', + '8192', + '-v', + '/work:/work', + '-v', + `${join(dir, 'sandbox-env')}:${join(dir, 'sandbox-env')}:ro`, + ]) + expect(calls[0].opts?.timeout).toBe(120000) + expect(calls[1].args).toEqual(['machine', 'start', '--name', 'forge-c']) + expect(calls[1].opts?.timeout).toBe(120000) + expect(calls[2].args).toEqual([ + 'machine', + 'exec', + '--name', + 'forge-c', + '--', + 'sh', + '-c', + buildSmolvmRootWrapper('sh'), + SMOLVM_GUEST_BOOTSTRAP, + ]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('createSandbox does not append the env mount when a workspace already covers it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'smolvm-data-')) + try { + const { calls, runner } = createRecordingRunner() + const rt = createSmolvmRuntime(logger, { dataDir: dir, run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: dir }], { + template: 'docker.io/library/oc-forge-sandbox:latest', + }) + expect(calls[0].args).toEqual([ + 'machine', + 'create', + '--name', + 'forge-c', + '--net', + '--image', + 'docker.io/library/oc-forge-sandbox:latest', + '-v', + `${dir}:${dir}`, + ]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('a failed Docker bootstrap is logged but never fails the sandbox', async () => { + const { calls, runner } = createRecordingRunner((rec) => + rec.args[1] === 'exec' ? { stdout: '', stderr: 'dockerd: not found', exitCode: 1 } : { stdout: '', stderr: '', exitCode: 0 }, + ) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.createSandbox('forge-c', [{ hostDir: '/work' }])).resolves.toBeUndefined() + expect(calls[2].args[calls[2].args.length - 1]).toBe(SMOLVM_GUEST_BOOTSTRAP) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('Docker is unavailable in forge-c')) + }) + + test('the guest bootstrap no-ops without dockerd and skips an already-running daemon', () => { + expect(SMOLVM_GUEST_BOOTSTRAP).toContain('command -v dockerd >/dev/null 2>&1 || exit 0') + expect(SMOLVM_GUEST_BOOTSTRAP).toContain('docker info >/dev/null 2>&1 && exit 0') + // overlay2 cannot stack on the machine's overlay root, so the data root is the ext4 disk, + // and the socket group must match the exec user's primary group. + expect(SMOLVM_GUEST_BOOTSTRAP).toContain('--data-root=/storage/docker') + expect(SMOLVM_GUEST_BOOTSTRAP).toContain('--storage-driver=overlay2') + expect(SMOLVM_GUEST_BOOTSTRAP).toContain('--group agent') + }) + + test('the guest bootstrap fixes /etc/hosts before probing dockerd', () => { + // The image ships an empty /etc/hosts, so sudo prints "unable to resolve host" for every + // guest command unless the hostname is pinned first; the trailing `|| true` keeps this + // step from ever failing the bootstrap, so the "Docker is unavailable" log stays accurate. + const hostsLine = + 'grep -q "$(hostname)" /etc/hosts 2>/dev/null || echo "127.0.0.1 $(hostname)" | sudo -n tee -a /etc/hosts >/dev/null 2>&1 || true' + expect(SMOLVM_GUEST_BOOTSTRAP.startsWith(hostsLine)).toBe(true) + expect(SMOLVM_GUEST_BOOTSTRAP.indexOf('command -v dockerd')).toBeGreaterThan( + SMOLVM_GUEST_BOOTSTRAP.indexOf(hostsLine), + ) + }) + + test('createSandbox throws when a set template resolves to null', async () => { + const { runner } = createRecordingRunner() + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect( + rt.createSandbox('forge-c', [{ hostDir: '/work' }], { template: 'oc-forge-sandbox:latest' }), + ).rejects.toThrow('Sandbox template "oc-forge-sandbox:latest" not found in the smolvm image store') + }) + + test('createSandbox failure short-circuits before start', async () => { + const { calls, runner } = createRecordingRunner((rec) => + rec.args[1] === 'create' ? { stdout: '', stderr: 'disk full', exitCode: 1 } : { stdout: '', stderr: '', exitCode: 0 }, + ) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.createSandbox('forge-c', [{ hostDir: '/work' }])).rejects.toThrow( + 'Failed to create sandbox: disk full', + ) + expect(calls).toHaveLength(1) + }) + + test('createSandbox throws on a failed start', async () => { + const { runner } = createRecordingRunner((rec) => + rec.args[1] === 'start' ? { stdout: '', stderr: 'cannot boot', exitCode: 1 } : { stdout: '', stderr: '', exitCode: 0 }, + ) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.createSandbox('forge-c', [{ hostDir: '/work' }])).rejects.toThrow( + 'Failed to start sandbox: cannot boot', + ) + }) + + test('exec embeds the env preamble and cwd prefix', async () => { + const { calls, runner } = createRecordingRunner() + const rt = createSmolvmRuntime(logger, { run: runner }) + await rt.exec('forge-c', 'ls', { envFile: '/data/sandbox-env/forge-c.env', cwd: '/work' }) + expect(calls[0].args).toEqual([ + 'machine', + 'exec', + '--name', + 'forge-c', + '--', + 'sh', + '-c', + buildSmolvmRootWrapper('sh'), + "while IFS= read -r __fe || [ -n \"$__fe\" ]; do [ -n \"$__fe\" ] && export \"$__fe\"; done < '/data/sandbox-env/forge-c.env'; cd '/work' && ls", + ]) + expect(calls[0].opts?.timeout).toBe(120000) + }) + + test('exec without envFile or cwd runs the bare command with a custom timeout', async () => { + const { calls, runner } = createRecordingRunner() + const rt = createSmolvmRuntime(logger, { run: runner }) + await rt.exec('forge-c', 'ls', { timeout: 3000 }) + expect(calls[0].args[calls[0].args.length - 1]).toBe('ls') + expect(calls[0].opts?.timeout).toBe(3000) + }) + + test('execPipe sets interactive, passes stdin and prefixes the env preamble', async () => { + const { calls, runner } = createRecordingRunner() + const rt = createSmolvmRuntime(logger, { run: runner }) + await rt.execPipe('forge-c', 'cat', 'hello', { envFile: '/e.env' }) + expect(calls[0].args.slice(0, 4)).toEqual(['machine', 'exec', '-i', '--name']) + expect(calls[0].opts?.stdin).toBe('hello') + expect(calls[0].args[calls[0].args.length - 1]).toMatch(/^while IFS= read -r __fe/) + }) + + test('exec transparently restarts a stopped machine and retries exactly once', async () => { + let execCount = 0 + const { calls, runner } = createRecordingRunner((rec) => { + if (rec.args[1] === 'exec') { + execCount += 1 + return execCount === 1 + ? { stdout: '', stderr: 'machine forge-c is not running', exitCode: 1 } + : { stdout: 'ok', stderr: '', exitCode: 0 } + } + if (rec.args[1] === 'ls') { + return { stdout: JSON.stringify({ machines: [{ name: 'forge-c', status: 'stopped' }] }), stderr: '', exitCode: 0 } + } + return { stdout: '', stderr: '', exitCode: 0 } + }) + const rt = createSmolvmRuntime(logger, { run: runner }) + const result = await rt.exec('forge-c', 'ls') + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('ok') + expect(calls.map((c) => c.args.join(' '))).toEqual([ + `machine exec --name forge-c -- sh -c ${buildSmolvmRootWrapper('sh')} ls`, + 'machine ls --json', + 'machine start --name forge-c', + `machine exec --name forge-c -- sh -c ${buildSmolvmRootWrapper('sh')} ${SMOLVM_GUEST_BOOTSTRAP}`, + `machine exec --name forge-c -- sh -c ${buildSmolvmRootWrapper('sh')} ls`, + ]) + }) + + test('exec does not loop when the stopped retry keeps failing', async () => { + const { calls, runner } = createRecordingRunner((rec) => { + if (rec.args[1] === 'exec') return { stdout: '', stderr: 'not running', exitCode: 1 } + if (rec.args[1] === 'ls') { + return { stdout: JSON.stringify({ machines: [{ name: 'forge-c', status: 'stopped' }] }), stderr: '', exitCode: 0 } + } + return { stdout: '', stderr: '', exitCode: 0 } + }) + const rt = createSmolvmRuntime(logger, { run: runner }) + const result = await rt.exec('forge-c', 'ls') + expect(result.exitCode).toBe(1) + const commandExecs = calls.filter( + (c) => c.args.join(' ').startsWith('machine exec') && c.args[c.args.length - 1] === 'ls', + ) + expect(commandExecs).toHaveLength(2) + expect(calls.filter((c) => c.args.join(' ').startsWith('machine start')).length).toBe(1) + }) + + test('exec surfaces the original error when the failure is not a stopped machine', async () => { + const { calls, runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'permission denied', exitCode: 1 })) + const rt = createSmolvmRuntime(logger, { run: runner }) + const result = await rt.exec('forge-c', 'ls') + expect(result.exitCode).toBe(1) + expect(calls).toHaveLength(1) + }) + + test('exec does not restart when the failure message matches but the machine is running', async () => { + const { calls, runner } = createRecordingRunner((rec) => { + if (rec.args[1] === 'exec') return { stdout: '', stderr: 'process is not running', exitCode: 1 } + if (rec.args[1] === 'ls') { + return { stdout: JSON.stringify({ machines: [{ name: 'forge-c', status: 'running' }] }), stderr: '', exitCode: 0 } + } + return { stdout: '', stderr: '', exitCode: 0 } + }) + const rt = createSmolvmRuntime(logger, { run: runner }) + const result = await rt.exec('forge-c', 'ls') + expect(result.exitCode).toBe(1) + expect(calls.map((c) => c.args.join(' '))).toEqual([ + `machine exec --name forge-c -- sh -c ${buildSmolvmRootWrapper('sh')} ls`, + 'machine ls --json', + ]) + }) + + test('exec surfaces the original error when the machine is missing despite a matching message', async () => { + const { calls, runner } = createRecordingRunner((rec) => { + if (rec.args[1] === 'exec') return { stdout: '', stderr: 'machine forge-c is not running', exitCode: 1 } + if (rec.args[1] === 'ls') { + return { stdout: JSON.stringify({ machines: [] }), stderr: '', exitCode: 0 } + } + return { stdout: '', stderr: '', exitCode: 0 } + }) + const rt = createSmolvmRuntime(logger, { run: runner }) + const result = await rt.exec('forge-c', 'ls') + expect(result.exitCode).toBe(1) + expect(calls.map((c) => c.args.join(' '))).toEqual([ + `machine exec --name forge-c -- sh -c ${buildSmolvmRootWrapper('sh')} ls`, + 'machine ls --json', + ]) + }) + + test('exec throws when the restart start fails', async () => { + const { runner } = createRecordingRunner((rec) => { + if (rec.args[1] === 'exec') return { stdout: '', stderr: 'machine is stopped', exitCode: 1 } + if (rec.args[1] === 'ls') { + return { stdout: JSON.stringify({ machines: [{ name: 'forge-c', status: 'stopped' }] }), stderr: '', exitCode: 0 } + } + return { stdout: '', stderr: 'cannot start', exitCode: 1 } + }) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.exec('forge-c', 'ls')).rejects.toThrow('Failed to start sandbox: cannot start') + }) + + test('execPipe restarts a stopped machine too', async () => { + let execCount = 0 + const { calls, runner } = createRecordingRunner((rec) => { + if (rec.args[1] === 'exec') { + execCount += 1 + return execCount === 1 + ? { stdout: '', stderr: 'machine stopped', exitCode: 1 } + : { stdout: 'pipe-out', stderr: '', exitCode: 0 } + } + if (rec.args[1] === 'ls') { + return { stdout: JSON.stringify({ machines: [{ name: 'forge-c', status: 'stopped' }] }), stderr: '', exitCode: 0 } + } + return { stdout: '', stderr: '', exitCode: 0 } + }) + const rt = createSmolvmRuntime(logger, { run: runner }) + const result = await rt.execPipe('forge-c', 'cat', 'in') + expect(result.stdout).toBe('pipe-out') + expect(calls.filter((c) => c.args.join(' ').startsWith('machine start')).length).toBe(1) + }) + + test('getSandboxState maps machine ls entries to running/stopped/missing', async () => { + const stdout = JSON.stringify({ + machines: [ + { name: 'forge-a', status: 'running' }, + { name: 'forge-b', status: 'stopped' }, + ], + }) + const { calls, runner } = createRecordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.getSandboxState('forge-a')).resolves.toBe('running') + await expect(rt.getSandboxState('forge-b')).resolves.toBe('stopped') + await expect(rt.getSandboxState('forge-c')).resolves.toBe('missing') + expect(calls[0].args).toEqual(['machine', 'ls', '--json']) + expect(calls[0].opts?.timeout).toBe(5000) + }) + + test('getSandboxState reports unknown on unparseable or schema-changed output', async () => { + for (const stdout of ['not json', '123', JSON.stringify({ error: 'down' })]) { + const { runner } = createRecordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') + } + }) + + test('getSandboxState reports unknown when the ls invocation fails or throws', async () => { + const failing = createRecordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) + await expect(createSmolvmRuntime(logger, { run: failing.runner }).getSandboxState('forge-a')).resolves.toBe('unknown') + const throwing = createRecordingRunner(() => { throw new Error('smolvm exploded') }) + await expect(createSmolvmRuntime(logger, { run: throwing.runner }).getSandboxState('forge-a')).resolves.toBe('unknown') + }) + + test('getSandboxState treats empty output as an empty list so a missing sandbox can be created', async () => { + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: '', exitCode: 0 })) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.getSandboxState('forge-a')).resolves.toBe('missing') + }) + + test('listSandboxesByPrefix filters parsed machine names by prefix', async () => { + const stdout = JSON.stringify({ machines: [{ name: 'forge-a' }, { name: 'forge-b' }, { name: 'other' }] }) + const { runner } = createRecordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.listSandboxesByPrefix('forge-')).resolves.toEqual(['forge-a', 'forge-b']) + }) + + test('listSandboxesByPrefix returns [] on a failing ls', async () => { + const { runner } = createRecordingRunner(() => { throw new Error('boom') }) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.listSandboxesByPrefix('forge-')).resolves.toEqual([]) + }) + + test('removeSandbox runs the delete vector and tolerates a not-found failure', async () => { + const { calls, runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'no such machine forge-a', exitCode: 1 })) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.removeSandbox('forge-a')).resolves.toBeUndefined() + expect(calls[0].args).toEqual(['machine', 'delete', '--name', 'forge-a', '-f']) + }) + + test('removeSandbox throws on an unexpected failure', async () => { + const { runner } = createRecordingRunner(() => ({ stdout: '', stderr: 'permission denied', exitCode: 1 })) + const rt = createSmolvmRuntime(logger, { run: runner }) + await expect(rt.removeSandbox('forge-a')).rejects.toThrow('Failed to remove sandbox: permission denied') + }) + + test('allowNetworkHost logs a note and returns true (egress is applied per machine at create time)', async () => { + const log = vi.fn() + const rt = createSmolvmRuntime({ ...logger, log }) + await expect(rt.allowNetworkHost('db.internal')).resolves.toBe(true) + expect(log).toHaveBeenCalled() + }) + + test('sandboxContainerName is exposed on the runtime', () => { + const rt = createSmolvmRuntime(logger) + expect(rt.sandboxContainerName('feature/test')).toBe('forge-feature-test') + }) +}) + +describe('root wrapper as a shell script', () => { + test('falls back to running the inner script unelevated when the sudo probe fails', () => { + const dir = mkdtempSync(join(tmpdir(), 'smolvm-sudo-')) + try { + const probeLog = join(dir, 'probe.log') + writeFileSync( + join(dir, 'sudo'), + `#!/bin/sh +printf '%s\\n' "$@" > '${probeLog}' +exit 1 +`, + { mode: 0o755 }, + ) + const env = { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` } + const result = spawnSync( + 'sh', + ['-c', buildSmolvmRootWrapper('sh'), 'echo "wrapped-ran $0|$1"', 'inner-arg-0', 'inner-arg-1'], + { encoding: 'utf-8', env }, + ) + expect(result.status).toBe(0) + expect(result.stdout).toContain('wrapped-ran inner-arg-0|inner-arg-1') + // The probe reached the stub with -nE (not bare -n) so SETENV is proven before use. + expect(readFileSync(probeLog, 'utf-8').trim().split('\n')).toEqual(['-nE', 'true']) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('elevates through a passwordless sudo that records its argv and execs the rest', () => { + const dir = mkdtempSync(join(tmpdir(), 'smolvm-sudo-')) + const argvLog = join(dir, 'sudo-argv.log') + try { + writeFileSync( + join(dir, 'sudo'), + `#!/bin/sh +printf '%s\\n' "$@" > '${argvLog}' +if [ "$1" = '-nE' ]; then shift; fi +case "$1" in + PATH=*) export "$1"; shift ;; +esac +exec "$@" +`, + { mode: 0o755 }, + ) + const env = { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` } + const inner = 'echo "wrapped-ran $0|$1"' + const result = spawnSync( + 'sh', + ['-c', buildSmolvmRootWrapper('sh'), inner, 'inner-arg-0', 'inner-arg-1'], + { encoding: 'utf-8', env }, + ) + expect(result.status).toBe(0) + expect(result.stdout).toContain('wrapped-ran inner-arg-0|inner-arg-1') + expect(readFileSync(argvLog, 'utf-8').trim().split('\n')).toEqual([ + '-nE', + `PATH=${env.PATH}`, + 'sh', + '-c', + inner, + 'inner-arg-0', + 'inner-arg-1', + ]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/test/sandbox/template.test.ts b/test/sandbox/template.test.ts index 403cf2dae..1cf7cb74c 100644 --- a/test/sandbox/template.test.ts +++ b/test/sandbox/template.test.ts @@ -35,7 +35,7 @@ describe('buildAndLoadSandboxTemplate', () => { const tmp = mkdtempSync(join(tmpdir(), 'forge-tpl-')) try { const record: Array<{ command: string; args: string[] }> = [] - const loadTemplate = vi.fn(async (_tar: string) => {}) + const loadTemplate = vi.fn(async (_tar: string, _ref: string) => {}) const deps: BuildTemplateDeps = { runCommand: makeFakeRun(record), loadTemplate, @@ -50,6 +50,7 @@ describe('buildAndLoadSandboxTemplate', () => { expect(record[1].args[0]).toBe('save') expect(loadTemplate).toHaveBeenCalledTimes(1) expect(loadTemplate.mock.calls[0][0]).toMatch(/forge-sandbox-template-\d+\.tar$/) + expect(loadTemplate.mock.calls[0][1]).toBe('oc-forge-sandbox:latest') expect(leftoverTars(tmp)).toHaveLength(0) } finally { rmSync(tmp, { recursive: true, force: true }) @@ -173,14 +174,22 @@ describe('template build args and command formatter', () => { }) test('formatTemplateBuildCommands reflects default args', () => { - expect(formatTemplateBuildCommands('/ctx', 'oc-forge-sandbox:latest')).toBe( + expect(formatTemplateBuildCommands('/ctx', 'oc-forge-sandbox:latest', 'sbx template load ', undefined)).toBe( 'docker build -t oc-forge-sandbox:latest "/ctx" && docker save oc-forge-sandbox:latest -o && sbx template load ', ) }) test('formatTemplateBuildCommands reflects the browser-control build arg', () => { - expect(formatTemplateBuildCommands('/ctx', 'oc-forge-sandbox:latest', { browserControl: true })).toBe( + expect( + formatTemplateBuildCommands('/ctx', 'oc-forge-sandbox:latest', 'sbx template load ', { browserControl: true }), + ).toBe( 'docker build --build-arg INSTALL_BROWSER_CONTROL=true -t oc-forge-sandbox:latest "/ctx" && docker save oc-forge-sandbox:latest -o && sbx template load ', ) }) + + test('formatTemplateBuildCommands renders the loadHint as the final pipeline step', () => { + expect(formatTemplateBuildCommands('/ctx', 'oc-forge-sandbox:latest', 'cp /store/image.tar', undefined)).toBe( + 'docker build -t oc-forge-sandbox:latest "/ctx" && docker save oc-forge-sandbox:latest -o && cp /store/image.tar', + ) + }) })