diff --git a/AGENTS.md b/AGENTS.md index b278cf768..e3e9ac254 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,11 @@ ## Generated and bundled files - `pnpm build` rewrites `src/version.ts`, `src/dashboard/marked-source.ts`, and `src/dashboard/app-bundle.ts`. Edit `package.json`, `src/dashboard/marked.min.js`, or `src/dashboard/app/` respectively, never the generated files. -- The build does not clean `dist/`; remove stale output when deleting or renaming source modules. +- `pnpm build` removes the output directory before compiling (`scripts/build.ts`), so deleting or renaming source modules cannot leave stale output in `dist/`. +- `scripts/build.ts` runs `tsc` first, then `Bun.build` overwrites `dist/index.js` (server) and `dist/tui.js` (TUI) with self-contained bundles. Both must stay bundled so the plugin loads without resolving `node_modules`; the vendored installer mode (`bunx opencode-forge --vendor`) depends on it. `@opentui/*`, `@opencode-ai/plugin/tui`, `solid-js`, and `bun:sqlite` stay external because opencode's runtime provides them. +- `resolveShippedRoot` in `src/utils/shipped-paths.ts` is the only way to locate shipped files on disk. Never derive paths from `import.meta.url` directly: bundling collapses it, which would silently break the migration SQL loader, prompt loading, and bundled-asset sync. - Bundled prompts (`src/prompts/`) and skills (`skills/`) sync on every plugin load, preserving user edits. The standalone installer handles conflicts and orphan pruning. -- Keep section-summary markers in `src/prompts/agents/auditor-loop-addendum.md` synchronized with constants in `src/utils/section-summary.ts`. +- The section-summary block template is the single `SECTION_SUMMARY_TEMPLATE` in `src/loop/prompts.ts`, built from the marker constants in `src/utils/section-summary.ts`; do not hand-write the marker strings into prompt markdown files or other prompt builders. ## Loop runtime @@ -29,9 +31,10 @@ ## 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`. -- `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`. +- `src/sandbox/msb.ts` is the sole TypeScript runtime/lifecycle facade and `msb` CLI argument owner; route runtime operations through its `SandboxRuntime` facade. The one required exception is the generated shell shim (`src/sandbox/shell-shim.ts`), which invokes `msb exec` directly when an agent shell command must run inside a sandbox. `src/sandbox/process.ts` is the only child-process spawner; all TypeScript shell execution goes through `runCommand`. +- `buildSandboxWorkspaces` canonicalizes the host side of every mount and leaves `containerDir` as the original path. msb refuses a host path that traverses a symlink and fails the whole sandbox with `ENOTDIR`, which on macOS breaks every `os.tmpdir()` mount because `/var` is a symlink to `private/var`. Never canonicalize the container side: absolute paths handed to the agent must resolve identically inside the sandbox. +- `getSandboxState` is the only liveness primitive; five states: `running`, `stopped` (reusable, never create/evict), `transient` (real but not directly executable: `Created`/`Starting`/`Draining`/`Paused` map here), `unknown` (query failed), `missing` (may create or evict). `Stopped`/`Crashed` map to the reusable `stopped` state because `msb exec` starts them in place. `registerActiveSandbox` is the only place a usable sandbox is recorded. +- `container/Dockerfile` must derive from a plain OCI base and keep the final `USER agent`; `ENTRYPOINT`/`CMD` are ignored because msb runs `agentd` as PID 1. ## Dashboard, storage, and paths diff --git a/README.md b/README.md index 0ddc31516..b098b1348 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,24 @@ Add to your `opencode.json` to enable Forge’s server-side hooks, tools, and ag } ``` +### Plugin-directory install + +Instead of editing the `plugin` arrays by hand, the installer can wire the plugin into opencode's config directory: + +```bash +bunx opencode-forge --link # re-export shim for the current build +bunx opencode-forge --vendor # self-contained copy (portable) +``` + +From a source checkout, use `pnpm setup --link` or `pnpm setup --vendor`. Both modes also write the `tui.json` `plugin` entry automatically — opencode does not auto-load the TUI plugin from the plugin directory, so the plugin directory alone cannot enable the sidebar and execution dialog. In a non-interactive shell the flags still require `-y`, `-f`, or `-k`. + +| | `--link` | `--vendor` | +| --- | --- | --- | +| Picks up a rebuild | Yes — re-exports the live build | No — re-run after upgrade | +| Portable to another machine | No — absolute path to this checkout | Yes | +| Payload in config dir | Shim only | Full copy (~6 MB) | +| Needs re-run after upgrade | No | Yes | + As of OpenCode 1.17.8, `OPENCODE_EXPERIMENTAL_WORKSPACES=true` is required for the plugin's loop functionality to work. Set it in the environment that launches `opencode`: ```bash @@ -52,7 +70,7 @@ Forge ships two plugin entrypoints plus standalone management surfaces: - **Server plugin** — enabled through OpenCode plugin config in `opencode.json`. The package declares the `server` oc-plugin surface and exports `./server` for the server entrypoint. - **TUI plugin** — enabled separately in `tui.json`. The package declares the `tui` oc-plugin surface and exports `./tui` for the terminal UI entrypoint. -- **Installer CLI** — a standalone CLI accessible via `bunx opencode-forge` or `pnpm setup` (from a source checkout) for installing/upgrading bundled prompts and skills. +- **Installer CLI** — a standalone CLI accessible via `bunx opencode-forge` or `pnpm setup` (from a source checkout) for installing/upgrading bundled prompts and skills, and for installing the plugin itself into opencode's plugin directory (`--link`/`--vendor`/`--unlink`). - **Dashboard** — a read-only observability interface launchable from the TUI command palette (`Open dashboard`) or via `pnpm dashboard` (source checkouts only). The server plugin provides the core hooks, tools, agents, plan storage, loop orchestration, review persistence, and sandbox support. The TUI plugin layers on the sidebar and execution dialog. @@ -107,11 +125,11 @@ Execution flow dialog with mode and model selection: - **Plans** — architect authors validated plans directly into SQL storage with `plan-write`/`plan-edit` - **Execution** — approved-plan launch paths plus direct `/execute-goal` loops in dedicated worktree sessions; plan loops can also target a configured remote opencode server (see [Configuration](docs/configuration.md#remotes)); grouped execution launches features from a PRD as parallel loops -- **Loops** — iterative coding/auditing with isolated git worktree and optional sbx sandbox +- **Loops** — iterative coding/auditing with isolated git worktree and optional msb sandbox - **Review Findings** — persistent, loop-scoped review findings across loop sessions - **Group tools** — `launch-group`, `group-status`, `group-cancel` for parallel feature orchestration - **TUI** — sidebar and execution dialog -- **Sandbox** — Optional sbx worktree loop isolation with bind-mounted project files +- **Sandbox** — Optional msb worktree loop isolation with bind-mounted project files ## Agents @@ -142,9 +160,9 @@ Forge provides these tool groups: - **Plan tools** — `plan-write`, `plan-edit`, `plan-read`, `section-read`, `plan-adjust` - **Review tools** — `review-write`, `review-read`, `review-delete` - **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status` -- **Sandbox routing** — native `bash`, `glob`, and `grep` tools route into sbx for sandboxed sessions +- **Sandbox routing** — native `bash`, `glob`, and `grep` tools route into msb for sandboxed sessions -Loops always run in an isolated git worktree; sbx is used when enabled, configured, and available. +Loops always run in an isolated git worktree; msb is used when enabled, configured, and available. | Tool | Description | |------|-------------| @@ -227,7 +245,7 @@ The plugin includes a TUI sidebar widget and an execution dialog for launching p The sidebar shows Forge's connection status and version. Captured plans live on the server in the `plansRepo` SQL store; the TUI no longer keeps a local archive or in-TUI editor. -When sandboxing is configured, the sidebar displays the current session's sbx state. The `Toggle host sandbox` palette command, and optional `tui.keybinds.toggleHostSandbox` binding, enable or disable sandbox routing for the current session and its Task subagents. The TUI also follows replacement code and auditor sessions when a loop rotates, but does not follow unrelated subagent sessions. +When sandboxing is configured, the sidebar displays the current session's msb state. The `Toggle host sandbox` palette command, and optional `tui.keybinds.toggleHostSandbox` binding, enable or disable sandbox routing for the current session and its Task subagents. The TUI also follows replacement code and auditor sessions when a loop rotates, but does not follow unrelated subagent sessions. ### Additional Commands @@ -251,7 +269,7 @@ Choose from three execution modes: 1. **New session** — Creates a fresh Code session and sends the plan as the initial prompt 2. **Execute here** — Takes over the current session immediately with the plan -3. **Loop** — Prompts the architect to launch an iterative coding/auditing loop via the `execute-plan` tool in an isolated git worktree (sbx is used when enabled, configured, and available) +3. **Loop** — Prompts the architect to launch an iterative coding/auditing loop via the `execute-plan` tool in an isolated git worktree (msb is used when enabled, configured, and available) #### Model Selection @@ -353,7 +371,7 @@ After the architect presents a summary, the user chooses an execution mode from - **New session** — Creates a new Code session and sends the plan as the initial prompt. - **Execute here** — The code agent takes over the current session immediately with the plan. -- **Loop** — The architect is prompted to launch an iterative coding/auditing loop via the `execute-plan` tool, which creates an isolated git worktree and provisions sbx when enabled, configured, and available. +- **Loop** — The architect is prompted to launch an iterative coding/auditing loop via the `execute-plan` tool, which creates an isolated git worktree and provisions msb when enabled, configured, and available. | Mode | When to choose it | |------|-------------------| @@ -401,7 +419,7 @@ Loop sessions rotate between code and auditor work, so Forge persists per-sessio ### Worktree Isolation -Loops always run in an isolated git worktree. Sandbox is optional and enabled only when the `sbx` daemon is available and configured (`sandbox.mode = 'sbx'`): when available, a sandbox is provisioned automatically alongside the worktree; otherwise the loop runs in worktree-only mode. Changes are auto-committed and the worktree is removed on completion (branch preserved for later merge). +Loops always run in an isolated git worktree. Sandbox is optional and controlled by `sandbox.enabled` (default `true`) with driver `sandbox.mode = 'msb'`: when enabled, a sandbox is provisioned automatically alongside the worktree. If the `msb` CLI is missing or the host cannot run microVMs, sandbox startup fails and the loop start is rolled back — it never silently falls back to the host. Set `sandbox.enabled: false` to run worktree-only. Changes are auto-committed and the worktree is removed on completion (branch preserved for later merge). ### Auditor Integration @@ -490,7 +508,7 @@ All worktree-based execution paths require a git repository with at least one ro When a worktree loop starts with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`, forge: 1. Calls `experimental.workspace.create` with `type: "forge"`, `branch: null`, and `extra: { loopName, projectDirectory, workspaceCreatedAt }` to register the workspace through the `forge` adapter -2. The adapter's `create` hook creates the git worktree (reusing an orphaned branch when possible) and, when configured, provisions the sbx sandbox +2. The adapter's `create` hook creates the git worktree (reusing an orphaned branch when possible) and, when configured, provisions the msb sandbox 3. Creates a new Code session pointed at the worktree directory 4. Calls `experimental.workspace.warp` to bind the session to that workspace 5. Persists the workspace ID on the loop record (`loops.workspace_id`) so the TUI can route clicks on a loop into the correct workspace @@ -526,29 +544,29 @@ Worktree loops require a git repository with at least one commit. OpenCode scope ## Sandbox -Run loop iterations inside an isolated `sbx` sandbox when the `sbx` daemon is available and configured. Sandbox is optional: when `sbx` is enabled, Forge provisions a loop sandbox automatically; otherwise loops run in worktree-only mode. +Run loop iterations inside an isolated `msb` sandbox. Sandbox is optional and controlled by `sandbox.enabled` (default `true`) with driver `sandbox.mode = 'msb'`: when enabled, Forge provisions a loop sandbox automatically. If the `msb` CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop is rolled back rather than silently falling back to the host; set `sandbox.enabled: false` to run worktree-only. -See [Sandbox](docs/sandbox.md) for setup, native in-sandbox Docker, network access, environment passthrough, custom bind mounts, large-output handling, and resource defaults. +See [Sandbox](docs/sandbox.md) for setup, host requirements, image building and loading, network access, environment passthrough and secrets, custom bind mounts, large-output handling, and resource defaults. ### Prerequisites -- The `sbx` CLI installed and authenticated (`sbx login`), with the `sbx` daemon running (`sbx daemon start`) on a supported platform (macOS 14+ Apple silicon, Windows 11 with Hypervisor Platform, or Ubuntu 24.04+ with KVM). -- Docker, used only to build the sandbox template. +- The `msb` CLI installed — no account or login step. Install with `curl -fsSL https://install.microsandbox.dev | sh` and verify with `msb doctor` on a supported platform (Linux with KVM, macOS on Apple silicon, or Windows 11 with Windows Hypervisor Platform). +- Docker, required on the host only to build the sandbox image (the msb runtime itself does not need it; Docker *inside* the sandbox is a separate in-image stack). - OpenCode >= 1.15.5 — sandbox shell routing relies on the session-aware `shell.env` plugin hook. Enforced via `engines.opencode`, so older versions refuse to load the plugin rather than silently running sandbox commands on the host. (Loops additionally require OpenCode >= 1.17.8 for workspace integration, see [Requirements](#requirements).) ### Setup -**1. Build and load the sandbox template:** +**1. Build and load the sandbox image:** ```bash docker build -t oc-forge-sandbox:latest container/ docker save oc-forge-sandbox:latest -o forge-sandbox.tar -sbx template load forge-sandbox.tar +msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest ``` -The default image includes Node.js (NodeSource current channel), pnpm, Bun, Python 3 + uv, ripgrep, git, and jq. Chromium and Browser Control are an opt-in image feature: set `sandbox.imageFeatures.browserControl` to `true`, then run `Build sandbox template` from the command palette to rebuild and load the configured image tag. +The default image includes Node.js (NodeSource current channel), pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a full Docker Engine (docker-ce, CLI, containerd, Buildx, and Compose from Docker's official apt repo) that runs natively inside the microVM — `docker run`, `docker build`, and `docker compose` all work in-sandbox. The daemon is started on demand by `forge-dockerd-start` (msb boots its own `agentd` as PID 1 and ignores the image's entrypoint, so nothing runs dockerd at boot); `/var/lib/docker` is backed by a dedicated block device because overlayfs cannot run on a virtiofs mount. The built image is roughly 1.65 GB. Chromium and Browser Control are an opt-in image feature: set `sandbox.imageFeatures.browserControl` to `true`, then run `Build sandbox template` from the command palette to rebuild and load the configured image tag. -The `container/Dockerfile` ships with the plugin package. If the template is missing when OpenCode starts, Forge shows a warning toast with a "Build sandbox template" command in the palette. You can also trigger the build from the command palette at any time by searching for `Build sandbox template`, which opens a confirmation dialog and runs the build/save/load sequence automatically. +The `container/Dockerfile` ships with the plugin package. If the image is missing when OpenCode starts, Forge shows a warning toast with a "Build sandbox template" command in the palette. You can also trigger the build from the command palette at any time by searching for `Build sandbox template`, which opens a confirmation dialog and runs the build/save/load sequence automatically. Restart OpenCode after changing sandbox configuration. diff --git a/container/Dockerfile b/container/Dockerfile index 8f84ee1f8..c2a5f6361 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,15 +1,20 @@ -# Forge sandbox image, derived from Docker's official sbx shell template. -# Deriving (rather than building from scratch) preserves the labels, default -# `agent` user, entrypoint and command contract that `sbx` expects from a -# -t/--template image; the shell-docker flavour already ships a working -# in-sandbox Docker Engine, so no Docker installation is needed here. -FROM docker.io/docker/sandbox-templates:shell-docker - -# The base template runs as a non-root `agent` user (with sudo). System-level -# package installs below run as root; the final USER directive returns to the -# agent user so `sbx exec` commands run unprivileged, matching the template -# contract. The sandbox manager does not pass `-u` to `sbx exec`. +# Forge sandbox image, built on a plain Ubuntu base for the microsandbox (msb) +# runtime. msb boots this image as a microVM with its own kernel and runs its +# own `agentd` as PID 1, so the image's ENTRYPOINT/CMD are never executed. +# Docker Engine therefore runs natively inside the microVM; since nothing runs +# an entrypoint at boot, `forge-dockerd-start` (installed below) is the way to +# bring the daemon up. +FROM ubuntu:24.04 + +# Create the runtime user explicitly: the plain Ubuntu base ships a default +# `ubuntu` user holding uid/gid 1000, which is removed so the `agent` user +# (uid/gid 1000, home /home/agent) replaces it, matching the built-in user the +# Docker template used to provide. System-level installs below run as root; the +# final USER directive returns to `agent` so `msb exec` commands run +# unprivileged. The sandbox manager does not pass `-u` to `msb exec`. USER root +RUN userdel -r ubuntu 2>/dev/null || true; groupdel ubuntu 2>/dev/null || true; \ + useradd --create-home --shell /bin/bash --uid 1000 --user-group agent RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ @@ -22,10 +27,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ unzip \ && rm -rf /var/lib/apt/lists/* -# Guarantee passwordless root elevation for the `agent` user, independent of the -# base template's sudo configuration, so system-wide package installs (apt, gem, -# npm -g, pip) work inside sandbox loops. `sbx` commands stay unprivileged as -# `agent`; installs use an explicit `sudo` prefix. +# Guarantee passwordless root elevation for the `agent` user so system-wide +# package installs (apt, gem, npm -g, pip) work inside sandbox loops. `msb exec` +# commands stay unprivileged as `agent`; installs use an explicit `sudo` prefix. RUN apt-get update && apt-get install -y --no-install-recommends sudo \ && echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/forge-agent \ && chmod 0440 /etc/sudoers.d/forge-agent \ @@ -59,7 +63,7 @@ RUN curl -fsSL https://astral.sh/uv/install.sh | bash \ # # npm_config_store_dir pins pnpm's content-addressable store to a # container-internal path. Without it, pnpm places the store on the project -# filesystem (the sbx-mounted project directory), which floods the host file +# filesystem (the msb-mounted project directory), which floods the host file # watcher and is slow over the macOS bind mount. ENV HOME=/opt/forge \ XDG_CACHE_HOME=/opt/forge/.cache \ @@ -78,11 +82,87 @@ RUN mkdir -p /opt/forge/.cache /opt/forge/.local/share/pnpm/store /opt/forge/.np # The trailing chmod is load-bearing: this global install runs as root and populates the # pnpm store (store/v10/{files,index,projects}) with root-owned 0755 dirs, AFTER the earlier # `chmod -R 0777 /opt/forge`. Because the container runs as root but agent commands run as the -# template's `agent` user via `sbx exec`, that UID could not write into the root-owned store and +# image's `agent` user via `msb exec`, that UID could not write into the root-owned store and # `pnpm install` failed with EACCES when registering the project — which previously drove the -# agent to relocate the store onto the sbx-mounted project directory (huge, slow file transfers). +# agent to relocate the store onto the msb-mounted project directory (huge, slow file transfers). # Re-asserting 0777 here, as the last build step that touches /opt/forge, keeps the store # writable by any exec UID. Any future build step that runs pnpm as root must do the same. + +# Docker Engine from Docker's official apt repository. The microVM boots a real +# kernel, so the daemon runs natively — no privileged-container or nested- +# virtualization tricks. `/var/lib/docker` is mounted as a dedicated msb block +# device and public egress is the default, so `docker run` and registry pulls +# work out of the box. The `agent` user joins the `docker` group for socket +# access without sudo; starting the daemon itself stays root-only via the +# passwordless sudo granted above. Installed before the INSTALL_BROWSER_CONTROL +# toggle so rebuilding with that flag on does not re-fetch the Docker stack. +RUN install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \ + && usermod -aG docker agent \ + && rm -rf /var/lib/apt/lists/* + +# msb ignores ENTRYPOINT/CMD, so nothing starts dockerd at boot. This idempotent +# helper is the single way to ensure the daemon is running inside a sandbox: it +# starts dockerd detached (setsid, root-owned, log at /var/log/dockerd.log, stdin +# from /dev/null) and waits up to 60s for `docker info` to succeed, then exits +# non-zero with the log tail if the daemon never becomes ready. An flock +# serializes concurrent invocations so two simultaneous calls cannot start two +# daemons. It re-execs itself under the passwordless sudo rule above when called +# unprivileged, so `agent` can invoke it directly. +RUN cat > /usr/local/bin/forge-dockerd-start <<'EOF' && chmod 0755 /usr/local/bin/forge-dockerd-start +#!/bin/sh +set -u + +lock=/var/run/forge-dockerd.lock +log=/var/log/dockerd.log +ready_timeout=60 + +# Already serving: nothing to do. `agent` is in the docker group, so this probe +# needs no elevation. +docker info >/dev/null 2>&1 && exit 0 + +# The daemon, its lock and its log all require root, so re-exec once under the +# passwordless sudo rule. This keeps the command callable as plain +# `forge-dockerd-start` from an unprivileged sandbox session. +if [ "$(id -u)" -ne 0 ]; then + exec sudo -n "$0" "$@" +fi + +# Serialize concurrent starts so only one daemon is ever launched. +exec 9>"$lock" +flock 9 || exit 1 + +# Someone else may have brought the daemon up while we waited for the lock. +docker info >/dev/null 2>&1 && exit 0 + +# A daemon is already starting but not yet answering: wait for it below. +if [ -f /var/run/docker.pid ] && kill -0 "$(cat /var/run/docker.pid)" 2>/dev/null; then + : +else + # Launch detached. `9>&-` closes the inherited lock fd in the daemon so the + # flock is released when this script exits instead of being held open for + # the daemon's lifetime. + setsid dockerd >"$log" 2>&1 &- & +fi + +elapsed=0 +while [ "$elapsed" -lt "$ready_timeout" ]; do + if docker info >/dev/null 2>&1; then + exit 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) +done + +echo "dockerd did not become ready within ${ready_timeout}s; tail of $log:" >&2 +tail -n 20 "$log" 2>/dev/null >&2 +exit 1 +EOF + ARG INSTALL_BROWSER_CONTROL=false RUN pnpm add -g fallow@latest --global-bin-dir /usr/local/bin \ @@ -97,9 +177,9 @@ RUN pnpm add -g fallow@latest --global-bin-dir /usr/local/bin \ fi \ && chmod -R 0777 /opt/forge -# Allow git to operate on the sbx-mounted project directory regardless of owner UID. +# Allow git to operate on the msb-mounted project directory regardless of owner UID. RUN git config --system --add safe.directory '*' -# Return to the template's default user. `sbx exec` (without `-u`) runs commands -# as this user, so agent commands are unprivileged by default. +# Return to the image's default user. `msb exec` (without `-u`) runs commands as +# this user, so agent commands are unprivileged by default. USER agent diff --git a/docs/api/README.md b/docs/api/README.md index b0b252d20..b6ffb30f9 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -41,20 +41,40 @@ Add to your `opencode.json` to enable Forge’s server-side hooks, tools, and ag } ``` +### Plugin-directory install + +Instead of editing the `plugin` arrays by hand, the installer can wire the plugin into opencode's config directory: + +```bash +bunx opencode-forge --link # re-export shim for the current build +bunx opencode-forge --vendor # self-contained copy (portable) +``` + +From a source checkout, use `pnpm setup --link` or `pnpm setup --vendor`. Both modes also write the `tui.json` `plugin` entry automatically — opencode does not auto-load the TUI plugin from the plugin directory, so the plugin directory alone cannot enable the sidebar and execution dialog. In a non-interactive shell the flags still require `-y`, `-f`, or `-k`. + +| | `--link` | `--vendor` | +| --- | --- | --- | +| Picks up a rebuild | Yes — re-exports the live build | No — re-run after upgrade | +| Portable to another machine | No — absolute path to this checkout | Yes | +| Payload in config dir | Shim only | Full copy (~6 MB) | +| Needs re-run after upgrade | No | Yes | + As of OpenCode 1.17.8, `OPENCODE_EXPERIMENTAL_WORKSPACES=true` is required for the plugin's loop functionality to work. Set it in the environment that launches `opencode`: ```bash export OPENCODE_EXPERIMENTAL_WORKSPACES=true ``` -Without this, Forge cannot create loop worktrees and `execute-plan` / `/execute-plan` will fail. See [Common Issues](#common-issues) and [Workspace Integration](#workspace-integration) for details. +Without this, Forge cannot create loop worktrees, so plan loops, goal loops, TUI Loop launches, and grouped execution will fail. See [Common Issues](#common-issues) and [Workspace Integration](#workspace-integration) for details. ## What Forge Adds -Forge ships two user-facing surfaces: +Forge ships two plugin entrypoints plus standalone management surfaces: - **Server plugin** — enabled through OpenCode plugin config in `opencode.json`. The package declares the `server` oc-plugin surface and exports `./server` for the server entrypoint. - **TUI plugin** — enabled separately in `tui.json`. The package declares the `tui` oc-plugin surface and exports `./tui` for the terminal UI entrypoint. +- **Installer CLI** — a standalone CLI accessible via `bunx opencode-forge` or `pnpm setup` (from a source checkout) for installing/upgrading bundled prompts and skills, and for installing the plugin itself into opencode's plugin directory (`--link`/`--vendor`/`--unlink`). +- **Dashboard** — a read-only observability interface launchable from the TUI command palette (`Open dashboard`) or via `pnpm dashboard` (source checkouts only). The server plugin provides the core hooks, tools, agents, plan storage, loop orchestration, review persistence, and sandbox support. The TUI plugin layers on the sidebar and execution dialog. @@ -69,7 +89,7 @@ The server plugin provides the core hooks, tools, agents, plan storage, loop orc ## Dashboard -Forge includes a read-only observability Dashboard — a standalone Bun HTTP server (`src/dashboard/`) that serves a SolidJS single-page app at `GET /` and JSON state at `GET /api/data`. Launch it from the TUI command palette (`Open dashboard`) or via `pnpm dashboard`. The dashboard **never mutates** loop, workspace, or storage state. By default it binds loopback only. Set `dashboard.host` / `dashboard.port` in `forge-config.jsonc` to expose it on a LAN or VPN — see [Configuration](_media/configuration.md#dashboard). The dashboard has **no authentication**, so a non-loopback bind must be protected at the network layer. +Forge includes a read-only observability Dashboard — a standalone Bun HTTP server (`src/dashboard/`) that serves a SolidJS single-page app at `GET /` and JSON state at `GET /api/data`. Launch it from the TUI command palette (`Open dashboard`) or via `pnpm dashboard` (source checkouts only). The dashboard **never mutates** loop, workspace, or storage state. By default it binds loopback only. Set `dashboard.host` / `dashboard.port` in `forge-config.jsonc` to expose it on a LAN or VPN — see [Configuration](_media/configuration.md#dashboard). The dashboard has **no authentication**, so a non-loopback bind must be protected at the network layer. ### Views @@ -107,11 +127,12 @@ Execution flow dialog with mode and model selection: ## Features - **Plans** — architect authors validated plans directly into SQL storage with `plan-write`/`plan-edit` -- **Execution** — approved-plan launch paths plus direct `/execute-goal` loops in dedicated worktree sessions; plan loops can also target a configured remote opencode server (see [Configuration](_media/configuration.md#remotes)) -- **Loops** — iterative coding/auditing with isolated git worktree and optional sbx sandbox +- **Execution** — approved-plan launch paths plus direct `/execute-goal` loops in dedicated worktree sessions; plan loops can also target a configured remote opencode server (see [Configuration](_media/configuration.md#remotes)); grouped execution launches features from a PRD as parallel loops +- **Loops** — iterative coding/auditing with isolated git worktree and optional msb sandbox - **Review Findings** — persistent, loop-scoped review findings across loop sessions +- **Group tools** — `launch-group`, `group-status`, `group-cancel` for parallel feature orchestration - **TUI** — sidebar and execution dialog -- **Sandbox** — Optional sbx worktree loop isolation with bind-mounted project files +- **Sandbox** — Optional msb worktree loop isolation with bind-mounted project files ## Agents @@ -141,9 +162,9 @@ Forge provides these tool groups: - **Plan tools** — `plan-write`, `plan-edit`, `plan-read`, `section-read`, `plan-adjust` - **Review tools** — `review-write`, `review-read`, `review-delete` - **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status` -- **Sandbox shell** — `sh` when a sandbox manager is available +- **Sandbox routing** — native `bash`, `glob`, and `grep` tools route into msb for sandboxed sessions -Loops always run in an isolated git worktree; sbx sandbox is used automatically when available. +Loops always run in an isolated git worktree; msb is used when enabled, configured, and available. | Tool | Description | |------|-------------| @@ -164,6 +185,7 @@ Loops always run in an isolated git worktree; sbx sandbox is used automatically | `/execute-goal` | Execute a free-text goal in dedicated worktree sessions until an audit leaves no findings | code | | `/loop-status` | Check status of all active loops | code | | `/loop-cancel` | Cancel the active loop | code | +| `/launch-group` | Decompose a PRD or feature list into features and launch them as parallel planning + development loops | code | ## Configuration @@ -225,9 +247,21 @@ The plugin includes a TUI sidebar widget and an execution dialog for launching p The sidebar shows Forge's connection status and version. Captured plans live on the server in the `plansRepo` SQL store; the TUI no longer keeps a local archive or in-TUI editor. +When sandboxing is configured, the sidebar displays the current session's msb state. The `Toggle host sandbox` palette command, and optional `tui.keybinds.toggleHostSandbox` binding, enable or disable sandbox routing for the current session and its Task subagents. The TUI also follows replacement code and auditor sessions when a loop rotates, but does not follow unrelated subagent sessions. + +### Additional Commands + +The TUI also registers these commands: + +| Command | Description | +|---------|-------------| +| `Toggle host sandbox` | Enable or disable sandbox for the current session | +| `Build sandbox template` | Build, save, and load the sandbox template image | +| `Open dashboard` | Start the Forge dashboard and open it in a browser | + ### Execution Dialog -Open the dialog from the command palette as `Execute plan` (default keybind `f`). The plan is sourced from the stored plan for the current session, so the dialog shows exactly what `execute-plan` would run. Legacy chat capture remains available for backward compatibility when no stored row exists; new plans should always be authored with `plan-write`. If no plan can be resolved, the dialog will not open and you'll see a toast asking the architect to produce one first. +Open the dialog from the command palette as `Execute plan` (default keybind `f`). The plan is sourced from the stored plan for the current session, so the dialog shows exactly what `execute-plan` would run. Legacy chat capture remains available for backward compatibility when no stored row exists; new plans should always be authored with `plan-write`. If no plan can be resolved, a toast prompts the user and the dialog falls back to a paste-input prompt so a plan can be entered manually. A separate command, `Execute pasted plan`, opens the paste dialog directly. The dialog provides full control over execution parameters: @@ -237,7 +271,7 @@ Choose from three execution modes: 1. **New session** — Creates a fresh Code session and sends the plan as the initial prompt 2. **Execute here** — Takes over the current session immediately with the plan -3. **Loop** — Prompts the architect to launch an iterative coding/auditing loop via the `execute-plan` tool in an isolated git worktree (sbx sandbox used automatically when available) +3. **Loop** — Prompts the architect to launch an iterative coding/auditing loop via the `execute-plan` tool in an isolated git worktree (msb is used when enabled, configured, and available) #### Model Selection @@ -247,15 +281,15 @@ Two model selectors are available: - Opens a full model selection dialog with all available providers - Shows recently used models for quick access (derived from your OpenCode sessions, recent Forge loops, OpenCode favorites, and the global default) - Displays model capabilities (reasoning, tools support) in descriptions -- Defaults to the most recent Forge loop's selection, falling back to `config.executionModel` +- Defaults to `config.executionModel`, then the most recent Forge loop's selection, then the platform default **Auditor Model:** - Same model selection interface -- Defaults to the most recent Forge loop's auditor selection, falling back to `config.auditorModel` → `config.executionModel` +- Defaults to `config.auditorModel`, then `config.executionModel`, then the most recent Forge loop's auditor or execution model, then the platform default #### Persistence -Selections live on the **OpenCode server**, not in a TUI-local cache. Every loop execution stamps the chosen execution + auditor model (and variants) into `workspace.create.extra.forgeLoop`, and the next time the dialog opens it derives defaults and recents from `workspace.list()` plus the session list. This means the picker is correct even when the TUI runs on a different host than the OpenCode server. +Selections live on the **OpenCode server**, not in a TUI-local cache. Loops launched from the TUI execution dialog stamp the chosen execution and auditor models (and variants) into `workspace.create.extra.forgeLoop`; later dialogs derive defaults and recents from `workspace.list()` plus the session list. This keeps the picker correct when the TUI and OpenCode server run on different hosts. The dialog tracks only loop-mode executions for recents / last-used defaults; `New session` and `Execute here` modes do not create a workspace, so they do not contribute to recents. @@ -274,7 +308,7 @@ Add to your `~/.config/opencode/tui.json` or project-level `tui.json`: } ``` -### Model Selection Dialog +### Model Picker Organization The TUI provides a comprehensive model selection dialog when executing plans. The dialog features: @@ -310,7 +344,7 @@ TUI options are configured in `~/.config/opencode/forge-config.jsonc` under the } ``` -Set `sidebar` to `false` to completely disable the widget. +Set `sidebar` to `false` to disable the widget, Forge client connection, plan-execution commands, and execution dialog. Session-rotation following plus the dashboard, sandbox-template build, and host-sandbox toggle commands remain available. For local development, reference the built TUI file directly: @@ -339,7 +373,7 @@ After the architect presents a summary, the user chooses an execution mode from - **New session** — Creates a new Code session and sends the plan as the initial prompt. - **Execute here** — The code agent takes over the current session immediately with the plan. -- **Loop** — The architect is prompted to launch an iterative coding/auditing loop via the `execute-plan` tool, which creates an isolated git worktree and provisions an sbx sandbox when available. +- **Loop** — The architect is prompted to launch an iterative coding/auditing loop via the `execute-plan` tool, which creates an isolated git worktree and provisions msb when enabled, configured, and available. | Mode | When to choose it | |------|-------------------| @@ -351,33 +385,7 @@ The dialog also lets you pick the execution model, auditor model, and their opti For New session and Execute here, execution is immediate — there are no additional LLM calls between approval and execution. The system intercepts the user's approval answer, reads the cached plan, and dispatches it programmatically to the code agent. The architect never processes the approval response. For Loop mode, the architect is instead instructed to launch the loop via the `execute-plan` tool. -### Model Selection Priority - -Model and variant selection follows this priority order: - -**For execution model:** -1. In-session dialog override (instance lifetime) -2. `config.executionModel` -3. Last-used (per-project workspace) -4. Platform default - -**For auditor model:** -1. In-session override -2. `config.auditorModel` -3. `config.executionModel` (inherit) -4. Last-used workspace -5. Platform default - -**For execution variant:** -1. In-session override -2. `config.executionVariant` -3. Last-used workspace - -**For auditor variant:** -1. In-session override -2. `config.auditorVariant` -3. Last-used workspace - *(independent — does not inherit the execution variant)* +For grouped execution, the `launch-group` slash command orchestrates parallel feature extraction: a PRD or feature list is split into implementation-coherent features by the `feature-splitter` agent, each feature is planned by the `architect-auto` agent, and each warning-free plan runs as its own loop within a concurrency cap. The group tools (`launch-group`, `group-status`, `group-cancel`) are agent-invoked only (no slash commands beyond `/launch-group`). ### Troubleshooting @@ -413,26 +421,48 @@ Loop sessions rotate between code and auditor work, so Forge persists per-sessio ### Worktree Isolation -Loops always run in an isolated git worktree. Sandbox is optional: when the `sbx` daemon is available and `sandbox.mode = 'sbx'` is configured, a sandbox is provisioned automatically; otherwise the loop runs in worktree-only mode. Changes are auto-committed and the worktree is removed on completion (branch preserved for later merge). +Loops always run in an isolated git worktree. Sandbox is optional and controlled by `sandbox.enabled` (default `true`) with driver `sandbox.mode = 'msb'`: when enabled, a sandbox is provisioned automatically alongside the worktree. If the `msb` CLI is missing or the host cannot run microVMs, sandbox startup fails and the loop start is rolled back — it never silently falls back to the host. Set `sandbox.enabled: false` to run worktree-only. Changes are auto-committed and the worktree is removed on completion (branch preserved for later merge). ### Auditor Integration -After each coding iteration, the auditor agent reviews changes against project conventions and stored review findings. Findings are persisted via `review-write` scoped to the current loop. Outstanding `severity: 'bug'` findings block completion — the loop terminates only when the auditor has run at least once and zero bug-severity findings remain. +After each coding iteration, the auditor agent reviews changes against project conventions and stored review findings. Findings are persisted via `review-write` scoped to the current loop. Outstanding findings return a dirty audit to coding; the loop terminates only when the auditor has run at least once and no findings remain. + +### Section Lifecycle + +Sectioned (plan) loops execute the plan milestone by milestone. A plan is decomposed into sections at loop start (one-time preprocessing), with a hard cap of **24 sections** (`MAX_TOTAL_SECTIONS`); markers past the cap are dropped rather than merged. The loop then advances through sections via clean section audits during the `auditing` phase. Each section is coded and audited in sequence — dirty section audits rotate back to coding for the same section. + +When all sections are clean, the loop enters `final_auditing`, which audits the entire accumulated diff. Outstanding final-audit findings rotate the loop to a `final_audit_fix` coding pass without rewinding a section; it then returns to `final_auditing` for verification. A clean final audit triggers completion or the configured `post_action` phase. + +During a section audit, the auditor may amend the plan via `plan-adjust`: revise the section under audit in place and/or replace the pending section suffix. The plan objective and verification criteria are immutable, already-completed sections cannot be changed, and the resulting total is capped at 24 sections. If an amendment appends sections while in `final_auditing`, the loop reverts to `auditing` to execute them. ### Stall Detection -A watchdog monitors loop activity. If no progress is detected within `stallTimeoutMs` (default: 60s), the current phase is re-triggered. After `maxConsecutiveStalls` consecutive stalls (default: 5), the loop terminates with reason `stall_timeout`. Use `loop-status` with `restart` to resume from the persisted section/iteration. +Two stall timeouts guard against wedged sessions: + +- **`stallTimeoutMs`** (default: 60 s) — recovers a missing or non-busy session status after the ordinary activity window expires. +- **`busyStallTimeoutMs`** (default: 15 m) — bounds how long a busy session may emit neither tool activity nor streamed content. It is measured across the loop session and its subagents; on expiry Forge aborts and continues the phase with a nudge. + +Each recovery counts toward `maxConsecutiveStalls` (default: 5); exhausting the limit terminates with `stall_timeout`. Use `loop-status` with `restart` to resume from the persisted section and iteration. ### Model Configuration -Loops use the following priority order for model selection: +Model and variant selection follows this priority order (first match wins): -1. **In-session dialog override** — Changed in the execution dialog (instance lifetime) -2. `config.executionModel` — Global execution model fallback -3. Last-used workspace — Previously selected model for the project -4. Platform default — OpenCode's default model +**For execution model:** +1. In-session dialog override (instance lifetime) +2. `config.executionModel` +3. Last-used workspace preference +4. Platform default -The auditor model follows a similar chain: in-session override → `config.auditorModel` → `config.executionModel` (inherit) → last-used workspace → platform default. Variants follow their own priority (see [Model Selection Priority](#model-selection-priority)). +**For auditor model:** +1. In-session dialog override (instance lifetime) +2. `config.auditorModel` +3. `config.executionModel` +4. Last-used auditor model +5. Last-used execution model +6. Platform default + +Variants use override → matching config value → last-used workspace value. The auditor variant does not inherit the execution variant. When launching from the TUI dialog, your selection is remembered and pre-filled on subsequent launches. The dialog also allows selecting a separate model for the auditor phase. @@ -454,12 +484,10 @@ The loop terminates when any of these conditions is met: - **Max iterations** — The global `maxIterations` cap is exceeded (0 = unlimited). - **Stall timeout** — After `maxConsecutiveStalls` consecutive stalls (default: 5). Use `loop-status` with `restart` to resume from the persisted section and iteration. -- **Final audit completion** — When no bug-severity review findings remain after the final audit phase. If `loop.postAction.enabled` is `true`, the loop enters the `post_action` phase before final termination. +- **Final audit completion** — The auditor has run at least once and leaves **zero open review findings of any severity** (`bug` or `warning`). If `loop.postAction.enabled` is `true`, the loop enters the `post_action` phase before final termination. - **Post-action completion** — After a clean final audit and a successful post-completion action phase (if configured). - **Consecutive errors** — 3 consecutive errors in either phase. -Loops always run in an isolated git worktree. Sandbox is optional: when the `sbx` daemon is available and `sandbox.mode = 'sbx'` is configured, a sandbox is provisioned automatically; otherwise the loop runs in worktree-only mode. - ## Workspace Integration Forge worktree loops register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything. @@ -468,19 +496,21 @@ Forge worktree loops register as **OpenCode workspaces**, letting you switch bet Workspace integration requires the **experimental workspace runtime** enabled in OpenCode. See [Quick Start](#quick-start) for the environment variable setup. No forge config option enables or disables this — the toggle is purely on the OpenCode side and must be present before OpenCode starts. +All worktree-based execution paths require a git repository with at least one root commit: `execute-plan` (Loop mode), `execute-goal` (`/execute-goal`), TUI Loop execution dialog launches, grouped execution (`/launch-group`), and group restarts all check for the root commit before creating worktrees, sessions, or group state. If OpenCode started before the initial commit, it resolves the project as `global`; create the commit, restart OpenCode, and retry. + > The `OPENCODE_EXPERIMENTAL_WORKSPACES` flag is not currently documented on opencode.ai. The authoritative source is `packages/core/src/flag/flag.ts` and `packages/opencode/src/effect/runtime-flags.ts` in the OpenCode repo. ### When workspace integration is active - **Env var set, OpenCode ≥ 1.17.8** → Forge can create the worktree workspace, bind loop sessions to it, and show the loop as a switchable workspace in the TUI. -- **Env var unset or older OpenCode** → `experimental.workspace.create` is unavailable or no-ops, Forge cannot create the loop worktree, and `execute-plan` / `/execute-plan` fails before iteration starts. +- **Env var unset or older OpenCode** → `experimental.workspace.create` is unavailable or no-ops, Forge cannot create the loop worktree, and `execute-plan` / `/execute-plan`, `execute-goal`, TUI Loop launches, and `/launch-group` all fail before iteration starts. ### What it does When a worktree loop starts with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`, forge: 1. Calls `experimental.workspace.create` with `type: "forge"`, `branch: null`, and `extra: { loopName, projectDirectory, workspaceCreatedAt }` to register the workspace through the `forge` adapter -2. The adapter's `create` hook creates the git worktree (reusing an orphaned branch when possible) and, when configured, provisions the sbx sandbox +2. The adapter's `create` hook creates the git worktree (reusing an orphaned branch when possible) and, when configured, provisions the msb sandbox 3. Creates a new Code session pointed at the worktree directory 4. Calls `experimental.workspace.warp` to bind the session to that workspace 5. Persists the workspace ID on the loop record (`loops.workspace_id`) so the TUI can route clicks on a loop into the correct workspace @@ -498,13 +528,13 @@ If initial workspace creation fails at startup — env var unset, OpenCode versi ## Common Issues -### `execute-plan` / `/execute-plan` fails to start +### Worktree execution fails to start **Most common cause:** `OPENCODE_EXPERIMENTAL_WORKSPACES=true` was not set in the environment that launched OpenCode. See [Quick Start](#quick-start) for setup. Symptoms include: -- `execute-plan` or `/execute-plan` returns an internal error before the first coding session starts +- A plan loop, goal loop, TUI Loop launch, or feature group returns an internal error before its first coding session starts - Forge logs contain `createBuiltinWorktreeWorkspace: workspace.create threw`, `workspace.create returned no workspace id`, or `handleStartLoop: failed to create builtin worktree workspace` - No loop worktree appears in the TUI workspace switcher @@ -516,29 +546,29 @@ Worktree loops require a git repository with at least one commit. OpenCode scope ## Sandbox -Run loop iterations inside an isolated `sbx` sandbox. Sandbox is optional: when the `sbx` daemon is available and configured, Forge provisions a loop sandbox automatically; otherwise loops run in worktree-only mode. +Run loop iterations inside an isolated `msb` sandbox. Sandbox is optional and controlled by `sandbox.enabled` (default `true`) with driver `sandbox.mode = 'msb'`: when enabled, Forge provisions a loop sandbox automatically. If the `msb` CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop is rolled back rather than silently falling back to the host; set `sandbox.enabled: false` to run worktree-only. -See [Sandbox](_media/sandbox.md) for setup, native in-sandbox Docker, network access, environment passthrough, custom bind mounts, large-output handling, and resource defaults. +See [Sandbox](_media/sandbox.md) for setup, host requirements, image building and loading, network access, environment passthrough and secrets, custom bind mounts, large-output handling, and resource defaults. ### Prerequisites -- The `sbx` CLI installed and authenticated (`sbx login`), with the `sbx` daemon running (`sbx daemon start`) on a supported platform (macOS 14+ Apple silicon, Windows 11 with Hypervisor Platform, or Ubuntu 24.04+ with KVM). -- Docker, used only to build the sandbox template. +- The `msb` CLI installed — no account or login step. Install with `curl -fsSL https://install.microsandbox.dev | sh` and verify with `msb doctor` on a supported platform (Linux with KVM, macOS on Apple silicon, or Windows 11 with Windows Hypervisor Platform). +- Docker, required on the host only to build the sandbox image (the msb runtime itself does not need it; Docker *inside* the sandbox is a separate in-image stack). - OpenCode >= 1.15.5 — sandbox shell routing relies on the session-aware `shell.env` plugin hook. Enforced via `engines.opencode`, so older versions refuse to load the plugin rather than silently running sandbox commands on the host. (Loops additionally require OpenCode >= 1.17.8 for workspace integration, see [Requirements](#requirements).) ### Setup -**1. Build and load the sandbox template:** +**1. Build and load the sandbox image:** ```bash docker build -t oc-forge-sandbox:latest container/ docker save oc-forge-sandbox:latest -o forge-sandbox.tar -sbx template load forge-sandbox.tar +msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest ``` -The image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, and jq. +The default image includes Node.js (NodeSource current channel), pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a full Docker Engine (docker-ce, CLI, containerd, Buildx, and Compose from Docker's official apt repo) that runs natively inside the microVM — `docker run`, `docker build`, and `docker compose` all work in-sandbox. The daemon is started on demand by `forge-dockerd-start` (msb boots its own `agentd` as PID 1 and ignores the image's entrypoint, so nothing runs dockerd at boot); `/var/lib/docker` is backed by a dedicated block device because overlayfs cannot run on a virtiofs mount. The built image is roughly 1.65 GB. Chromium and Browser Control are an opt-in image feature: set `sandbox.imageFeatures.browserControl` to `true`, then run `Build sandbox template` from the command palette to rebuild and load the configured image tag. -The `container/Dockerfile` ships with the plugin package. If the template is missing when OpenCode starts, Forge shows a warning toast with a "Build sandbox template" command in the palette. You can also trigger the build from the command palette at any time by searching for `Build sandbox template`, which opens a confirmation dialog and runs the build/save/load sequence automatically. +The `container/Dockerfile` ships with the plugin package. If the image is missing when OpenCode starts, Forge shows a warning toast with a "Build sandbox template" command in the palette. You can also trigger the build from the command palette at any time by searching for `Build sandbox template`, which opens a confirmation dialog and runs the build/save/load sequence automatically. Restart OpenCode after changing sandbox configuration. diff --git a/docs/api/_media/architecture.md b/docs/api/_media/architecture.md index 017f7cfed..7d3578f5f 100644 --- a/docs/api/_media/architecture.md +++ b/docs/api/_media/architecture.md @@ -67,7 +67,7 @@ The codebase is organized into these module groups under `src/`: | `hooks/` | Plugin event/lifecycle hooks (session, loop events, plan capture, plan approval, watchdog, sandbox, forge-session-attach, loop-permission, host-side-effects) | `index.ts`, `session.ts`, `loop.ts`, `plan-capture.ts`, `plan-approval.ts`, `watchdog.ts`, `sandbox-tools.ts`, `forge-session-attach.ts`, `loop-permission.ts`, `host-side-effects.ts` | | `loop/` | Core loop state machine and runtime | `runtime.ts`, `service.ts`, `state.ts`, `transitions.ts`, `prompts.ts`, `restartability.ts`, `in-flight-guard.ts`, `token-usage.ts`, `name-uniqueness.ts` | | `services/` | Higher-level orchestration services | `execution.ts`, `session-loop-resolver.ts`, `deterministic-decomposer.ts`, `plan-capture.ts`, `worktree-log.ts` | -| `sandbox/` | sbx sandbox management | `sbx.ts`, `manager.ts`, `context.ts`, `reconcile.ts` | +| `sandbox/` | msb sandbox management | `msb.ts`, `manager.ts`, `context.ts`, `reconcile.ts` | | `storage/` | SQLite persistence layer (repos + migrations) | `database.ts`, `repos/*.ts`, `migrations/*.sql` | | `tools/` | Plugin tools callable by AI agents | `loop.ts`, `review.ts`, `plan-kv.ts`, `section-read.ts` | | `workspace/` | Git worktree / workspace management | `forge-adapter.ts`, `forge-worktree.ts`, `pending-teardown.ts`, `classify-stale.ts`, `remove-with-context.ts`, `sweep-stale.ts` | @@ -96,30 +96,36 @@ See [loop-system.md](loop-system.md) for detailed documentation. ## Sandbox System -Sandbox is optional. When the `sbx` daemon is available and `sandbox.mode = 'sbx'` is configured, a sandbox is provisioned automatically; otherwise loops run in worktree-only mode. +Sandbox is optional and controlled by `sandbox.enabled` (default `true`) with driver `sandbox.mode = 'msb'`. When enabled, a sandbox is provisioned automatically. If the `msb` CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop is rolled back rather than falling back to the host; set `sandbox.enabled: false` to run worktree-only. ### Components -- **SandboxRuntime** (`sandbox/sbx.ts`) - `sbx` CLI facade (create/exec/remove/list, availability probe) +- **SandboxRuntime** (`sandbox/msb.ts`) - `msb` CLI facade (create/exec/remove/list, availability probe) - **SandboxManager** (`sandbox/manager.ts`) - Sandbox lifecycle management - **SandboxContext** (`sandbox/context.ts`) - Tool call redirection - **SandboxTools** (`hooks/sandbox-tools.ts`) - Hooks for sandbox integration +- **Shell shim** (`sandbox/shell-shim.ts`) - Generated shim routing the native `bash` tool through `msb exec` +- **Shell env hook** (`hooks/shell-env.ts`) - Injects the sandbox container into each shell spawn ### How It Works -1. When a sandbox loop starts, an `sbx` sandbox is created +1. When a sandbox loop starts, an `msb` sandbox is created 2. The worktree directory is mounted at its identical host path inside the sandbox -3. `bash` runs inside the sandbox; `glob` and `grep` results are produced inside the sandbox +3. Shell commands and search tools run inside the sandbox: `bash` through the generated shell shim, `glob` and `grep` through the sandbox tool hooks — both backed by `msb exec` 4. File operations (`read`, `write`, `edit`) operate on the host directly 5. On loop completion, the sandbox is stopped and removed +### State Model + +The sandbox state model has five states. `running` and `stopped` are both usable: msb suspends idle microVMs to `stopped` and `msb exec` resumes them in place, so forge never recreates a merely-stopped sandbox. `transient` covers msb's `Created`/`Starting`/`Draining`/`Paused` statuses — real but not directly executable, and never collapsed into `unknown`. `unknown` means the state query failed and says nothing about the sandbox, so forge fails closed and refuses to create or remove on that basis. `missing` is the one confirmed-absent state, and the only one in which forge creates a sandbox. + ### Tool Redirection `bash` and the search tools reach the sandbox through two different mechanisms: - **`bash`** is redirected out of band, not through a tool hook. The `config` hook points `cfg.shell` at the `forge-shell` shim (`sandbox/shell-shim.ts`) and the `shell.env` hook - injects `FORGE_SANDBOX_CONTAINER`. The shim `exec`s `sbx exec -w "$PWD" bash "$@"`. + injects `FORGE_SANDBOX_CONTAINER`. The shim `exec`s `msb exec --quiet "$FORGE_SANDBOX_CONTAINER" --no-tty -w "$PWD" -- bash "$@"`. Tool arguments are never rewritten. - **`glob` and `grep`** use output replacement. `tool.execute.before` runs the equivalent `rg` command inside the container and stores the result by `callID`; `tool.execute.after` @@ -205,7 +211,7 @@ The plugin follows this initialization sequence within `createForgePlugin()`: 1. **Logger** - Always first (`createLogger()`) 2. **v2 Client** - Create OpenCode v2 SDK client for API calls -3. **Sandbox Manager** - sbx sandbox management (optional, fails gracefully) +3. **Sandbox Manager** - msb sandbox management (optional, fails gracefully) 4. **Pending Teardown Registry** - Track worktree teardown contexts 5. **Workspace Status Registry** - Track workspace connected/disconnected state 6. **Workspace Adapter** - Register forge workspace adapter if experimental workspace API available @@ -250,7 +256,7 @@ graph TD end LoopRuntime --> SandboxManager["Sandbox Manager"] - SandboxManager --> Sbx["sbx Sandbox"] + SandboxManager --> Msb["msb Sandbox"] SQLite --> LoopsRepo["Loops Repo"] SQLite --> PlansRepo["Plans Repo"] diff --git a/docs/api/_media/configuration.md b/docs/api/_media/configuration.md index 6d4607315..a78a4b8bc 100644 --- a/docs/api/_media/configuration.md +++ b/docs/api/_media/configuration.md @@ -134,7 +134,7 @@ The written file is added to the worktree's git exclude so it never appears in ` Notes: - The written file is ephemeral. Forge deletes its own `opencode.jsonc` before any teardown commit (and the whole worktree is removed on completion), so it can never land in loop history — even if the git-exclude write failed. A repository-tracked `opencode.jsonc` is never deleted (forge did not write it). Because the file is removed at teardown, a restarted loop is rewritten from the current `loop.worktreeOpencodeConfig`, so edits take effect on the next run. -- MCP servers declared here run as **host** processes from the worktree directory. When [Sandbox](sandbox.md) is enabled, only `bash`/`glob`/`grep` execute inside the sandbox; the MCP commands themselves are not sandbox-isolated. To run an MCP server *inside* the loop's sandbox, use the placeholder below with an `sbx exec -i` command. +- MCP servers declared here run as **host** processes from the worktree directory. When [Sandbox](sandbox.md) is enabled, only `bash`/`glob`/`grep` execute inside the sandbox; the MCP commands themselves are not sandbox-isolated. To run an MCP server *inside* the loop's sandbox, use the placeholder below with an `msb exec` command. - The string `{{FORGE_SANDBOX_CONTAINER}}` in any config value is replaced with the loop's sandbox container name (`forge-`) when the file is written. For loops without a sandbox, `mcp` entries referencing the placeholder are dropped instead, so the same config works with and without the sandbox. ## Group Launch @@ -206,7 +206,7 @@ Example: | `remotes[].password` | unset | Basic-auth password (`OPENCODE_SERVER_PASSWORD` on the remote). Omit when the remote runs without auth. Stored in plaintext in this config file. | | `remotes[].username` | `"opencode"` | Basic-auth username (`OPENCODE_SERVER_USERNAME` default). | | `remotes[].gitRemote` | `"origin"` | Git remote name, configured on **both** machines' clones, used for code sync. | -| `remotes[].sandbox` | `true` | Whether the remote loop runs sandboxed. Must mirror the remote server's actual `sandbox.enabled`/sbx capability — see below. | +| `remotes[].sandbox` | `true` | Whether the remote loop runs sandboxed. Must mirror the remote server's actual `sandbox.enabled`/msb capability — see below. | Example: @@ -244,16 +244,50 @@ 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.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.enabled` | `true` | Enable sandboxed execution. When enabled and the msb CLI or host virtualization is unavailable, sandbox startup fails rather than falling back to the host; set `false` to run worktree-only. | +| `sandbox.mode` | `"msb"` | Sandbox mode. `msb` is currently the only supported mode. A stale `"mode": "sbx"` from an older install is reported as a migration warning in the log and, when running in the TUI, as a toast. | +| `sandbox.image` | `"oc-forge-sandbox:latest"` | msb image reference used for sandboxed execution. | +| `sandbox.imageFeatures.browserControl` | `false` | Include Chromium, the Browser Control CLI/MCP server, and its extension when building the bundled sandbox image. Rebuild the image after changing it. | +| `sandbox.resources.memory` | `"8g"` | Memory the sandbox boots with (`msb create -m`). | +| `sandbox.resources.maxMemory` | unset | Boot-time ceiling for hotpluggable memory (`msb create --max-memory`). Unset pins the sandbox at `memory`; msb rejects a value below `memory`. | +| `sandbox.resources.cpus` | `"4"` | CPU count the sandbox boots with (`msb create -c`; integer-only). | +| `sandbox.resources.maxCpus` | unset | Boot-time ceiling for virtual CPUs (`msb create --max-cpus`; integer-only). Unset pins the sandbox at `cpus`; msb rejects a value below `cpus`. | +| `sandbox.resources.dockerDisk` | `"16g"` | Size of the dedicated block device backing the sandbox's in-VM Docker Engine data dir (`/var/lib/docker`, `--mount-named ...:kind=disk,size=`). The disk is sparse, so the generous default costs no real disk up front. | | `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.env` | `[]` | Host environment variables to pass into each sandbox command via the env file. | +| `sandbox.network.allow` | `[]` | Egress allow-list applied at create time. Restriction is opt-in: an empty list, or a list containing the `*`/`**` allow-all wildcard, passes no network flags and msb's default allows all public egress; configuring any concrete host flips the sandbox to deny-by-default (`--net-default deny`) with one `--net-rule allow@` per validated host. | +| `sandbox.network.env` | `[]` | Host environment variables to inject into the sandbox at create time as bare names (values never appear on forge's command line). | +| `sandbox.network.secrets` | `[]` | Host-held credentials bound at create time. Each entry names a host env var and the hosts allowed to receive its real value; the value never enters the guest. The named variable must be exported in the environment that launches opencode — a bound secret with a missing variable breaks every sandboxed shell command. | + +### Sandbox network egress + +`sandbox.network.allow`, plus the destination hosts of any configured secrets, controls the sandbox's outbound access. Restriction is opt-in: when nothing is configured — or the allow list contains `*`/`**` — forge passes no network flags and msb's own default applies, so all public egress is allowed. A wildcard entry anywhere in the list makes the whole list unrestricted, overriding any narrower entries in the same list. When at least one concrete host or secret destination is configured and no allow-all wildcard is present, forge flips the sandbox to deny-by-default with `--net-default deny` plus one `--net-rule allow@` per validated host. + +- Invalid host entries are skipped and logged rather than failing the loop launch. Verified rejections include commas (a comma separates whole rule tokens, not hosts), port-qualified hosts (colon), `@`, a suffix with fewer than two labels (`*.example.com` is valid, `*.com` is rejected), and a bare single-label hostname (use `domain=myhost`). `domain=` and `suffix=` forms pass through. A wildcard in a secret's destination hosts stays invalid — those hosts declare where that secret may be sent, not global egress policy. +- If every configured host is invalid, forge emits `--net-default deny` with no allow rules and logs that egress is fully denied — it deliberately does not fall back to allow-all. +- DNS is gateway-mediated and needs no rule; the old `--net-rule allow@dns` form was rejected by msb 0.6.8, and its presence made every sandbox creation fail. +- The host's loopback interface remains unreachable from inside the sandbox: `host.microsandbox.internal` and the gateway IP are both blocked, because the private range is not part of msb's `public` egress group. +- Egress rules cannot be changed on a live sandbox (`msb modify` has no `--net-rule`), so a newly configured host requires recreating the sandbox. + +### Sandbox secrets + +Credentials that should never be readable inside the guest belong in `sandbox.network.secrets`, not `env`. msb keeps a host-side source reference, exposes a `$MSB_` placeholder inside the sandbox, and substitutes the real value only for the listed hosts at the network boundary: + +```jsonc +{ + "sandbox": { + "network": { + "secrets": [ + { "env": "GITHUB_TOKEN", "hosts": ["api.github.com"] } + ] + } + } +} +``` + +Each named variable must be exported in the environment that **launches opencode**. Once a secret is bound, every `msb exec` fails with `invalid config: secret : host environment variable is not set` if the variable is absent from the invoking process's environment; because the shell shim inherits opencode's environment, a missing variable breaks every sandboxed shell command. Forge logs an explicit warning naming the variable. + +Adopting an existing sandbox (for example after a plugin restart) converges the bound secrets with `msb modify` exactly once per adoption per plugin instance: `--secret @` refreshes the current value of every configured entry, and `--secret-rm ` drops entries that are no longer configured. A refresh failure blocks adoption without marking the sandbox converged, so a later startup can retry. The previous per-sandbox plaintext env file under `/sandbox-env/` is gone. ## Bundled Assets & Installer @@ -294,3 +328,61 @@ pnpm setup # from a checkout | `--no-prune` | Only report orphaned files; never delete them. | Without a flag the installer is interactive: for each conflicting file it offers overwrite / keep / diff, and for each orphan it offers delete / keep. When you choose **keep** on a conflict, the manifest is updated so future startup syncs continue to preserve your version. + +### Plugin-directory install + +The installer can also write the plugin itself into opencode's plugin directory, instead of hand-editing the `plugin` arrays: + +| Flag | Behavior | +|---|---| +| `--link` | Writes `/plugin/opencode-forge.js`, a one-line re-export shim whose target is the absolute path of the current build's `dist/index.js`. Because the shim re-exports the live build, a rebuild is picked up on the next opencode start with no reinstall. The shim is tied to that checkout path, so it is not portable to another machine. | +| `--vendor` | Copies `package.json`, `forge-config.jsonc`, `dist/`, `container/`, and `skills/` into `/plugin/opencode-forge/` (~6 MB) and writes the shim with the relative target `./opencode-forge/dist/index.js`. The whole config folder becomes self-contained and can be version-controlled and moved to another machine. Requires re-running after an upgrade. | +| `--unlink` | Removes the shim, the vendored directory, and the `tui.json` entry. | + +From a source checkout the same flags are `pnpm setup --link`, `pnpm setup --vendor`, and `pnpm setup --unlink`. In a non-interactive shell, `--link` and `--vendor` still require one of `-y`, `-f`, or `-k`, matching every other non-interactive use of the installer. + +Both modes also write the `plugin` entry into `tui.json` (see [Server vs TUI loading](#server-vs-tui-loading)). + +#### Resolved layout + +`--link` leaves only the shim in the config dir: + +```text +/ +├── plugin/ +│ └── opencode-forge.js # export { default } from "/abs/path/to/dist/index.js" +└── tui.json # plugin: ["/abs/path/to/dist/tui.js"] +``` + +`--vendor` copies the whole package: + +```text +/ +├── plugin/ +│ ├── opencode-forge.js # export { default } from "./opencode-forge/dist/index.js" +│ └── opencode-forge/ +│ ├── package.json +│ ├── forge-config.jsonc +│ ├── dist/ +│ │ ├── index.js +│ │ └── tui.js +│ ├── container/ +│ └── skills/ +└── tui.json # plugin: ["./plugin/opencode-forge/dist/tui.js"] +``` + +The vendored copy mirrors the npm package layout rather than being "just dist": forge resolves its bundled assets as siblings of its package root (`container/`, `skills/`, `forge-config.jsonc`), so the sandbox template and the bundled skill sync resolve inside the vendored copy. + +#### Server vs TUI loading + +opencode auto-loads server plugins from the config dir by globbing `{plugin,plugins}/*.{ts,js}`. Both the singular `plugin/` and plural `plugins/` directory names work. The scan is not recursive and does not match `.mjs`, which is why the installer uses a top-level shim file and keeps the vendored payload in a subdirectory — the payload itself is never scanned. + +That scan serves the server plugin surface only. The TUI surface is loaded exclusively from the `plugin` array in `tui.json`; there is no TUI directory scan. This is why both modes write a `tui.json` entry, and why the plugin directory alone cannot enable the sidebar and execution dialog. Path specs in a config file resolve relative to that config file's own directory, which is what makes the vendored `./plugin/opencode-forge/dist/tui.js` entry portable. + +#### Double-loading + +Local (`file://`) plugin specs dedup by exact file URL, while npm specs dedup by package name. So keeping a `plugin` array entry for forge AND installing the shim makes opencode initialize forge twice under the same id `oc-forge`. The installer detects an existing forge entry in the global `opencode.json`/`opencode.jsonc`; when run interactively it offers to comment the entry out, and in non-interactive mode it warns and changes nothing. + +#### Verification + +`opencode debug config` prints the resolved config. Its `plugin` array should list the shim's `file://` URL exactly once, with no duplicate forge entry. diff --git a/docs/api/_media/loop-system.md b/docs/api/_media/loop-system.md index e0c4d9220..901c44cde 100644 --- a/docs/api/_media/loop-system.md +++ b/docs/api/_media/loop-system.md @@ -76,7 +76,7 @@ interface LoopState { completedAt?: string // ISO timestamp worktree?: boolean // Whether using worktree isolation modelFailed?: boolean // Whether model error occurred - sandbox?: boolean // Whether using sbx sandbox + sandbox?: boolean // Whether using msb sandbox sandboxContainer?: string // Sandbox name if sandboxed completionSummary?: string // Summary of loop completion executionModel?: string // Model used for execution @@ -173,7 +173,7 @@ Outstanding `severity: 'bug'` findings block loop completion — the loop termin ## Worktree Isolation -Loops always run in an isolated git worktree. Sandbox is optional: when the `sbx` daemon is available and `sandbox.mode = 'sbx'` is configured, a sandbox is provisioned automatically; otherwise the loop runs in worktree-only mode. +Loops always run in an isolated git worktree. Sandbox is optional and controlled by `sandbox.enabled` (default `true`) with driver `sandbox.mode = 'msb'`: when enabled, a sandbox is provisioned automatically. If the `msb` CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop start is rolled back rather than silently falling back to the host; set `sandbox.enabled: false` to run worktree-only. Worktree loops require a repository with at least one commit. If OpenCode started before the initial commit, it resolves the project as `global`; create the commit, restart OpenCode, and retry. Forge rejects `execute-plan` loop mode, `execute-goal`, local or remote TUI loop launch, and feature-group launch/restart before creating workspaces, sessions, or group state when this precondition is not met. @@ -200,7 +200,7 @@ Benefits of worktree isolation: ## Sandbox Integration -Sandbox is optional. When the `sbx` daemon is available and configured, a sandbox is provisioned automatically; otherwise loops run in worktree-only mode. +Sandbox is optional and controlled by `sandbox.enabled` (default `true`): when enabled, a sandbox is provisioned automatically. If the `msb` CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop is rolled back rather than falling back to the host; set `sandbox.enabled: false` to run worktree-only. 1. Sandbox created with the worktree mounted at its identical host path 2. `bash`, `glob`, `grep` tools redirect into the sandbox diff --git a/docs/api/_media/sandbox.md b/docs/api/_media/sandbox.md index 976693350..30306ee8a 100644 --- a/docs/api/_media/sandbox.md +++ b/docs/api/_media/sandbox.md @@ -1,29 +1,34 @@ # 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 `msb` sandbox — a microVM booted by the [microsandbox](https://microsandbox.dev) CLI — 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). ## Prerequisites -- The `sbx` CLI installed and authenticated. Run `sbx login` to authenticate. -- The `sbx` daemon (`sandboxd`) running. Start it with `sbx daemon start` if it is not already up. -- A platform the `sbx` daemon supports: macOS 14+ on Apple silicon, Windows 11 with Hypervisor Platform, or Ubuntu 24.04+ with KVM. -- Docker, used only to build the sandbox template (see below). +- The `msb` CLI installed — there is **no account and no login step**. Install with: + ```bash + curl -fsSL https://install.microsandbox.dev | sh + ``` + Verify the host is ready with `msb doctor` (an alias of `msb self doctor`), which checks the hypervisor prerequisites. +- A host that can run microVMs: Linux with KVM, macOS on Apple silicon, or Windows 11 with Windows Hypervisor Platform. +- Docker on the host, used only to build the sandbox image (see below) — the msb runtime itself does not need it. -The daemon serializes its work behind in-flight sandbox commands, so `sbx daemon status` and `sbx ls` can take seconds while several loops are running. Forge bounds those queries at 30s and treats a query that does not answer as *indeterminate* rather than "daemon down": it logs and continues, letting the actual sandbox operation report the authoritative error. Only a daemon that answers definitively (or a missing CLI) fails a loop launch with remediation advice. +Forge probes availability with `msb doctor` bounded at 30s. A probe that does not answer is treated as *indeterminate* rather than "daemon down": Forge logs and continues, letting the actual sandbox operation report the authoritative error. Only a host that answers definitively (or a missing CLI) fails a loop launch with remediation advice. -Build and load the bundled template: +Build and load the bundled image: ```bash docker build -t oc-forge-sandbox:latest container/ docker save oc-forge-sandbox:latest -o forge-sandbox.tar -sbx template load forge-sandbox.tar +msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest ``` -The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a native Docker daemon inside each sandbox. +`msb load` registers the archive under the tag Forge looks up (`sandbox.image`, default `oc-forge-sandbox:latest`); list loaded images with `msb images --format json`. -The sandbox image grants the `agent` user passwordless sudo, so loops can install whatever software they need at runtime. `sbx` commands stay unprivileged as `agent` (keeping host-mapped worktree files owned by the host user), so system-wide installs use an explicit `sudo` prefix, for example `sudo apt-get install ruby`. +The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and Docker Engine (see [Nested Docker](#nested-docker)). + +The sandbox image grants the `agent` user passwordless sudo, so loops can install whatever software they need at runtime. Commands arrive via `msb exec` without `-u`, so they run as the image's `USER agent` (keeping host-mapped worktree files owned by the host user); system-wide installs use an explicit `sudo` prefix, for example `sudo apt-get install ruby`. ### Browser Control (opt-in) @@ -39,7 +44,7 @@ Chromium and Browser Control add a substantial browser payload, so they are excl } ``` -Then run `Build sandbox template` from the command palette. Changing the option does not modify an already-loaded template; rebuild it explicitly. The equivalent manual Docker build is: +Then run `Build sandbox template` from the command palette. Changing the option does not modify an already-loaded image; rebuild it explicitly. The equivalent manual Docker build is: ```bash docker build \ @@ -47,7 +52,7 @@ docker build \ -t oc-forge-sandbox:latest \ container/ docker save oc-forge-sandbox:latest -o forge-sandbox.tar -sbx template load forge-sandbox.tar +msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest ``` The resulting image provides `browser-control`, `browser-control-mcp`, Chromium as `chromium`, and the unpacked extension at `/opt/browser-control-extension`. @@ -82,23 +87,23 @@ Sandbox loops use opencode's native `bash` tool — streaming output, truncation > Requires opencode >= 1.15.5 (the session-aware `shell.env` plugin hook). Enforced via the `engines.opencode` field in Forge's package.json: older opencode versions refuse to load the plugin instead of silently running sandbox loop commands on the host. 1. Forge points opencode's `shell` config at a generated shim (`/forge-shell`). -2. On every bash tool call, Forge's `shell.env` hook resolves the session. Sessions belonging to an active sandbox loop, or to the acknowledged host-session selection, get `FORGE_SANDBOX_CONTAINER` injected; descendants such as Task-tool subagents inherit the same routing. The shim then runs the command via `sbx exec -w "$PWD" bash`. +2. On every bash tool call, Forge's `shell.env` hook resolves the session. Sessions belonging to an active sandbox loop, or to the acknowledged host-session selection, get `FORGE_SANDBOX_CONTAINER` injected; descendants such as Task-tool subagents inherit the same routing. The shim then runs the command via `msb exec --quiet "$FORGE_SANDBOX_CONTAINER" --no-tty -w "$PWD" -- bash "$@"`. 3. Sessions with no expected sandbox get no container env, and the shim execs the host shell unchanged (respecting a user-configured `shell` via `FORGE_HOST_SHELL`). Active loop routing always takes precedence over host-session preference. -The shim fails closed: if the sandbox is expected but `sbx exec` fails (or the loop sandbox cannot be restored), the command errors — it never silently runs on the host. +The shim fails closed: if the sandbox is expected but `msb exec` fails (or the loop sandbox cannot be restored), the command errors — it never silently runs on the host. `msb exec` propagates the guest command's exit code verbatim, so the bash tool keeps seeing real exit statuses. ## Tool Behavior | Tool category | Behavior in a sandboxed session | |---|---| | Shell | Native `bash` tool, executed inside the loop sandbox via the shell shim. | -| Search tools | `glob` and `grep` route through the `sbx exec` execution hooks. | +| Search tools | `glob` and `grep` route through the `msb exec` execution hooks. | | File tools | `read`, `write`, and `edit` operate on the host filesystem. | | Git operations managed by Forge | Worktree commits, cleanup, and branch management are handled on the host. | ## Network Access -The `sbx` network proxy is deny-by-default: outbound access is blocked except for hosts explicitly allowed, and the host's loopback interface is unreachable from inside the sandbox. Allow specific hosts with `sandbox.network.allow`: +Public egress is **allowed by default**: when `sandbox.network.allow` is omitted — or set to `["*"]` or `["**"]`, the explicit allow-all wildcards — Forge passes no network flags and msb's own default applies, letting the sandbox reach any public host. A wildcard entry anywhere in `network.allow` makes the whole list unrestricted, overriding any narrower entries in the same list. Restriction is opt-in — listing concrete hosts with `sandbox.network.allow` flips the sandbox to restricted egress, where only allow-listed hosts are reachable: ```jsonc { @@ -110,11 +115,15 @@ The `sbx` network proxy is deny-by-default: outbound access is blocked except fo } ``` -Forge applies each entry with `sbx policy allow network ` when a sandbox starts. Because the daemon persists policy, a daemon restart under a live plugin leaves the allowlist unapplied until the manager is recreated on the next plugin start. +When any concrete host is configured (including secret destination hosts, which are unioned into the same allow list) and no `*`/`**` entry is present in `sandbox.network.allow`, Forge creates the sandbox with `--net-default deny` plus one `--net-rule allow@` per validated host. Each entry is validated before use; invalid entries are skipped and logged. Verified rejections: a comma (for `--net-rule` a comma separates whole rule tokens, not hosts), a colon (`example.com:443` needs the `example.com:tcp:443` form), an `@`, a wildcard suffix with fewer than two labels (`*.example.com` is valid, `*.com` is rejected), and a bare single-label hostname (msb requires the `domain=` form). The `domain=` and `suffix=` forms pass through. A wildcard in a secret's destination hosts stays invalid — those hosts declare where that secret may be sent, not global egress policy. If every configured host is rejected as invalid, Forge still emits `--net-default deny` with no allow rules and logs that egress is fully denied — it deliberately does not fall back to allow-all, so a config typo cannot silently remove an intended restriction. + +Either way, the host's loopback interface remains unreachable from inside the sandbox: the private range is not part of msb's `public` egress group, so a host listener on e.g. `0.0.0.0:18923` stays unreachable via `host.microsandbox.internal` or the gateway IP even when no net flags are passed. + +Forge applies the rules at sandbox **create time** only. msb rules are per sandbox (not daemon-global) and `msb modify` has no `--net-rule` flag, so egress rules cannot be changed on a live sandbox: a newly configured host requires the sandbox to be recreated. ## Environment Passthrough -Select host environment variables can be passed into each sandbox command: +Select host environment variables can be injected into the sandbox at create time: ```jsonc { @@ -126,9 +135,34 @@ Select host environment variables can be passed into each sandbox command: } ``` -Values are written to a sandbox-lifetime env file on the host that is attached to every `sbx exec` via `--env-file`. The file is removed when the sandbox is stopped. +Forge passes each name as the bare `-e ` form, so msb resolves the value from its own environment and the value never appears on forge's command line (or in `ps` output). Only names that are set in the host process are injected; unset names are skipped and logged. + +## Secrets + +Host-held credentials are bound with `sandbox.network.secrets` instead of `env`. A secret **never enters the guest**: msb keeps a host-side source reference to the environment variable, exposes a `$MSB_` placeholder inside the sandbox, and substitutes the real value only for the listed hosts at the network boundary. + +```jsonc +{ + "sandbox": { + "network": { + "secrets": [ + { "env": "GITHUB_TOKEN", "hosts": ["api.github.com"] } + ] + } + } +} +``` + +Each entry maps to `msb create --secret @`. Once a sandbox has a secret bound, every `msb exec` fails unless the named host environment variable is present in the environment of the process invoking msb — msb reports `error: invalid config: secret X: host environment variable X is not set`. Because the shell shim inherits opencode's process environment, a variable missing there breaks every sandboxed shell command. The variables named in `sandbox.network.secrets` must therefore be exported in the environment that **launches opencode**, not merely present in an interactive shell. Forge logs an explicit warning naming the variable when a configured secret's host variable is unset. + +Adopting an existing sandbox (for example after a plugin restart) converges the bound secrets with `msb modify`: `--secret @` refreshes the current value of every configured entry, and `--secret-rm ` drops entries that are no longer configured. Convergence runs once per sandbox adoption per plugin instance, not on every liveness check. A refresh failure blocks adoption without marking the sandbox converged, so a later startup can retry. + +One placeholder caveat: a secret introduced by `msb modify` on an already-existing sandbox gets a `$` placeholder instead of the `$MSB_` form, so a newly added secret is most reliable on a freshly created sandbox. + +Security notes: -Security note: only pass variables you are willing to expose to the sandbox. +- Only pass variables you are willing to expose to the sandbox. Plain variables listed in `network.env` are readable inside the guest. +- The previous per-sandbox plaintext env file under `/sandbox-env/` is **gone**: nothing is written to disk, and the real secret value is stored only on the host. ## Read-Only Project Mount @@ -145,7 +179,7 @@ The loop worktree remains writable. When the read-only project mount would nest The worktree's git metadata directory is mounted read-write so git works inside the sandbox (`status`, `log`, `diff`, and commits all resolve against the real repository). Two guards keep that from becoming a path out of the sandbox: - `/hooks` is mounted **read-only**, so a sandboxed agent cannot plant a hook that the user's own git would later execute on the host. -- Every git command Forge itself runs is invoked with `core.hooksPath` disabled, so no repository hook runs on the host — including one reached through a `core.hooksPath` entry in the repo-local config, which cannot be mounted read-only because `sbx` workspaces are directories, not files. +- Every git command Forge itself runs is invoked with `core.hooksPath` disabled, so no repository hook runs on the host — including one reached through a `core.hooksPath` entry in the repo-local config, which cannot be mounted read-only because msb workspaces are directories, not files. Consequences: tools that install hooks into `.git/hooks` (for example `pre-commit install`, or Husky v4) fail inside the sandbox — hook managers that keep hooks in the working tree and point `core.hooksPath` at them still work. Forge's own scratch-branch commits never run repository hooks. @@ -173,13 +207,25 @@ Rules: Security note: read-write custom mounts give the sandbox write access to host paths. Use them only for trusted directories. -## Docker +## Nested Docker + +The sandbox image ships an in-VM Docker Engine, enabled by default. `container/Dockerfile` installs it from Docker's official apt repository — `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin` — and the `agent` user is in the `docker` group. + +msb runs its own `agentd` as PID 1 and ignores the image's `ENTRYPOINT`/`CMD`, so the daemon cannot start at boot. Instead the image ships `/usr/local/bin/forge-dockerd-start`: idempotent and safe to run concurrently (an `flock` serializes starts), self-elevating via the passwordless sudo rule so `agent` can call it bare, it starts `dockerd` detached with `setsid` and waits up to 60s for readiness, exiting non-zero with the daemon log tail on failure. + +`/var/lib/docker` is backed by a real block device, because Docker's `overlayfs` driver cannot run on a virtiofs workspace mount. Forge passes `--mount-named -docker-data:/var/lib/docker:kind=disk,size=`, configurable via `sandbox.resources.dockerDisk` (default `16g`). The disk is sparse, so the default costs no real disk up front. + +Named volumes **survive `msb rm`**, so Forge explicitly removes the sandbox's docker data volume when it removes the sandbox (and during orphan cleanup) — otherwise a multi-gigabyte volume would leak per loop. + +Verified working inside the sandbox: `docker info` reports server 29.7.2 with `storage=overlayfs`, `docker run --rm hello-world` succeeds, `docker compose version` works, and `docker build` works. The daemon survives across separate `msb exec` calls. + +Registry pulls work with the default allow-public egress posture and need no extra configuration. Under an opt-in restriction, pulling from Docker Hub requires allow-listing `registry-1.docker.io`, `auth.docker.io`, `production.cloudfront.docker.com`, and the CloudFront blob host (`*.cloudfront.net`). -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. +The image derives from a plain OCI base, keeps the final `USER agent`, and declares no `ENTRYPOINT`/`CMD`. Docker remains required on the **host** to build the image. The built image is roughly 1.65 GB, up from about 1 GB, because Docker Engine is heavy. ## Sandbox Lifecycle -`sbx` auto-stops a sandbox roughly 35 seconds after the last exec session ends, and `sbx exec` auto-starts a stopped sandbox, so a stop is never a correctness problem — only a restart. A stop is a full VM reboot that destroys in-memory state, while on-disk state (Docker images, containers, and files) persists across it. Cold starts are roughly 0.9s for the first command after a stop, vs ~0.16s warm. Forge relies on auto-resume instead of holding a separate keep-alive exec open. +msb sandboxes are reusable: `msb exec` resolves a stopped or crashed sandbox by starting it in place, so a stop is never a correctness problem — only a restart. A stop is a full VM reboot that destroys in-memory state while on-disk state persists. Forge adopts an existing running or stopped sandbox without recreating it (reusing the same `forge-` name), and relies on msb's start-in-place resolution instead of holding a separate keep-alive exec open. ## Large Command Output @@ -205,7 +251,8 @@ The mount is read-only because the setting exists to grant read access. To make ## Resource Defaults -| Option | Default | sbx flag | +| Option | Default | msb flag | |---|---:|---| -| `sandbox.resources.memory` | `"8g"` | `--memory` | -| `sandbox.resources.cpus` | `"4"` | `--cpus` (integer-only) | +| `sandbox.resources.memory` | `"8g"` | `msb create -m` | +| `sandbox.resources.cpus` | `"4"` | `msb create -c` (integer-only) | +| `sandbox.resources.dockerDisk` | `"16g"` | `msb create --mount-named -docker-data:/var/lib/docker:kind=disk,size=` | diff --git a/docs/api/functions/createForgePlugin.md b/docs/api/functions/createForgePlugin.md index 21f773a14..d8a2af055 100644 --- a/docs/api/functions/createForgePlugin.md +++ b/docs/api/functions/createForgePlugin.md @@ -8,7 +8,7 @@ > **createForgePlugin**(`config`): `Plugin` -Defined in: [index.ts:276](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L276) +Defined in: [index.ts:295](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L295) Creates an OpenCode plugin instance with loop management and sandboxing. diff --git a/docs/api/functions/createParentSessionLookup.md b/docs/api/functions/createParentSessionLookup.md index a5cb985af..126a77e14 100644 --- a/docs/api/functions/createParentSessionLookup.md +++ b/docs/api/functions/createParentSessionLookup.md @@ -8,7 +8,7 @@ > **createParentSessionLookup**(`__namedParameters`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:93](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L93) +Defined in: [index.ts:92](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L92) ## Parameters diff --git a/docs/api/functions/createSessionDirectoryLookup.md b/docs/api/functions/createSessionDirectoryLookup.md index 92a13190a..3057f52de 100644 --- a/docs/api/functions/createSessionDirectoryLookup.md +++ b/docs/api/functions/createSessionDirectoryLookup.md @@ -6,13 +6,13 @@ # Function: createSessionDirectoryLookup() -> **createSessionDirectoryLookup**(`__namedParameters`): (`sessionId`) => `Promise`\<`string` \| `null`\> +> **createSessionDirectoryLookup**(`options`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:154](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L154) +Defined in: [index.ts:198](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L198) ## Parameters -### \_\_namedParameters +### options [`CreateSessionDirectoryLookupOptions`](../interfaces/CreateSessionDirectoryLookupOptions.md) diff --git a/docs/api/interfaces/CompactionConfig.md b/docs/api/interfaces/CompactionConfig.md index 746d049e9..9ccf58c60 100644 --- a/docs/api/interfaces/CompactionConfig.md +++ b/docs/api/interfaces/CompactionConfig.md @@ -6,7 +6,7 @@ # Interface: CompactionConfig -Defined in: [types.ts:165](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L165) +Defined in: [types.ts:188](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L188) Configuration for session compaction behavior. @@ -16,7 +16,7 @@ Configuration for session compaction behavior. > `optional` **customPrompt?**: `boolean` -Defined in: [types.ts:167](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L167) +Defined in: [types.ts:190](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L190) Use a custom compaction prompt. @@ -26,6 +26,6 @@ Use a custom compaction prompt. > `optional` **maxContextTokens?**: `number` -Defined in: [types.ts:169](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L169) +Defined in: [types.ts:192](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L192) Maximum context tokens for compaction. diff --git a/docs/api/interfaces/CreateParentSessionLookupOptions.md b/docs/api/interfaces/CreateParentSessionLookupOptions.md index 9db632667..1f1cc02e0 100644 --- a/docs/api/interfaces/CreateParentSessionLookupOptions.md +++ b/docs/api/interfaces/CreateParentSessionLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateParentSessionLookupOptions -Defined in: [index.ts:52](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L52) +Defined in: [index.ts:51](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L51) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:52](https://github.com/chriswritescode-dev/opencode-forge/ > **client**: `ForgeClient` -Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L53) +Defined in: [index.ts:52](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L52) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/ > **directory**: `string` -Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L54) +Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L53) *** @@ -30,7 +30,7 @@ Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/ > **logger**: `object` -Defined in: [index.ts:56](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L56) +Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L55) #### debug @@ -92,7 +92,7 @@ Defined in: [index.ts:56](https://github.com/chriswritescode-dev/opencode-forge/ > **loop**: `Loop` -Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L55) +Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L54) *** @@ -100,4 +100,4 @@ Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/ > `optional` **negativeTtlMs?**: `number` -Defined in: [index.ts:57](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L57) +Defined in: [index.ts:56](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L56) diff --git a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md index ec778ed4d..c16a6eb3f 100644 --- a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md +++ b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateSessionDirectoryLookupOptions -Defined in: [index.ts:147](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L147) +Defined in: [index.ts:146](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L146) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:147](https://github.com/chriswritescode-dev/opencode-forge > **client**: `ForgeClient` -Defined in: [index.ts:148](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L148) +Defined in: [index.ts:147](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L147) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:148](https://github.com/chriswritescode-dev/opencode-forge > **directory**: `string` -Defined in: [index.ts:149](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L149) +Defined in: [index.ts:148](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L148) *** @@ -30,7 +30,7 @@ Defined in: [index.ts:149](https://github.com/chriswritescode-dev/opencode-forge > **loop**: `Loop` -Defined in: [index.ts:150](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L150) +Defined in: [index.ts:149](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L149) *** @@ -38,4 +38,4 @@ Defined in: [index.ts:150](https://github.com/chriswritescode-dev/opencode-forge > `optional` **negativeTtlMs?**: `number` -Defined in: [index.ts:151](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L151) +Defined in: [index.ts:150](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L150) diff --git a/docs/api/interfaces/DashboardConfig.md b/docs/api/interfaces/DashboardConfig.md index 483d70d14..a797d04af 100644 --- a/docs/api/interfaces/DashboardConfig.md +++ b/docs/api/interfaces/DashboardConfig.md @@ -6,7 +6,7 @@ # Interface: DashboardConfig -Defined in: [types.ts:201](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L201) +Defined in: [types.ts:224](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L224) Configuration for the read-only observability dashboard HTTP server. The dashboard is unauthenticated: binding to a non-loopback address exposes @@ -20,7 +20,7 @@ for the canonical warning text rendered by launch surfaces. > `optional` **host?**: `string` -Defined in: [types.ts:203](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L203) +Defined in: [types.ts:226](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L226) Bind hostname or IP. Defaults to "localhost". Use "0.0.0.0" to listen on all interfaces. @@ -30,6 +30,6 @@ Bind hostname or IP. Defaults to "localhost". Use "0.0.0.0" to listen on all int > `optional` **port?**: `number` -Defined in: [types.ts:205](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L205) +Defined in: [types.ts:228](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L228) Base bind port. Defaults to 4747. Consecutive ports are tried when busy. diff --git a/docs/api/interfaces/PluginConfig.md b/docs/api/interfaces/PluginConfig.md index 6d429f6e2..de62049a5 100644 --- a/docs/api/interfaces/PluginConfig.md +++ b/docs/api/interfaces/PluginConfig.md @@ -6,7 +6,7 @@ # Interface: PluginConfig -Defined in: [types.ts:255](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L255) +Defined in: [types.ts:278](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L278) Complete plugin configuration for opencode-forge. @@ -16,7 +16,7 @@ Complete plugin configuration for opencode-forge. > `optional` **agents?**: `Record`\<`string`, `AgentOverrideConfig`\> -Defined in: [types.ts:287](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L287) +Defined in: [types.ts:310](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L310) Per-agent configuration overrides. @@ -26,7 +26,7 @@ Per-agent configuration overrides. > `optional` **auditorFallbackModels?**: (`string` \| `AuditorFallbackModel`)[] -Defined in: [types.ts:273](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L273) +Defined in: [types.ts:296](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L296) Ordered entries tried, in order, when the current auditor model hits a provider usage/auth limit mid-loop. Use a `"provider/model"` string, or `{ model, variant }` to pin a variant to that fallback; the primary `auditorVariant` is **not** inherited by fallback entries. @@ -36,7 +36,7 @@ Ordered entries tried, in order, when the current auditor model hits a provider > `optional` **auditorModel?**: `string` -Defined in: [types.ts:267](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L267) +Defined in: [types.ts:290](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L290) Model to use for code auditing. @@ -46,7 +46,7 @@ Model to use for code auditing. > `optional` **auditorVariant?**: `string` -Defined in: [types.ts:271](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L271) +Defined in: [types.ts:294](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L294) Default reasoning/thinking variant for the auditor model. @@ -56,7 +56,7 @@ Default reasoning/thinking variant for the auditor model. > `optional` **compaction?**: [`CompactionConfig`](CompactionConfig.md) -Defined in: [types.ts:261](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L261) +Defined in: [types.ts:284](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L284) Compaction behavior configuration. @@ -66,7 +66,7 @@ Compaction behavior configuration. > `optional` **completedLoopTtlMs?**: `number` -Defined in: [types.ts:281](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L281) +Defined in: [types.ts:304](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L304) TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. @@ -76,7 +76,7 @@ TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. > `optional` **dashboard?**: [`DashboardConfig`](DashboardConfig.md) -Defined in: [types.ts:285](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L285) +Defined in: [types.ts:308](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L308) Dashboard HTTP server bind configuration. @@ -86,7 +86,7 @@ Dashboard HTTP server bind configuration. > `optional` **dataDir?**: `string` -Defined in: [types.ts:257](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L257) +Defined in: [types.ts:280](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L280) Custom data directory for plugin storage. Defaults to platform data dir. @@ -96,7 +96,7 @@ Custom data directory for plugin storage. Defaults to platform data dir. > `optional` **executionModel?**: `string` -Defined in: [types.ts:265](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L265) +Defined in: [types.ts:288](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L288) Model to use for code execution. @@ -106,7 +106,7 @@ Model to use for code execution. > `optional` **executionVariant?**: `string` -Defined in: [types.ts:269](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L269) +Defined in: [types.ts:292](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L292) Default reasoning/thinking variant for the execution model. @@ -116,7 +116,7 @@ Default reasoning/thinking variant for the execution model. > `optional` **groupLaunch?**: `GroupLaunchConfig` -Defined in: [types.ts:277](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L277) +Defined in: [types.ts:300](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L300) Group launch configuration. @@ -126,7 +126,7 @@ Group launch configuration. > `optional` **logging?**: `LoggingConfig` -Defined in: [types.ts:259](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L259) +Defined in: [types.ts:282](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L282) Logging configuration. @@ -136,7 +136,7 @@ Logging configuration. > `optional` **loop?**: `LoopConfig` -Defined in: [types.ts:275](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L275) +Defined in: [types.ts:298](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L298) Loop behavior configuration. @@ -146,7 +146,7 @@ Loop behavior configuration. > `optional` **messagesTransform?**: `MessagesTransformConfig` -Defined in: [types.ts:263](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L263) +Defined in: [types.ts:286](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L286) Message transformation for architect agent. @@ -156,7 +156,7 @@ Message transformation for architect agent. > `optional` **remotes?**: `RemoteServerConfig`[] -Defined in: [types.ts:279](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L279) +Defined in: [types.ts:302](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L302) Remote opencode servers available as loop launch targets. @@ -166,7 +166,7 @@ Remote opencode servers available as loop launch targets. > `optional` **sandbox?**: `SandboxConfig` -Defined in: [types.ts:289](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L289) +Defined in: [types.ts:312](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L312) Sandbox execution configuration. @@ -176,6 +176,6 @@ Sandbox execution configuration. > `optional` **tui?**: `TuiConfig` -Defined in: [types.ts:283](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/types.ts#L283) +Defined in: [types.ts:306](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/types.ts#L306) TUI display configuration. diff --git a/docs/api/variables/VERSION.md b/docs/api/variables/VERSION.md index 328c4bc27..d45b2e0c7 100644 --- a/docs/api/variables/VERSION.md +++ b/docs/api/variables/VERSION.md @@ -6,6 +6,6 @@ # Variable: VERSION -> `const` **VERSION**: `"0.8.0"` = `'0.8.0'` +> `const` **VERSION**: `"0.8.9"` = `'0.8.9'` -Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/version.ts#L1) +Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/version.ts#L1) diff --git a/docs/api/variables/default.md b/docs/api/variables/default.md index d360f6e48..ca915f5c6 100644 --- a/docs/api/variables/default.md +++ b/docs/api/variables/default.md @@ -8,7 +8,7 @@ > `const` **default**: `object` -Defined in: [index.ts:991](https://github.com/chriswritescode-dev/opencode-forge/blob/bae347e91d162356f5d88217fa4b5258e5c999ac/src/index.ts#L991) +Defined in: [index.ts:1025](https://github.com/chriswritescode-dev/opencode-forge/blob/53f50fe5eb66473d6a437eb28c168c9033d7a9c5/src/index.ts#L1025) ## Type Declaration diff --git a/docs/architecture.md b/docs/architecture.md index 017f7cfed..7d3578f5f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,7 @@ The codebase is organized into these module groups under `src/`: | `hooks/` | Plugin event/lifecycle hooks (session, loop events, plan capture, plan approval, watchdog, sandbox, forge-session-attach, loop-permission, host-side-effects) | `index.ts`, `session.ts`, `loop.ts`, `plan-capture.ts`, `plan-approval.ts`, `watchdog.ts`, `sandbox-tools.ts`, `forge-session-attach.ts`, `loop-permission.ts`, `host-side-effects.ts` | | `loop/` | Core loop state machine and runtime | `runtime.ts`, `service.ts`, `state.ts`, `transitions.ts`, `prompts.ts`, `restartability.ts`, `in-flight-guard.ts`, `token-usage.ts`, `name-uniqueness.ts` | | `services/` | Higher-level orchestration services | `execution.ts`, `session-loop-resolver.ts`, `deterministic-decomposer.ts`, `plan-capture.ts`, `worktree-log.ts` | -| `sandbox/` | sbx sandbox management | `sbx.ts`, `manager.ts`, `context.ts`, `reconcile.ts` | +| `sandbox/` | msb sandbox management | `msb.ts`, `manager.ts`, `context.ts`, `reconcile.ts` | | `storage/` | SQLite persistence layer (repos + migrations) | `database.ts`, `repos/*.ts`, `migrations/*.sql` | | `tools/` | Plugin tools callable by AI agents | `loop.ts`, `review.ts`, `plan-kv.ts`, `section-read.ts` | | `workspace/` | Git worktree / workspace management | `forge-adapter.ts`, `forge-worktree.ts`, `pending-teardown.ts`, `classify-stale.ts`, `remove-with-context.ts`, `sweep-stale.ts` | @@ -96,30 +96,36 @@ See [loop-system.md](loop-system.md) for detailed documentation. ## Sandbox System -Sandbox is optional. When the `sbx` daemon is available and `sandbox.mode = 'sbx'` is configured, a sandbox is provisioned automatically; otherwise loops run in worktree-only mode. +Sandbox is optional and controlled by `sandbox.enabled` (default `true`) with driver `sandbox.mode = 'msb'`. When enabled, a sandbox is provisioned automatically. If the `msb` CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop is rolled back rather than falling back to the host; set `sandbox.enabled: false` to run worktree-only. ### Components -- **SandboxRuntime** (`sandbox/sbx.ts`) - `sbx` CLI facade (create/exec/remove/list, availability probe) +- **SandboxRuntime** (`sandbox/msb.ts`) - `msb` CLI facade (create/exec/remove/list, availability probe) - **SandboxManager** (`sandbox/manager.ts`) - Sandbox lifecycle management - **SandboxContext** (`sandbox/context.ts`) - Tool call redirection - **SandboxTools** (`hooks/sandbox-tools.ts`) - Hooks for sandbox integration +- **Shell shim** (`sandbox/shell-shim.ts`) - Generated shim routing the native `bash` tool through `msb exec` +- **Shell env hook** (`hooks/shell-env.ts`) - Injects the sandbox container into each shell spawn ### How It Works -1. When a sandbox loop starts, an `sbx` sandbox is created +1. When a sandbox loop starts, an `msb` sandbox is created 2. The worktree directory is mounted at its identical host path inside the sandbox -3. `bash` runs inside the sandbox; `glob` and `grep` results are produced inside the sandbox +3. Shell commands and search tools run inside the sandbox: `bash` through the generated shell shim, `glob` and `grep` through the sandbox tool hooks — both backed by `msb exec` 4. File operations (`read`, `write`, `edit`) operate on the host directly 5. On loop completion, the sandbox is stopped and removed +### State Model + +The sandbox state model has five states. `running` and `stopped` are both usable: msb suspends idle microVMs to `stopped` and `msb exec` resumes them in place, so forge never recreates a merely-stopped sandbox. `transient` covers msb's `Created`/`Starting`/`Draining`/`Paused` statuses — real but not directly executable, and never collapsed into `unknown`. `unknown` means the state query failed and says nothing about the sandbox, so forge fails closed and refuses to create or remove on that basis. `missing` is the one confirmed-absent state, and the only one in which forge creates a sandbox. + ### Tool Redirection `bash` and the search tools reach the sandbox through two different mechanisms: - **`bash`** is redirected out of band, not through a tool hook. The `config` hook points `cfg.shell` at the `forge-shell` shim (`sandbox/shell-shim.ts`) and the `shell.env` hook - injects `FORGE_SANDBOX_CONTAINER`. The shim `exec`s `sbx exec -w "$PWD" bash "$@"`. + injects `FORGE_SANDBOX_CONTAINER`. The shim `exec`s `msb exec --quiet "$FORGE_SANDBOX_CONTAINER" --no-tty -w "$PWD" -- bash "$@"`. Tool arguments are never rewritten. - **`glob` and `grep`** use output replacement. `tool.execute.before` runs the equivalent `rg` command inside the container and stores the result by `callID`; `tool.execute.after` @@ -205,7 +211,7 @@ The plugin follows this initialization sequence within `createForgePlugin()`: 1. **Logger** - Always first (`createLogger()`) 2. **v2 Client** - Create OpenCode v2 SDK client for API calls -3. **Sandbox Manager** - sbx sandbox management (optional, fails gracefully) +3. **Sandbox Manager** - msb sandbox management (optional, fails gracefully) 4. **Pending Teardown Registry** - Track worktree teardown contexts 5. **Workspace Status Registry** - Track workspace connected/disconnected state 6. **Workspace Adapter** - Register forge workspace adapter if experimental workspace API available @@ -250,7 +256,7 @@ graph TD end LoopRuntime --> SandboxManager["Sandbox Manager"] - SandboxManager --> Sbx["sbx Sandbox"] + SandboxManager --> Msb["msb Sandbox"] SQLite --> LoopsRepo["Loops Repo"] SQLite --> PlansRepo["Plans Repo"] diff --git a/docs/configuration.md b/docs/configuration.md index 6d4607315..a78a4b8bc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -134,7 +134,7 @@ The written file is added to the worktree's git exclude so it never appears in ` Notes: - The written file is ephemeral. Forge deletes its own `opencode.jsonc` before any teardown commit (and the whole worktree is removed on completion), so it can never land in loop history — even if the git-exclude write failed. A repository-tracked `opencode.jsonc` is never deleted (forge did not write it). Because the file is removed at teardown, a restarted loop is rewritten from the current `loop.worktreeOpencodeConfig`, so edits take effect on the next run. -- MCP servers declared here run as **host** processes from the worktree directory. When [Sandbox](sandbox.md) is enabled, only `bash`/`glob`/`grep` execute inside the sandbox; the MCP commands themselves are not sandbox-isolated. To run an MCP server *inside* the loop's sandbox, use the placeholder below with an `sbx exec -i` command. +- MCP servers declared here run as **host** processes from the worktree directory. When [Sandbox](sandbox.md) is enabled, only `bash`/`glob`/`grep` execute inside the sandbox; the MCP commands themselves are not sandbox-isolated. To run an MCP server *inside* the loop's sandbox, use the placeholder below with an `msb exec` command. - The string `{{FORGE_SANDBOX_CONTAINER}}` in any config value is replaced with the loop's sandbox container name (`forge-`) when the file is written. For loops without a sandbox, `mcp` entries referencing the placeholder are dropped instead, so the same config works with and without the sandbox. ## Group Launch @@ -206,7 +206,7 @@ Example: | `remotes[].password` | unset | Basic-auth password (`OPENCODE_SERVER_PASSWORD` on the remote). Omit when the remote runs without auth. Stored in plaintext in this config file. | | `remotes[].username` | `"opencode"` | Basic-auth username (`OPENCODE_SERVER_USERNAME` default). | | `remotes[].gitRemote` | `"origin"` | Git remote name, configured on **both** machines' clones, used for code sync. | -| `remotes[].sandbox` | `true` | Whether the remote loop runs sandboxed. Must mirror the remote server's actual `sandbox.enabled`/sbx capability — see below. | +| `remotes[].sandbox` | `true` | Whether the remote loop runs sandboxed. Must mirror the remote server's actual `sandbox.enabled`/msb capability — see below. | Example: @@ -244,16 +244,50 @@ 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.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.enabled` | `true` | Enable sandboxed execution. When enabled and the msb CLI or host virtualization is unavailable, sandbox startup fails rather than falling back to the host; set `false` to run worktree-only. | +| `sandbox.mode` | `"msb"` | Sandbox mode. `msb` is currently the only supported mode. A stale `"mode": "sbx"` from an older install is reported as a migration warning in the log and, when running in the TUI, as a toast. | +| `sandbox.image` | `"oc-forge-sandbox:latest"` | msb image reference used for sandboxed execution. | +| `sandbox.imageFeatures.browserControl` | `false` | Include Chromium, the Browser Control CLI/MCP server, and its extension when building the bundled sandbox image. Rebuild the image after changing it. | +| `sandbox.resources.memory` | `"8g"` | Memory the sandbox boots with (`msb create -m`). | +| `sandbox.resources.maxMemory` | unset | Boot-time ceiling for hotpluggable memory (`msb create --max-memory`). Unset pins the sandbox at `memory`; msb rejects a value below `memory`. | +| `sandbox.resources.cpus` | `"4"` | CPU count the sandbox boots with (`msb create -c`; integer-only). | +| `sandbox.resources.maxCpus` | unset | Boot-time ceiling for virtual CPUs (`msb create --max-cpus`; integer-only). Unset pins the sandbox at `cpus`; msb rejects a value below `cpus`. | +| `sandbox.resources.dockerDisk` | `"16g"` | Size of the dedicated block device backing the sandbox's in-VM Docker Engine data dir (`/var/lib/docker`, `--mount-named ...:kind=disk,size=`). The disk is sparse, so the generous default costs no real disk up front. | | `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.env` | `[]` | Host environment variables to pass into each sandbox command via the env file. | +| `sandbox.network.allow` | `[]` | Egress allow-list applied at create time. Restriction is opt-in: an empty list, or a list containing the `*`/`**` allow-all wildcard, passes no network flags and msb's default allows all public egress; configuring any concrete host flips the sandbox to deny-by-default (`--net-default deny`) with one `--net-rule allow@` per validated host. | +| `sandbox.network.env` | `[]` | Host environment variables to inject into the sandbox at create time as bare names (values never appear on forge's command line). | +| `sandbox.network.secrets` | `[]` | Host-held credentials bound at create time. Each entry names a host env var and the hosts allowed to receive its real value; the value never enters the guest. The named variable must be exported in the environment that launches opencode — a bound secret with a missing variable breaks every sandboxed shell command. | + +### Sandbox network egress + +`sandbox.network.allow`, plus the destination hosts of any configured secrets, controls the sandbox's outbound access. Restriction is opt-in: when nothing is configured — or the allow list contains `*`/`**` — forge passes no network flags and msb's own default applies, so all public egress is allowed. A wildcard entry anywhere in the list makes the whole list unrestricted, overriding any narrower entries in the same list. When at least one concrete host or secret destination is configured and no allow-all wildcard is present, forge flips the sandbox to deny-by-default with `--net-default deny` plus one `--net-rule allow@` per validated host. + +- Invalid host entries are skipped and logged rather than failing the loop launch. Verified rejections include commas (a comma separates whole rule tokens, not hosts), port-qualified hosts (colon), `@`, a suffix with fewer than two labels (`*.example.com` is valid, `*.com` is rejected), and a bare single-label hostname (use `domain=myhost`). `domain=` and `suffix=` forms pass through. A wildcard in a secret's destination hosts stays invalid — those hosts declare where that secret may be sent, not global egress policy. +- If every configured host is invalid, forge emits `--net-default deny` with no allow rules and logs that egress is fully denied — it deliberately does not fall back to allow-all. +- DNS is gateway-mediated and needs no rule; the old `--net-rule allow@dns` form was rejected by msb 0.6.8, and its presence made every sandbox creation fail. +- The host's loopback interface remains unreachable from inside the sandbox: `host.microsandbox.internal` and the gateway IP are both blocked, because the private range is not part of msb's `public` egress group. +- Egress rules cannot be changed on a live sandbox (`msb modify` has no `--net-rule`), so a newly configured host requires recreating the sandbox. + +### Sandbox secrets + +Credentials that should never be readable inside the guest belong in `sandbox.network.secrets`, not `env`. msb keeps a host-side source reference, exposes a `$MSB_` placeholder inside the sandbox, and substitutes the real value only for the listed hosts at the network boundary: + +```jsonc +{ + "sandbox": { + "network": { + "secrets": [ + { "env": "GITHUB_TOKEN", "hosts": ["api.github.com"] } + ] + } + } +} +``` + +Each named variable must be exported in the environment that **launches opencode**. Once a secret is bound, every `msb exec` fails with `invalid config: secret : host environment variable is not set` if the variable is absent from the invoking process's environment; because the shell shim inherits opencode's environment, a missing variable breaks every sandboxed shell command. Forge logs an explicit warning naming the variable. + +Adopting an existing sandbox (for example after a plugin restart) converges the bound secrets with `msb modify` exactly once per adoption per plugin instance: `--secret @` refreshes the current value of every configured entry, and `--secret-rm ` drops entries that are no longer configured. A refresh failure blocks adoption without marking the sandbox converged, so a later startup can retry. The previous per-sandbox plaintext env file under `/sandbox-env/` is gone. ## Bundled Assets & Installer @@ -294,3 +328,61 @@ pnpm setup # from a checkout | `--no-prune` | Only report orphaned files; never delete them. | Without a flag the installer is interactive: for each conflicting file it offers overwrite / keep / diff, and for each orphan it offers delete / keep. When you choose **keep** on a conflict, the manifest is updated so future startup syncs continue to preserve your version. + +### Plugin-directory install + +The installer can also write the plugin itself into opencode's plugin directory, instead of hand-editing the `plugin` arrays: + +| Flag | Behavior | +|---|---| +| `--link` | Writes `/plugin/opencode-forge.js`, a one-line re-export shim whose target is the absolute path of the current build's `dist/index.js`. Because the shim re-exports the live build, a rebuild is picked up on the next opencode start with no reinstall. The shim is tied to that checkout path, so it is not portable to another machine. | +| `--vendor` | Copies `package.json`, `forge-config.jsonc`, `dist/`, `container/`, and `skills/` into `/plugin/opencode-forge/` (~6 MB) and writes the shim with the relative target `./opencode-forge/dist/index.js`. The whole config folder becomes self-contained and can be version-controlled and moved to another machine. Requires re-running after an upgrade. | +| `--unlink` | Removes the shim, the vendored directory, and the `tui.json` entry. | + +From a source checkout the same flags are `pnpm setup --link`, `pnpm setup --vendor`, and `pnpm setup --unlink`. In a non-interactive shell, `--link` and `--vendor` still require one of `-y`, `-f`, or `-k`, matching every other non-interactive use of the installer. + +Both modes also write the `plugin` entry into `tui.json` (see [Server vs TUI loading](#server-vs-tui-loading)). + +#### Resolved layout + +`--link` leaves only the shim in the config dir: + +```text +/ +├── plugin/ +│ └── opencode-forge.js # export { default } from "/abs/path/to/dist/index.js" +└── tui.json # plugin: ["/abs/path/to/dist/tui.js"] +``` + +`--vendor` copies the whole package: + +```text +/ +├── plugin/ +│ ├── opencode-forge.js # export { default } from "./opencode-forge/dist/index.js" +│ └── opencode-forge/ +│ ├── package.json +│ ├── forge-config.jsonc +│ ├── dist/ +│ │ ├── index.js +│ │ └── tui.js +│ ├── container/ +│ └── skills/ +└── tui.json # plugin: ["./plugin/opencode-forge/dist/tui.js"] +``` + +The vendored copy mirrors the npm package layout rather than being "just dist": forge resolves its bundled assets as siblings of its package root (`container/`, `skills/`, `forge-config.jsonc`), so the sandbox template and the bundled skill sync resolve inside the vendored copy. + +#### Server vs TUI loading + +opencode auto-loads server plugins from the config dir by globbing `{plugin,plugins}/*.{ts,js}`. Both the singular `plugin/` and plural `plugins/` directory names work. The scan is not recursive and does not match `.mjs`, which is why the installer uses a top-level shim file and keeps the vendored payload in a subdirectory — the payload itself is never scanned. + +That scan serves the server plugin surface only. The TUI surface is loaded exclusively from the `plugin` array in `tui.json`; there is no TUI directory scan. This is why both modes write a `tui.json` entry, and why the plugin directory alone cannot enable the sidebar and execution dialog. Path specs in a config file resolve relative to that config file's own directory, which is what makes the vendored `./plugin/opencode-forge/dist/tui.js` entry portable. + +#### Double-loading + +Local (`file://`) plugin specs dedup by exact file URL, while npm specs dedup by package name. So keeping a `plugin` array entry for forge AND installing the shim makes opencode initialize forge twice under the same id `oc-forge`. The installer detects an existing forge entry in the global `opencode.json`/`opencode.jsonc`; when run interactively it offers to comment the entry out, and in non-interactive mode it warns and changes nothing. + +#### Verification + +`opencode debug config` prints the resolved config. Its `plugin` array should list the shim's `file://` URL exactly once, with no duplicate forge entry. diff --git a/docs/loop-system.md b/docs/loop-system.md index e0c4d9220..901c44cde 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -76,7 +76,7 @@ interface LoopState { completedAt?: string // ISO timestamp worktree?: boolean // Whether using worktree isolation modelFailed?: boolean // Whether model error occurred - sandbox?: boolean // Whether using sbx sandbox + sandbox?: boolean // Whether using msb sandbox sandboxContainer?: string // Sandbox name if sandboxed completionSummary?: string // Summary of loop completion executionModel?: string // Model used for execution @@ -173,7 +173,7 @@ Outstanding `severity: 'bug'` findings block loop completion — the loop termin ## Worktree Isolation -Loops always run in an isolated git worktree. Sandbox is optional: when the `sbx` daemon is available and `sandbox.mode = 'sbx'` is configured, a sandbox is provisioned automatically; otherwise the loop runs in worktree-only mode. +Loops always run in an isolated git worktree. Sandbox is optional and controlled by `sandbox.enabled` (default `true`) with driver `sandbox.mode = 'msb'`: when enabled, a sandbox is provisioned automatically. If the `msb` CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop start is rolled back rather than silently falling back to the host; set `sandbox.enabled: false` to run worktree-only. Worktree loops require a repository with at least one commit. If OpenCode started before the initial commit, it resolves the project as `global`; create the commit, restart OpenCode, and retry. Forge rejects `execute-plan` loop mode, `execute-goal`, local or remote TUI loop launch, and feature-group launch/restart before creating workspaces, sessions, or group state when this precondition is not met. @@ -200,7 +200,7 @@ Benefits of worktree isolation: ## Sandbox Integration -Sandbox is optional. When the `sbx` daemon is available and configured, a sandbox is provisioned automatically; otherwise loops run in worktree-only mode. +Sandbox is optional and controlled by `sandbox.enabled` (default `true`): when enabled, a sandbox is provisioned automatically. If the `msb` CLI is unavailable or the host cannot run microVMs, sandbox startup fails and the loop is rolled back rather than falling back to the host; set `sandbox.enabled: false` to run worktree-only. 1. Sandbox created with the worktree mounted at its identical host path 2. `bash`, `glob`, `grep` tools redirect into the sandbox diff --git a/docs/modules.md b/docs/modules.md index 3066129d0..874cdf716 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -19,7 +19,7 @@ src/ ├── hooks/ # Plugin event/lifecycle hooks ├── loop/ # Core loop state machine & runtime ├── services/ # Business logic services -├── sandbox/ # sbx sandbox management +├── sandbox/ # msb sandbox management ├── storage/ # SQLite persistence layer ├── tools/ # Plugin tools callable by AI agents ├── tui/ # TUI-specific components @@ -268,39 +268,40 @@ Source: [src/services/execution.ts](../src/services/execution.ts) --- -## `sandbox/` — sbx Sandboxing +## `sandbox/` — msb Sandboxing -Drives the `sbx` CLI to provision isolated sandboxes for loop execution. +Drives the `msb` CLI to provision isolated sandboxes for loop execution. ### Files | File | Purpose | |------|---------| -| `sbx.ts` | `SandboxRuntime` facade over the `sbx` CLI (create/exec/remove/list, availability probe) | +| `msb.ts` | `SandboxRuntime` facade over the `msb` CLI (create/exec/remove/list, availability probe) | | `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` | Image build/save/load helper (`docker build`/`docker save`/`msb load`) | | `config-warnings.ts` | Warnings for legacy Docker-era sandbox config keys | -| `manager.ts` | `SandboxManager` lifecycle management (start/stop/getActive/isLive) | +| `manager.ts` | `SandboxManager` lifecycle management (start/stop/ensureRunning/isLive, orphan cleanup) | | `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 through `msb exec` | +| `shell-shim.ts` | Generated shim routing the native `bash` tool through `msb exec` | +| `session-controller.ts` | Per-session host sandbox selection and ownership | ### SandboxRuntime Interface ```typescript interface SandboxRuntime { - checkAvailable(): Promise + checkAvailable(): Promise templateExists(ref: string): Promise - loadTemplate(tarPath: string): Promise - createSandbox(name: string, workspaces: SandboxWorkspace[], opts?: CreateSandboxOpts): Promise + 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 + getSandboxState(name: string): Promise sandboxContainerName(worktreeName: string): string listSandboxesByPrefix(prefix: string): Promise - allowNetworkHost(host: string): Promise + refreshSandboxSecrets(name: string, secrets: SandboxSecretConfig[]): Promise } ``` @@ -308,18 +309,21 @@ interface SandboxRuntime { ```typescript interface SandboxManager { - start(loopName: string, worktreeDir: string): Promise - stop(loopName: string): Promise - getActive(): Map - isActive(loopName: string): boolean - isLive(sandboxName: string): Promise - isLiveByName(loopName: string): Promise - cleanupOrphans(preserveNames: Set): Promise - restore(loopName: string): Promise + runtime: SandboxRuntime + start(worktreeName: string, projectDir: string, startedAt?: string): Promise<{ containerName: string }> + stop(worktreeName: string): Promise + getActive(worktreeName: string): ActiveSandbox | null + isActive(worktreeName: string): boolean + isLive(worktreeName: string): Promise + cleanupOrphans(preserveWorktrees?: string[]): Promise + restore(worktreeName: string, projectDir: string, startedAt: string): Promise + ensureRunning(worktreeName: string, projectDir: string, startedAt?: string): Promise } ``` -Source: [src/sandbox/sbx.ts](../src/sandbox/sbx.ts), [src/sandbox/manager.ts](../src/sandbox/manager.ts) +`SandboxManagerConfig` no longer carries a `dataDir` field — its only reader was the deleted per-sandbox env-file writer. The overlapping-workspace drop rule is a single shared implementation used by both the mount plan and the workspace builder, so a mount conflict resolves identically on either path. `removeSandbox` also removes the sandbox's docker data volume (`-docker-data`), which backs `/var/lib/docker` for the in-VM Docker Engine. + +Source: [src/sandbox/msb.ts](../src/sandbox/msb.ts), [src/sandbox/manager.ts](../src/sandbox/manager.ts) --- @@ -488,7 +492,7 @@ createLoopService(...) // State management createSandboxManager(config, logger) // Sandbox createTools(ctx) // Tool registry createForgeWorkspaceAdapter(deps) // Workspace -createSbxRuntime(logger) // Sandbox +createMsbRuntime(logger) // Sandbox createLogger(config) // Logging ``` diff --git a/docs/sandbox.md b/docs/sandbox.md index 976693350..8caf71125 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1,29 +1,34 @@ # 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 `msb` sandbox — a microVM booted by the [microsandbox](https://microsandbox.dev) CLI — 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). ## Prerequisites -- The `sbx` CLI installed and authenticated. Run `sbx login` to authenticate. -- The `sbx` daemon (`sandboxd`) running. Start it with `sbx daemon start` if it is not already up. -- A platform the `sbx` daemon supports: macOS 14+ on Apple silicon, Windows 11 with Hypervisor Platform, or Ubuntu 24.04+ with KVM. -- Docker, used only to build the sandbox template (see below). +- The `msb` CLI installed — there is **no account and no login step**. Install with: + ```bash + curl -fsSL https://install.microsandbox.dev | sh + ``` + Verify the host is ready with `msb doctor` (an alias of `msb self doctor`), which checks the hypervisor prerequisites. +- A host that can run microVMs: Linux with KVM, macOS on Apple silicon, or Windows 11 with Windows Hypervisor Platform. +- Docker on the host, used only to build the sandbox image (see below) — the msb runtime itself does not need it. -The daemon serializes its work behind in-flight sandbox commands, so `sbx daemon status` and `sbx ls` can take seconds while several loops are running. Forge bounds those queries at 30s and treats a query that does not answer as *indeterminate* rather than "daemon down": it logs and continues, letting the actual sandbox operation report the authoritative error. Only a daemon that answers definitively (or a missing CLI) fails a loop launch with remediation advice. +Forge probes availability with `msb doctor` bounded at 30s. A probe that does not answer is treated as *indeterminate* rather than "daemon down": Forge logs and continues, letting the actual sandbox operation report the authoritative error. Only a host that answers definitively (or a missing CLI) fails a loop launch with remediation advice. -Build and load the bundled template: +Build and load the bundled image: ```bash docker build -t oc-forge-sandbox:latest container/ docker save oc-forge-sandbox:latest -o forge-sandbox.tar -sbx template load forge-sandbox.tar +msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest ``` -The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a native Docker daemon inside each sandbox. +`msb load` registers the archive under the tag Forge looks up (`sandbox.image`, default `oc-forge-sandbox:latest`); list loaded images with `msb images --format json`. -The sandbox image grants the `agent` user passwordless sudo, so loops can install whatever software they need at runtime. `sbx` commands stay unprivileged as `agent` (keeping host-mapped worktree files owned by the host user), so system-wide installs use an explicit `sudo` prefix, for example `sudo apt-get install ruby`. +The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and Docker Engine (see [Nested Docker](#nested-docker)). + +The sandbox image grants the `agent` user passwordless sudo, so loops can install whatever software they need at runtime. Commands arrive via `msb exec` without `-u`, so they run as the image's `USER agent` (keeping host-mapped worktree files owned by the host user); system-wide installs use an explicit `sudo` prefix, for example `sudo apt-get install ruby`. ### Browser Control (opt-in) @@ -39,7 +44,7 @@ Chromium and Browser Control add a substantial browser payload, so they are excl } ``` -Then run `Build sandbox template` from the command palette. Changing the option does not modify an already-loaded template; rebuild it explicitly. The equivalent manual Docker build is: +Then run `Build sandbox template` from the command palette. Changing the option does not modify an already-loaded image; rebuild it explicitly. The equivalent manual Docker build is: ```bash docker build \ @@ -47,7 +52,7 @@ docker build \ -t oc-forge-sandbox:latest \ container/ docker save oc-forge-sandbox:latest -o forge-sandbox.tar -sbx template load forge-sandbox.tar +msb load --input forge-sandbox.tar --tag oc-forge-sandbox:latest ``` The resulting image provides `browser-control`, `browser-control-mcp`, Chromium as `chromium`, and the unpacked extension at `/opt/browser-control-extension`. @@ -82,23 +87,23 @@ Sandbox loops use opencode's native `bash` tool — streaming output, truncation > Requires opencode >= 1.15.5 (the session-aware `shell.env` plugin hook). Enforced via the `engines.opencode` field in Forge's package.json: older opencode versions refuse to load the plugin instead of silently running sandbox loop commands on the host. 1. Forge points opencode's `shell` config at a generated shim (`/forge-shell`). -2. On every bash tool call, Forge's `shell.env` hook resolves the session. Sessions belonging to an active sandbox loop, or to the acknowledged host-session selection, get `FORGE_SANDBOX_CONTAINER` injected; descendants such as Task-tool subagents inherit the same routing. The shim then runs the command via `sbx exec -w "$PWD" bash`. +2. On every bash tool call, Forge's `shell.env` hook resolves the session. Sessions belonging to an active sandbox loop, or to the acknowledged host-session selection, get `FORGE_SANDBOX_CONTAINER` injected; descendants such as Task-tool subagents inherit the same routing. The shim then runs the command via `msb exec --quiet "$FORGE_SANDBOX_CONTAINER" --no-tty -w "$PWD" -- bash "$@"`. 3. Sessions with no expected sandbox get no container env, and the shim execs the host shell unchanged (respecting a user-configured `shell` via `FORGE_HOST_SHELL`). Active loop routing always takes precedence over host-session preference. -The shim fails closed: if the sandbox is expected but `sbx exec` fails (or the loop sandbox cannot be restored), the command errors — it never silently runs on the host. +The shim fails closed: if the sandbox is expected but `msb exec` fails (or the loop sandbox cannot be restored), the command errors — it never silently runs on the host. `msb exec` propagates the guest command's exit code verbatim, so the bash tool keeps seeing real exit statuses. ## Tool Behavior | Tool category | Behavior in a sandboxed session | |---|---| | Shell | Native `bash` tool, executed inside the loop sandbox via the shell shim. | -| Search tools | `glob` and `grep` route through the `sbx exec` execution hooks. | +| Search tools | `glob` and `grep` route through the `msb exec` execution hooks. | | File tools | `read`, `write`, and `edit` operate on the host filesystem. | | Git operations managed by Forge | Worktree commits, cleanup, and branch management are handled on the host. | ## Network Access -The `sbx` network proxy is deny-by-default: outbound access is blocked except for hosts explicitly allowed, and the host's loopback interface is unreachable from inside the sandbox. Allow specific hosts with `sandbox.network.allow`: +Public egress is **allowed by default**: when `sandbox.network.allow` is omitted — or set to `["*"]` or `["**"]`, the explicit allow-all wildcards — Forge passes no network flags and msb's own default applies, letting the sandbox reach any public host. A wildcard entry anywhere in `network.allow` makes the whole list unrestricted, overriding any narrower entries in the same list. Restriction is opt-in — listing concrete hosts with `sandbox.network.allow` flips the sandbox to restricted egress, where only allow-listed hosts are reachable: ```jsonc { @@ -110,11 +115,15 @@ The `sbx` network proxy is deny-by-default: outbound access is blocked except fo } ``` -Forge applies each entry with `sbx policy allow network ` when a sandbox starts. Because the daemon persists policy, a daemon restart under a live plugin leaves the allowlist unapplied until the manager is recreated on the next plugin start. +When any concrete host is configured (including secret destination hosts, which are unioned into the same allow list) and no `*`/`**` entry is present in `sandbox.network.allow`, Forge creates the sandbox with `--net-default deny` plus one `--net-rule allow@` per validated host. Each entry is validated before use; invalid entries are skipped and logged. Verified rejections: a comma (for `--net-rule` a comma separates whole rule tokens, not hosts), a colon (`example.com:443` needs the `example.com:tcp:443` form), an `@`, a wildcard suffix with fewer than two labels (`*.example.com` is valid, `*.com` is rejected), and a bare single-label hostname (msb requires the `domain=` form). The `domain=` and `suffix=` forms pass through. A wildcard in a secret's destination hosts stays invalid — those hosts declare where that secret may be sent, not global egress policy. If every configured host is rejected as invalid, Forge still emits `--net-default deny` with no allow rules and logs that egress is fully denied — it deliberately does not fall back to allow-all, so a config typo cannot silently remove an intended restriction. + +Either way, the host's loopback interface remains unreachable from inside the sandbox: the private range is not part of msb's `public` egress group, so a host listener on e.g. `0.0.0.0:18923` stays unreachable via `host.microsandbox.internal` or the gateway IP even when no net flags are passed. + +Forge applies the rules at sandbox **create time** only. msb rules are per sandbox (not daemon-global) and `msb modify` has no `--net-rule` flag, so egress rules cannot be changed on a live sandbox: a newly configured host requires the sandbox to be recreated. ## Environment Passthrough -Select host environment variables can be passed into each sandbox command: +Select host environment variables can be injected into the sandbox at create time: ```jsonc { @@ -126,9 +135,34 @@ Select host environment variables can be passed into each sandbox command: } ``` -Values are written to a sandbox-lifetime env file on the host that is attached to every `sbx exec` via `--env-file`. The file is removed when the sandbox is stopped. +Forge passes each name as the bare `-e ` form, so msb resolves the value from its own environment and the value never appears on forge's command line (or in `ps` output). Only names that are set in the host process are injected; unset names are skipped and logged. + +## Secrets -Security note: only pass variables you are willing to expose to the sandbox. +Host-held credentials are bound with `sandbox.network.secrets` instead of `env`. A secret **never enters the guest**: msb keeps a host-side source reference to the environment variable, exposes a `$MSB_` placeholder inside the sandbox, and substitutes the real value only for the listed hosts at the network boundary. + +```jsonc +{ + "sandbox": { + "network": { + "secrets": [ + { "env": "GITHUB_TOKEN", "hosts": ["api.github.com"] } + ] + } + } +} +``` + +Each entry maps to `msb create --secret @`. Once a sandbox has a secret bound, every `msb exec` fails unless the named host environment variable is present in the environment of the process invoking msb — msb reports `error: invalid config: secret X: host environment variable X is not set`. Because the shell shim inherits opencode's process environment, a variable missing there breaks every sandboxed shell command. The variables named in `sandbox.network.secrets` must therefore be exported in the environment that **launches opencode**, not merely present in an interactive shell. Forge logs an explicit warning naming the variable when a configured secret's host variable is unset. + +Adopting an existing sandbox (for example after a plugin restart) converges the bound secrets with `msb modify`: `--secret @` refreshes the current value of every configured entry, and `--secret-rm ` drops entries that are no longer configured. Convergence runs once per sandbox adoption per plugin instance, not on every liveness check. A refresh failure blocks adoption without marking the sandbox converged, so a later startup can retry. + +One placeholder caveat: a secret introduced by `msb modify` on an already-existing sandbox gets a `$` placeholder instead of the `$MSB_` form, so a newly added secret is most reliable on a freshly created sandbox. + +Security notes: + +- Only pass variables you are willing to expose to the sandbox. Plain variables listed in `network.env` are readable inside the guest. +- The previous per-sandbox plaintext env file under `/sandbox-env/` is **gone**: nothing is written to disk, and the real secret value is stored only on the host. ## Read-Only Project Mount @@ -145,7 +179,7 @@ The loop worktree remains writable. When the read-only project mount would nest The worktree's git metadata directory is mounted read-write so git works inside the sandbox (`status`, `log`, `diff`, and commits all resolve against the real repository). Two guards keep that from becoming a path out of the sandbox: - `/hooks` is mounted **read-only**, so a sandboxed agent cannot plant a hook that the user's own git would later execute on the host. -- Every git command Forge itself runs is invoked with `core.hooksPath` disabled, so no repository hook runs on the host — including one reached through a `core.hooksPath` entry in the repo-local config, which cannot be mounted read-only because `sbx` workspaces are directories, not files. +- Every git command Forge itself runs is invoked with `core.hooksPath` disabled, so no repository hook runs on the host — including one reached through a `core.hooksPath` entry in the repo-local config, which cannot be mounted read-only because msb workspaces are directories, not files. Consequences: tools that install hooks into `.git/hooks` (for example `pre-commit install`, or Husky v4) fail inside the sandbox — hook managers that keep hooks in the working tree and point `core.hooksPath` at them still work. Forge's own scratch-branch commits never run repository hooks. @@ -173,13 +207,25 @@ Rules: Security note: read-write custom mounts give the sandbox write access to host paths. Use them only for trusted directories. -## Docker +## Nested Docker + +The sandbox image ships an in-VM Docker Engine, enabled by default. `container/Dockerfile` installs it from Docker's official apt repository — `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin` — and the `agent` user is in the `docker` group. -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. +msb runs its own `agentd` as PID 1 and ignores the image's `ENTRYPOINT`/`CMD`, so the daemon cannot start at boot. Instead the image ships `/usr/local/bin/forge-dockerd-start`: idempotent and safe to run concurrently (an `flock` serializes starts), self-elevating via the passwordless sudo rule so `agent` can call it bare, it starts `dockerd` detached with `setsid` and waits up to 60s for readiness, exiting non-zero with the daemon log tail on failure. + +`/var/lib/docker` is backed by a real block device, because Docker's `overlayfs` driver cannot run on a virtiofs workspace mount. Forge passes `--mount-named -docker-data:/var/lib/docker:kind=disk,size=`, configurable via `sandbox.resources.dockerDisk` (default `16g`). The disk is sparse, so the default costs no real disk up front. + +Named volumes **survive `msb rm`**, so Forge explicitly removes the sandbox's docker data volume when it removes the sandbox (and during orphan cleanup) — otherwise a multi-gigabyte volume would leak per loop. + +Verified working inside the sandbox: `docker info` reports server 29.7.2 with `storage=overlayfs`, `docker run --rm hello-world` succeeds, `docker compose version` works, and `docker build` works. The daemon survives across separate `msb exec` calls. + +Registry pulls work with the default allow-public egress posture and need no extra configuration. Under an opt-in restriction, pulling from Docker Hub requires allow-listing `registry-1.docker.io`, `auth.docker.io`, `production.cloudfront.docker.com`, and the CloudFront blob host (`*.cloudfront.net`). + +The image derives from a plain OCI base, keeps the final `USER agent`, and declares no `ENTRYPOINT`/`CMD`. Docker remains required on the **host** to build the image. The built image is roughly 1.65 GB, up from about 1 GB, because Docker Engine is heavy. ## Sandbox Lifecycle -`sbx` auto-stops a sandbox roughly 35 seconds after the last exec session ends, and `sbx exec` auto-starts a stopped sandbox, so a stop is never a correctness problem — only a restart. A stop is a full VM reboot that destroys in-memory state, while on-disk state (Docker images, containers, and files) persists across it. Cold starts are roughly 0.9s for the first command after a stop, vs ~0.16s warm. Forge relies on auto-resume instead of holding a separate keep-alive exec open. +msb sandboxes are reusable: `msb exec` resolves a stopped or crashed sandbox by starting it in place, so a stop is never a correctness problem — only a restart. A stop is a full VM reboot that destroys in-memory state while on-disk state persists. Forge adopts an existing running or stopped sandbox without recreating it (reusing the same `forge-` name), and relies on msb's start-in-place resolution instead of holding a separate keep-alive exec open. ## Large Command Output @@ -205,7 +251,12 @@ The mount is read-only because the setting exists to grant read access. To make ## Resource Defaults -| Option | Default | sbx flag | +| Option | Default | msb flag | |---|---:|---| -| `sandbox.resources.memory` | `"8g"` | `--memory` | -| `sandbox.resources.cpus` | `"4"` | `--cpus` (integer-only) | +| `sandbox.resources.memory` | `"8g"` | `msb create -m` | +| `sandbox.resources.maxMemory` | unset | `msb create --max-memory` | +| `sandbox.resources.cpus` | `"4"` | `msb create -c` (integer-only) | +| `sandbox.resources.maxCpus` | unset | `msb create --max-cpus` (integer-only) | +| `sandbox.resources.dockerDisk` | `"16g"` | `msb create --mount-named -docker-data:/var/lib/docker:kind=disk,size=` | + +`memory` and `cpus` are what the microVM boots with. `maxMemory` and `maxCpus` are boot-time ceilings the guest can grow into; leaving them unset pins the sandbox at its boot allocation, which is why `msb inspect` reports identical `Memory` and `Max Memory` by default. Set a small boot allocation with a large ceiling (for example `memory: "2g"` with `maxMemory: "16g"`) to keep idle sandboxes cheap while still allowing a heavy build to expand. msb rejects a ceiling below the boot allocation and the sandbox fails to create, so keep `maxMemory` >= `memory` and `maxCpus` >= `cpus`. diff --git a/eslint.config.js b/eslint.config.js index a544fa85c..36353d0dd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -49,6 +49,6 @@ export default tseslint.config( }, }, { - ignores: ["dist/**", "node_modules/**", "*.d.ts", "test/**", ".sbx/**", "src/dashboard/marked.min.js", "src/dashboard/app-bundle.ts"], + ignores: ["dist/**", "node_modules/**", "*.d.ts", "test/**", ".msb/**", "src/dashboard/marked.min.js", "src/dashboard/app-bundle.ts"], } ); diff --git a/forge-config.jsonc b/forge-config.jsonc index ab466222c..45bfbd5d9 100644 --- a/forge-config.jsonc +++ b/forge-config.jsonc @@ -99,24 +99,48 @@ }, // 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 the msb CLI is installed and `msb doctor` reports a usable host a sandbox is provisioned + // automatically. Set "enabled": false to force worktree-only mode even when msb is available. "sandbox": { "enabled": true, - "mode": "sbx", + "mode": "msb", "image": "oc-forge-sandbox:latest", "imageFeatures": { "browserControl": false } // 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. msb egress is allow-by-default: with no hosts configured, + // forge passes no network flags and all PUBLIC egress is allowed. Restriction is opt-in — + // once any host is listed, forge passes `--net-default deny` plus one `--net-rule allow@` + // per validated host, and only those hosts are reachable. Host loopback (host.microsandbox.internal + // and the gateway IP) is always unreachable from inside the sandbox. Invalid host entries + // (e.g. a bare "*", port-qualified hosts, or single-label hosts) are skipped and logged rather + // than failing the loop launch; if every configured host is invalid, egress is fully denied + // rather than falling back to allow-all. Egress rules cannot be changed on a live sandbox — + // a newly configured host requires recreating the sandbox. + // Host environment variables pass through at create time as bare names (msb resolves each name + // from its own environment, so values never appear on forge's command line); host-held + // secrets are bound reference-only and exposed inside the sandbox as $MSB_ placeholders + // that msb substitutes only for the listed hosts. // "network": { - // // Hosts the sandbox may reach. Defaults to none (deny-by-default). + // // OPT-IN egress restriction: hosts the sandbox may reach. Leave empty (or omit) for + // // msb's default of allowing all public egress. // "allow": ["registry.npmjs.org"], - // // Host environment variable names passed into each sandbox exec via the env file. - // "env": ["MY_VAR"] + // // Host environment variable names to inject into the sandbox at create time. + // // Only names set in the host process are injected, as ordinary guest environment + // // variables (msb resolves a bare name from its own environment, so the value never + // // appears on forge's command line). Host-held credentials belong in "secrets" instead, + // // which never enter the guest. + // "env": ["MY_VAR"], + // // Host-held credentials bound at create time. Each entry references a host env var and + // // the hostnames allowed to receive its real value at the network boundary; the value + // // never enters the guest. The named variable must be exported in the environment that + // // launches opencode: a bound secret with a missing variable makes every sandboxed shell + // // command fail, and forge logs a warning naming the variable. + // "secrets": [ + // { "env": "NPM_TOKEN", "hosts": ["registry.npmjs.org"] } + // ] // }, // Additional host directories to bind-mount into the sandbox at their identical host path. // "readonly" defaults to true (read-only); set false to grant the sandbox read-write access. diff --git a/scripts/build.ts b/scripts/build.ts index b8a4ab63b..06fed5046 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, cpSync, mkdirSync, existsSync, chmodSync } from 'fs' +import { readFileSync, writeFileSync, cpSync, mkdirSync, existsSync, chmodSync, rmSync } from 'fs' import { join } from 'path' import { execSync } from 'child_process' import solidPlugin from '@opentui/solid/bun-plugin' @@ -7,6 +7,10 @@ import { buildDashboardApp } from './build-dashboard-app' const packageJsonPath = join(__dirname, '..', 'package.json') const versionPath = join(__dirname, '..', 'src', 'version.ts') +const distDir = join(__dirname, '..', 'dist') +rmSync(distDir, { recursive: true, force: true }) +console.log(`Cleaned ${distDir}`) + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) const version = packageJson.version as string @@ -37,6 +41,21 @@ execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' }) +console.log('Bundling server plugin...') +const serverResult = await Bun.build({ + entrypoints: [join(__dirname, '..', 'src', 'index.ts')], + outdir: join(__dirname, '..', 'dist'), + target: 'node', + external: ['@opentui/solid', '@opentui/core', '@opencode-ai/plugin/tui', 'solid-js'], +}) + +if (!serverResult.success) { + for (const log of serverResult.logs) { + console.error(log) + } + process.exit(1) +} + console.log('Compiling TUI plugin...') const result = await Bun.build({ entrypoints: [join(__dirname, '..', 'src', 'tui.tsx')], diff --git a/scripts/cleanup-loop.ts b/scripts/cleanup-loop.ts index 5e911094c..e2ac2ff7e 100644 --- a/scripts/cleanup-loop.ts +++ b/scripts/cleanup-loop.ts @@ -6,7 +6,7 @@ * - on-disk worktree directory * - git worktree registration * - git branch (forge/) - * - running sbx sandbox + * - running msb sandbox * * Usage: * bun scripts/cleanup-loop.ts [--project-dir=/path/to/project] [--dry-run] @@ -19,11 +19,12 @@ import Database from 'bun:sqlite' import { existsSync, rmSync } from 'fs' -import { homedir } from 'os' import { join } from 'path' -import { spawnSync } from 'child_process' import { readFlagValue } from '../src/utils/cli-flags' -import { parseSbxSandboxList } from '../src/sandbox/sbx' +import { defaultGitService } from '../src/utils/git-service' +import { loadPluginConfig } from '../src/setup' +import { resolveDataDir, resolveForgeDbPath, resolveOpencodeDataDir } from '../src/utils/opencode-paths' +import { createMsbRuntime, type SandboxRuntime } from '../src/sandbox/msb' interface Args { loopName: string @@ -53,21 +54,21 @@ function parseArgs(): Args { return { loopName, projectDir, dryRun } } -function logAction(dryRun: boolean, label: string, action: () => void): void { +async function logAction(dryRun: boolean, label: string, action: () => Promise | void): Promise { if (dryRun) { console.log(`[dry-run] would: ${label}`) return } try { - action() + await action() console.log(` ✓ ${label}`) } catch (err) { console.error(` ✗ ${label}: ${(err as Error).message}`) } } -function cleanupForgeDb(loopName: string, dryRun: boolean): void { - const path = join(homedir(), '.local/share/opencode/forge/forge.db') +async function cleanupForgeDb(loopName: string, dryRun: boolean, dataDir: string): Promise { + const path = resolveForgeDbPath(dataDir) if (!existsSync(path)) { console.log(`forge.db not found at ${path} — skipping`) return @@ -84,28 +85,37 @@ function cleanupForgeDb(loopName: string, dryRun: boolean): void { console.log(` no loops rows for ${loopName}`) return } - for (const row of rows) { - logAction(dryRun, `delete loops row project=${row.project_id} status=${row.status}`, () => { - db.run('DELETE FROM loops WHERE project_id = ? AND loop_name = ?', [row.project_id, loopName]) - }) + const dependentTables = ['loop_large_fields', 'section_plans', 'review_findings'] + const labels = [ + ...rows.map((row) => `delete loops row project=${row.project_id} status=${row.status}`), + ...dependentTables.map((table) => `delete ${table} entries for loop=${loopName}`), + ] + if (dryRun) { + for (const label of labels) { + await logAction(dryRun, label, () => {}) + } + return } - // Also clean dependent tables - for (const table of ['loop_large_fields', 'section_plans', 'review_findings']) { - logAction(dryRun, `delete ${table} entries for loop=${loopName}`, () => { + db.transaction(() => { + db.run('DELETE FROM loops WHERE loop_name = ?', [loopName]) + for (const table of dependentTables) { try { db.run(`DELETE FROM ${table} WHERE loop_name = ?`, [loopName]) } catch { // some tables may not exist on older schemas } - }) + } + })() + for (const label of labels) { + console.log(` ✓ ${label}`) } } finally { db.close() } } -function cleanupOpencodeDb(loopName: string, dryRun: boolean): void { - const path = join(homedir(), '.local/share/opencode/opencode.db') +async function cleanupOpencodeDb(loopName: string, dryRun: boolean): Promise { + const path = join(resolveOpencodeDataDir(), 'opencode.db') if (!existsSync(path)) { console.log(`\nopencode.db not found at ${path} — skipping`) return @@ -121,39 +131,56 @@ function cleanupOpencodeDb(loopName: string, dryRun: boolean): void { console.log(` no forge workspaces named ${loopName}`) return } + const labels: string[] = [] for (const ws of workspaces) { const sessions = db.query('SELECT id, title FROM session WHERE workspace_id = ?').all(ws.id) as Array<{ id: string title: string }> for (const sess of sessions) { - logAction(dryRun, `delete session ${sess.id} (title="${sess.title}") in workspace ${ws.id}`, () => { - db.run('DELETE FROM session_message WHERE session_id = ?', [sess.id]) - db.run('DELETE FROM session WHERE id = ?', [sess.id]) - }) + labels.push(`delete session ${sess.id} (title="${sess.title}") in workspace ${ws.id}`) } - logAction(dryRun, `delete workspace ${ws.id} (project=${ws.project_id})`, () => { + labels.push(`delete workspace ${ws.id} (project=${ws.project_id})`) + } + if (dryRun) { + for (const label of labels) { + await logAction(dryRun, label, () => {}) + } + return + } + db.transaction(() => { + for (const ws of workspaces) { + db.run('DELETE FROM session_message WHERE session_id IN (SELECT id FROM session WHERE workspace_id = ?)', [ws.id]) + db.run('DELETE FROM session WHERE workspace_id = ?', [ws.id]) db.run('DELETE FROM workspace WHERE id = ?', [ws.id]) - }) + } + })() + for (const label of labels) { + console.log(` ✓ ${label}`) } } finally { db.close() } } -function cleanupWorktreeDirectory(loopName: string, dryRun: boolean): void { - const path = join(homedir(), '.local/share/opencode/forge/worktrees', loopName) +async function cleanupWorktreeDirectory(loopName: string, dryRun: boolean, worktreesRoot: string): Promise { + const path = join(worktreesRoot, loopName) if (!existsSync(path)) { console.log(`\nworktree directory ${path} — already gone`) return } console.log(`\nworktree directory:`) - logAction(dryRun, `rm -rf ${path}`, () => { + await logAction(dryRun, `rm -rf ${path}`, () => { rmSync(path, { recursive: true, force: true }) }) } -function cleanupGitWorktree(loopName: string, projectDir: string | undefined, dryRun: boolean): void { +async function cleanupGitWorktree( + loopName: string, + projectDir: string | undefined, + dryRun: boolean, + worktreesRoot: string, +): Promise { if (!projectDir) { console.log(`\ngit cleanup skipped — pass --project-dir=/path/to/project to enable`) return @@ -164,64 +191,90 @@ function cleanupGitWorktree(loopName: string, projectDir: string | undefined, dr } console.log(`\ngit (${projectDir}):`) const branch = `forge/${loopName}` + const worktreePath = join(worktreesRoot, loopName) - logAction(dryRun, `git worktree prune`, () => { - const r = spawnSync('git', ['worktree', 'prune'], { cwd: projectDir, encoding: 'utf-8' }) - if (r.status !== 0) throw new Error(r.stderr || 'unknown error') + await logAction(dryRun, `git worktree prune`, () => { + const r = defaultGitService.worktreePrune(projectDir) + if (!r.ok) throw new Error(r.stderr || 'unknown error') }) - const list = spawnSync('git', ['worktree', 'list', '--porcelain'], { cwd: projectDir, encoding: 'utf-8' }) - const worktreePath = join(homedir(), '.local/share/opencode/forge/worktrees', loopName) - if (list.stdout.includes(worktreePath)) { - logAction(dryRun, `git worktree remove --force ${worktreePath}`, () => { - const r = spawnSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: projectDir, encoding: 'utf-8' }) - if (r.status !== 0) throw new Error(r.stderr || 'unknown error') + const worktrees = defaultGitService.worktreeList(projectDir) + if (!worktrees.ok) { + console.error(` ✗ git worktree list --porcelain: ${worktrees.stderr || 'unknown error'}`) + } else if (worktrees.stdout.includes(worktreePath)) { + await logAction(dryRun, `git worktree remove --force ${worktreePath}`, () => { + const r = defaultGitService.worktreeRemove(projectDir, worktreePath) + if (!r.ok) throw new Error(r.stderr || 'unknown error') }) } else { console.log(` git worktree registration for ${worktreePath} not found`) } - const branchCheck = spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { - cwd: projectDir, - encoding: 'utf-8', - }) - if (branchCheck.status === 0) { - logAction(dryRun, `git branch -D ${branch}`, () => { - const r = spawnSync('git', ['branch', '-D', branch], { cwd: projectDir, encoding: 'utf-8' }) - if (r.status !== 0) throw new Error(r.stderr || 'unknown error') + if (defaultGitService.branchExists(projectDir, branch)) { + await logAction(dryRun, `git branch -D ${branch}`, () => { + const r = defaultGitService.branchDelete(projectDir, branch) + if (!r.ok) throw new Error(r.stderr || 'unknown error') }) } else { console.log(` branch ${branch} not present`) } } -function cleanupSandbox(loopName: string, dryRun: boolean): void { - const sandboxName = `forge-${loopName}` - console.log(`\nsbx sandbox ${sandboxName}:`) - const inspect = spawnSync('sbx', ['ls', '--json'], { encoding: 'utf-8' }) - const found = parseSbxSandboxList(inspect.stdout).find((e) => e.name === sandboxName) - if (!found) { +async function cleanupSandbox(loopName: string, dryRun: boolean, runtime: SandboxRuntime): Promise { + // Derive the name through the same sanitization the runtime uses when provisioning, + // so cleanup can never drift from the actual container name (e.g. `foo_bar` → `forge-foo-bar`). + const sandboxName = runtime.sandboxContainerName(loopName) + console.log(`\nmsb sandbox ${sandboxName}:`) + // getSandboxState shares the runtime's own `msb ls` parsing, so this cannot drift from the + // status interpretation the rest of forge uses. `unknown` means the query failed: absence + // may only be proven by a parsed inventory, otherwise the sandbox may still be running. + const state = await runtime.getSandboxState(sandboxName) + if (state === 'unknown') { + console.error(` ✗ msb inventory query failed; sandbox ${sandboxName} may still be running`) + return false + } + if (state === 'missing') { console.log(` not present`) - return + return true + } + console.log(` present (state=${state})`) + if (dryRun) { + console.log(`[dry-run] would: msb rm --force ${sandboxName} --quiet`) + return true + } + try { + await runtime.removeSandbox(sandboxName) + console.log(` ✓ msb rm --force ${sandboxName} --quiet`) + return true + } catch (err) { + console.error(` ✗ msb rm --force ${sandboxName} --quiet: ${err instanceof Error ? err.message : String(err)}`) + return false } - console.log(` present (running=${found.running})`) - logAction(dryRun, `sbx rm --force ${sandboxName}`, () => { - const r = spawnSync('sbx', ['rm', '--force', sandboxName], { encoding: 'utf-8' }) - if (r.status !== 0) throw new Error(r.stderr || 'unknown error') - }) } -function main(): void { +async function main(): Promise { const args = parseArgs() console.log(`Cleanup loop: ${args.loopName}${args.dryRun ? ' [DRY RUN]' : ''}\n`) - cleanupForgeDb(args.loopName, args.dryRun) - cleanupOpencodeDb(args.loopName, args.dryRun) - cleanupWorktreeDirectory(args.loopName, args.dryRun) - cleanupGitWorktree(args.loopName, args.projectDir, args.dryRun) - cleanupSandbox(args.loopName, args.dryRun) + const dataDir = loadPluginConfig().dataDir || resolveDataDir() + const worktreesRoot = join(dataDir, 'worktrees') + + const runtime = createMsbRuntime({ log: console.log, error: console.error, debug: () => {} }) + + await cleanupForgeDb(args.loopName, args.dryRun, dataDir) + await cleanupOpencodeDb(args.loopName, args.dryRun) + await cleanupWorktreeDirectory(args.loopName, args.dryRun, worktreesRoot) + await cleanupGitWorktree(args.loopName, args.projectDir, args.dryRun, worktreesRoot) + const sandboxClean = await cleanupSandbox(args.loopName, args.dryRun, runtime) + if (!sandboxClean) { + console.error('\nCleanup incomplete: the msb sandbox could not be established as removed (inventory query failed or removal failed), so it may still be running.') + process.exit(1) + } console.log(`\n${args.dryRun ? 'Dry run complete.' : 'Cleanup complete.'}`) } -main() +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/src/agents/auditor.ts b/src/agents/auditor.ts index 4cb9169fa..a7be7144b 100644 --- a/src/agents/auditor.ts +++ b/src/agents/auditor.ts @@ -1,6 +1,5 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' -import { hasSectionSummaryMarkers } from '../utils/section-summary' import { AUDIT_ONLY_STRUCTURAL_DENY_PERMISSIONS, SHARED_STRUCTURAL_DENY_PERMISSIONS } from '../constants/loop' export const AUDITOR_TOOL_EXCLUDES = [ @@ -16,9 +15,6 @@ function buildLoopPrompt(promptsDir?: string): string { const base = buildBasePrompt(promptsDir) const loop = loadPrompt(['agents', 'auditor-loop-addendum.md'], promptsDir) const final = loadPrompt(['agents', 'auditor-final-audit-addendum.md'], promptsDir) - if (!hasSectionSummaryMarkers(loop)) { - console.warn('[forge] auditor-loop-addendum.md is missing section-summary markers; loop section parsing may fail') - } return `${base}\n\n${loop}\n\n${final}` } diff --git a/src/hooks/forge-session-attach.ts b/src/hooks/forge-session-attach.ts index 8ec8d38fa..dc58196fb 100644 --- a/src/hooks/forge-session-attach.ts +++ b/src/hooks/forge-session-attach.ts @@ -338,7 +338,7 @@ async function resolveAttachSandbox( worktreeDir: string | undefined, ): Promise<{ enabled: boolean; containerName?: string }> { // Worktree-only when the loop opted out, or when the sandbox isn't actually usable - // (sandbox disabled via config, or no Docker manager). Without a manager a sandbox + // (sandbox disabled via config, or no msb manager). Without a manager a sandbox // container cannot exist, so the workspace's stale `sandboxEnabled` flag must not be // trusted — degrade to host worktree execution so `bash` stays allowed. if (cfg?.sandboxEnabled === false) return { enabled: false } diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index 560516d0b..54599db83 100644 --- a/src/hooks/loop.ts +++ b/src/hooks/loop.ts @@ -14,6 +14,7 @@ import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-r import type { LoopTransitionsRepo } from '../storage/repos/loop-transitions-repo' import type { PlanAmendmentsRepo } from '../storage/repos/plan-amendments-repo' import type { PendingTeardownRegistry } from '../workspace/pending-teardown' +import type { GitService } from '../utils/git-service' export interface LoopEventHandler { onEvent(input: { event: { type: string; properties?: Record } }): Promise @@ -53,6 +54,7 @@ export function createLoopEventHandler( loopTransitionsRepo?: LoopTransitionsRepo, planAmendmentsRepo?: PlanAmendmentsRepo, directory?: string, + gitService?: GitService, ): LoopEventHandler { const loop = createLoop({ directory, @@ -71,6 +73,7 @@ export function createLoopEventHandler( loopSessionUsageRepo, loopTransitionsRepo, planAmendmentsRepo, + gitService, onTerminated: async (state, reason) => { await performTerminationSideEffects(state, reason, state.sessionId, { client: forgeClient, diff --git a/src/hooks/sandbox-tools.ts b/src/hooks/sandbox-tools.ts index 7bedace91..c0af7f467 100644 --- a/src/hooks/sandbox-tools.ts +++ b/src/hooks/sandbox-tools.ts @@ -55,7 +55,7 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t try { const result = await executeSandboxGlob( - { runtime, containerName, hostDir: sandbox.hostDir, envFile: sandbox.envFile }, + { runtime, containerName, hostDir: sandbox.hostDir }, args.pattern, args.path, ) @@ -74,7 +74,7 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t try { const result = await executeSandboxGrep( - { runtime, containerName, hostDir: sandbox.hostDir, envFile: sandbox.envFile }, + { runtime, containerName, hostDir: sandbox.hostDir }, args.pattern, { path: args.path, include: args.include }, ) diff --git a/src/hooks/shell-env.ts b/src/hooks/shell-env.ts index 4ba72d6ea..eff78c5a0 100644 --- a/src/hooks/shell-env.ts +++ b/src/hooks/shell-env.ts @@ -1,7 +1,7 @@ import type { Hooks } from '@opencode-ai/plugin' import type { Logger } from '../types' import type { SandboxContext } from '../sandbox/context' -import { SHIM_ENV_CONTAINER, SHIM_ENV_ENV_FILE, SHIM_ENV_HOST_SHELL } from '../sandbox/shell-shim' +import { SHIM_ENV_CONTAINER, SHIM_ENV_HOST_SHELL } from '../sandbox/shell-shim' export interface ShellEnvHookDeps { /** Resolves the sandbox context for a session through the unified loop-first resolver. */ @@ -13,8 +13,8 @@ export interface ShellEnvHookDeps { /** * Feeds the sandbox shell shim: for sessions that resolve to a sandbox context (a loop sandbox or - * an acknowledged host-session sandbox), injects the container name (and env-file path) so the shim - * routes the command into the microVM via `sbx exec`. Every other session gets no container env, so + * an acknowledged host-session sandbox), injects the container name so the shim + * routes the command into the microVM via `msb exec`. Every other session gets no container env, so * the shim falls through to the host shell — restoring the user's own configured shell when they had * one. * @@ -28,7 +28,6 @@ export function createShellEnvHook(deps: ShellEnvHookDeps): NonNullable 0 && !isForgeWorktreeDir(dataDir, directory)) { + publishToast({ + client: forgeClient, + directory, + logger, + title: 'Forge sandbox config', + message: legacySandboxWarnings.join(' '), + variant: 'warning', + duration: 10_000, + }) + } + emitLoopPermissionConfigWarnings(config, dataDir, directory, { logger, onWarnings: (warnings) => { @@ -330,7 +342,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { }) let sandboxManager: ReturnType | null = null - const runtime = createSbxRuntime(logger) + const runtime = createMsbRuntime(logger) if (!isSandboxConfigEnabled(config)) { logger.log('Sandbox disabled via config (sandbox.enabled=false); running in worktree-only mode') } else { @@ -338,7 +350,6 @@ export function createForgePlugin(config: PluginConfig): Plugin { try { sandboxManager = createSandboxManager(runtime, { image: config.sandbox?.image ?? DEFAULT_SANDBOX_IMAGE, - dataDir, toolOutputDir: resolveOpencodeToolOutputDir(), tmpDir: resolveOpencodeTmpDir(), sourceProjectDir: projectRoot, @@ -351,22 +362,21 @@ export function createForgePlugin(config: PluginConfig): Plugin { }, logger, defaultGitService) logger.log('Sandbox manager initialized') } catch (err) { - logger.error('Failed to initialize sbx sandbox manager', err) + logger.error('Failed to initialize msb sandbox manager; refusing to run worktree-only while sandbox is enabled', err) + throw err } } // Sandbox shell routing: opencode's native bash tool is pointed at a shim (via the `shell` // config key) that routes commands into the loop container when the shell.env hook injects // the container name. Without a working shim there is no safe way to route sandbox loop - // commands, so degrade to worktree-only mode rather than silently executing on the host. - // Known ceiling: the shim is POSIX sh, so Windows hosts run worktree-only; a cmd/pwsh shim - // would be the upgrade path. + // commands, so refuse to start with the sandbox enabled rather than silently executing on + // the host. The shim is POSIX sh, so Windows hosts (no shim) fail closed too. let shellShimPath: string | null = null if (sandboxManager) { shellShimPath = process.platform === 'win32' ? null : ensureShellShim(dataDir, logger) if (!shellShimPath) { - logger.error('Sandbox shell shim unavailable; falling back to worktree-only mode') - sandboxManager = null + throw new Error('Sandbox shell shim unavailable on this host; refusing to run worktree-only mode while sandbox is enabled') } } // The shell the user had configured before forge overrode `shell` with the shim; injected @@ -391,7 +401,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { directory, logger, title: 'Sandbox unavailable', - message: describeSbxUnavailable(available), + message: describeMsbUnavailable(available), variant: 'warning', duration: 10_000, }) @@ -490,7 +500,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { } } - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => config, sandboxManager || undefined, dataDir, config.loop, sectionPlansRepo, notifyLoopChange, pendingTeardowns, loopSessionUsageRepo, loopTransitionsRepo, planAmendmentsRepo, directory) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => config, sandboxManager || undefined, dataDir, config.loop, sectionPlansRepo, notifyLoopChange, pendingTeardowns, loopSessionUsageRepo, loopTransitionsRepo, planAmendmentsRepo, directory, defaultGitService) const promptsDir = resolvePromptsDir() const agents = buildAgents(promptsDir) diff --git a/src/install/cli.ts b/src/install/cli.ts index d9da4eb8b..a4bf1ac92 100644 --- a/src/install/cli.ts +++ b/src/install/cli.ts @@ -8,6 +8,8 @@ import { resolveConfigDir, resolveConfigPath, resolveBundledConfigPath, + resolveTuiConfigPath, + resolveVendorDir, } from './paths' import { runInteractiveInstall, @@ -16,6 +18,19 @@ import { type InstallSummary, type OrphanChoice, } from './installer' +import { + disableConfigRegistration, + ensureTuiRegistration, + findConfigRegistrations, + linkPlugin, + removeTuiRegistration, + resolveTuiEntry, + unlinkPlugin, + unvendorPlugin, + vendorPlugin, + VENDORED_TUI_SPEC, + type TuiRegistrationResult, +} from './plugin-link' import type { OrphanFile, PlannedFile } from '../utils/bundled-sync' interface CliOptions { @@ -23,10 +38,11 @@ interface CliOptions { prune: boolean dryRun: boolean help: boolean + link: 'prompt' | 'external' | 'vendored' | 'off' } function parseArgs(argv: string[]): CliOptions { - const opts: CliOptions = { mode: 'interactive', prune: true, dryRun: false, help: false } + const opts: CliOptions = { mode: 'interactive', prune: true, dryRun: false, help: false, link: 'prompt' } for (const arg of argv) { switch (arg) { case '-f': @@ -51,6 +67,15 @@ function parseArgs(argv: string[]): CliOptions { case '--no-prune': opts.prune = false break + case '--link': + opts.link = 'external' + break + case '--vendor': + opts.link = 'vendored' + break + case '--unlink': + opts.link = 'off' + break case '-h': case '--help': opts.help = true @@ -73,12 +98,22 @@ installed silently; when an installed file differs from the bundle you are prompted to overwrite or keep your version. Orphaned files from older layouts are offered for removal. +The --link mode always loads the current build, so a rebuild needs no +reinstall, but is tied to this machine's checkout path. The --vendor mode copies +forge into the config dir, so the whole config folder can be version-controlled +and moved to another machine, at the cost of re-running after an upgrade. Both +modes write the tui.json entry, because the TUI plugin is not auto-loaded from +the plugin directory. + Options: -f, --force Overwrite all conflicting files and delete all orphans -k, --keep Keep all local versions; never delete anything -y, --yes Non-interactive: keep edited files, prune orphans -n, --dry-run Show what would change without writing anything --no-prune Do not touch orphaned files (only report them) + --link Install into opencode's plugin dir from the current build + --vendor Install a self-contained copy into the config dir (portable) + --unlink Remove the plugin-dir installation -h, --help Show this help ` @@ -97,8 +132,13 @@ function showDiff(file: PlannedFile): void { stdout.write(`\n${res.stdout || ' (no textual diff)\n'}\n`) } +/** Interactive yes/no question for the plugin-directory step. */ +interface LinkPrompter { + confirm(question: string, defaultYes: boolean): Promise +} + /** Interactive prompter backed by a readline interface. */ -function interactivePrompter(rl: ReturnType): InstallerPrompter { +function interactivePrompter(rl: ReturnType): InstallerPrompter & LinkPrompter { return { async fileConflict(file: PlannedFile): Promise { const label = file.state === 'edited' ? 'locally edited' : file.state @@ -129,6 +169,19 @@ function interactivePrompter(rl: ReturnType): InstallerP stdout.write(' Please answer d or k.\n') } }, + async confirm(question: string, defaultYes: boolean): Promise { + for (;;) { + const answer = ( + await rl.question(` ${question} — [y]es / [n]o (default ${defaultYes ? 'yes' : 'no'}): `) + ) + .trim() + .toLowerCase() + if (answer === 'y' || answer === 'yes') return true + if (answer === 'n' || answer === 'no') return false + if (answer === '') return defaultYes + stdout.write(' Please answer y or n.\n') + } + }, } } @@ -187,6 +240,97 @@ function printSummary(summary: InstallSummary): void { } } +function reportTuiRegistration(tui: TuiRegistrationResult): void { + stdout.write(` ${tui.action}: ${tui.file} ${JSON.stringify(tui.spec)}\n`) + if (tui.action === 'failed') process.exitCode = 1 +} + +async function handleConfigRegistrations( + opts: CliOptions, + prompter: InstallerPrompter & Partial, +): Promise { + for (const reg of findConfigRegistrations()) { + stdout.write(` config registration: ${reg.file}:${reg.line} "${reg.spec}"\n`) + stdout.write(' Leaving it in place makes opencode load forge twice under the same id (oc-forge).\n') + if (prompter.confirm) { + const disable = await prompter.confirm('Disable this entry?', true) + if (!disable) { + stdout.write(' kept: left in place\n') + continue + } + stdout.write(` disabled: ${disableConfigRegistration(reg, { dryRun: opts.dryRun })}\n`) + } else { + stdout.write( + ' warning: not modified. Re-run interactively to disable it, or remove this entry by hand.\n', + ) + } + } +} + +/** + * Perform the plugin-directory step after the bundle install: install or remove + * the server re-export shim, register the TUI entry, and surface any + * double-loading config registrations. + */ +async function runPluginLinkStep( + opts: CliOptions, + prompter: InstallerPrompter & Partial, +): Promise { + if (opts.link === 'prompt') { + if (!prompter.confirm) return + const yes = await prompter.confirm("Install forge into opencode's plugin dir?", true) + if (!yes) return + const selfContained = await prompter.confirm('Make it self-contained so the config folder is portable?', false) + opts.link = selfContained ? 'vendored' : 'external' + } + stdout.write('\nPlugin directory:\n') + if (opts.link === 'off') { + const unlinked = unlinkPlugin({ dryRun: opts.dryRun }) + stdout.write(` ${unlinked.action}: ${unlinked.shimPath}\n`) + const unvendored = unvendorPlugin({ dryRun: opts.dryRun }) + stdout.write(` ${unvendored}: ${resolveVendorDir()}\n`) + const tuiRemoved = removeTuiRegistration({ dryRun: opts.dryRun }) + stdout.write(` ${tuiRemoved}: ${resolveTuiConfigPath()}\n`) + return + } + if (opts.link === 'vendored') { + const vendor = vendorPlugin({ dryRun: opts.dryRun }) + if (vendor.action !== 'vendored') { + stdout.write(` ${vendor.action}: ${vendor.vendorDir}\n`) + if (vendor.action === 'missing-entry') { + stdout.write(' The built package could not be found. Run `pnpm build` first, then re-run.\n') + } + process.exitCode = 1 + return + } + stdout.write(` copied: ${vendor.vendorDir}\n`) + list('copied', vendor.copied) + list('missing', vendor.missing) + const linked = linkPlugin({ dryRun: opts.dryRun, mode: 'vendored' }) + stdout.write(` ${linked.action}: ${linked.shimPath}\n`) + if (linked.target) stdout.write(` re-exports: ${linked.target}\n`) + reportTuiRegistration(ensureTuiRegistration({ dryRun: opts.dryRun, spec: VENDORED_TUI_SPEC })) + await handleConfigRegistrations(opts, prompter) + return + } + const linked = linkPlugin({ dryRun: opts.dryRun, mode: 'external' }) + if (linked.action === 'missing-entry') { + stdout.write(` missing-entry: ${linked.shimPath}\n`) + stdout.write(' The built server entry could not be found. Run `pnpm build` first, then re-run.\n') + process.exitCode = 1 + return + } + stdout.write(` ${linked.action}: ${linked.shimPath}\n`) + if (linked.target) stdout.write(` re-exports: ${linked.target}\n`) + const tuiEntry = resolveTuiEntry() + if (tuiEntry) { + reportTuiRegistration(ensureTuiRegistration({ dryRun: opts.dryRun, spec: tuiEntry })) + } else { + stdout.write(' warning: TUI entry skipped because dist/tui.js was not found.\n') + } + await handleConfigRegistrations(opts, prompter) +} + async function main(): Promise { const opts = parseArgs(process.argv.slice(2)) if (opts.help) { @@ -213,12 +357,15 @@ async function main(): Promise { const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null try { - const prompter = rl ? interactivePrompter(rl) : autoPrompter(opts.mode as 'force' | 'keep' | 'yes') + const prompter: InstallerPrompter & Partial = rl + ? interactivePrompter(rl) + : autoPrompter(opts.mode as 'force' | 'keep' | 'yes') const summary = await runInteractiveInstall(getBundleSpecs(), prompter, { prune: opts.prune, dryRun: opts.dryRun, }) printSummary(summary) + await runPluginLinkStep(opts, prompter) } finally { rl?.close() } diff --git a/src/install/paths.ts b/src/install/paths.ts index 78d08e0fb..bd2043ae3 100644 --- a/src/install/paths.ts +++ b/src/install/paths.ts @@ -1,6 +1,6 @@ import { homedir, platform } from 'os' -import { dirname, join } from 'path' -import { fileURLToPath } from 'url' +import { join } from 'path' +import { resolveShippedRoot } from '../utils/shipped-paths' /** * Single source of truth for every filesystem location the bundled-asset @@ -14,12 +14,15 @@ import { fileURLToPath } from 'url' */ /** - * Directory containing the loaded plugin module set — `dist/` in a published - * build, `src/` when running from source. This module lives at - * `/install/paths.(ts|js)`, so step up one level. + * Root of the shipped module tree — `dist/` in a published build, `src/` when + * running from source. Bundling-safe: it walks up from this module to the + * nearest `dist`/`src` ancestor instead of assuming a fixed relative position, + * so it resolves identically for the unbundled layout + * (`/install/paths.js`), a future bundled `dist/index.js`, and + * source runs (`/src/install/paths.ts`). */ export function resolvePluginDir(): string { - return join(dirname(fileURLToPath(import.meta.url)), '..') + return resolveShippedRoot(import.meta.url) } /** `~/.config/opencode` (or the `XDG_CONFIG_HOME`/Windows equivalent). */ @@ -34,6 +37,11 @@ export function resolveConfigPath(): string { return join(resolveConfigDir(), 'forge-config.jsonc') } +/** opencode's TUI config file, which lists plugin entries for the TUI surface. */ +export function resolveTuiConfigPath(): string { + return join(resolveConfigDir(), 'tui.json') +} + /** Bundled default config shipped with the package. */ export function resolveBundledConfigPath(): string { return join(resolvePluginDir(), '..', 'forge-config.jsonc') @@ -69,6 +77,51 @@ export function resolveBundledSkillsDir(): string { return join(resolvePluginDir(), '..', 'skills') } +/** Filename of the one-line server re-export shim installed into opencode's config dir. */ +export const PLUGIN_SHIM_FILENAME = 'opencode-forge.js' + +/** opencode's global plugin scan directory (`/plugin`, non-recursive glob). */ +export function resolvePluginShimDir(): string { + return join(resolveConfigDir(), 'plugin') +} + +/** Absolute path of the installed server re-export shim. */ +export function resolvePluginShimPath(): string { + return join(resolvePluginShimDir(), PLUGIN_SHIM_FILENAME) +} + +/** Directory name of the vendored package copy inside the plugin shim dir. */ +export const VENDOR_DIR_NAME = 'opencode-forge' + +/** Absolute path of the vendored forge package copy (`/plugin/opencode-forge`). */ +export function resolveVendorDir(): string { + return join(resolvePluginShimDir(), VENDOR_DIR_NAME) +} + +/** + * The package-layout assets copied verbatim into the vendored dir. They mirror + * the npm package layout because forge resolves its bundled assets as siblings + * of the loaded module's package root (`/../forge-config.jsonc`, + * `container`, `skills`), so a vendored copy must preserve that sibling + * structure for the sandbox template and bundled skill sync to resolve. + */ +export const VENDORED_ASSETS: readonly string[] = ['package.json', 'forge-config.jsonc', 'dist', 'container', 'skills'] + +/** + * Ordered candidates for the built server entry. The first hits a published/built + * layout where this module lives in `dist/`; the second covers running the + * installer from source (`pnpm setup` runs `bun src/install/cli.ts`, so + * `resolvePluginDir()` is `src/`) where the real entry is the sibling `dist/index.js`. + */ +export function resolveServerEntryCandidates(): string[] { + return [join(resolvePluginDir(), 'index.js'), join(resolvePluginDir(), '..', 'dist', 'index.js')] +} + +/** Candidate filenames for the global opencode config, in lookup order. */ +export function resolveOpencodeConfigCandidates(): string[] { + return ['opencode.jsonc', 'opencode.json'].map((f) => join(resolveConfigDir(), f)) +} + /** Declarative description of one installable bundle directory. */ export interface BundleSpec { /** Manifest name and stable identifier. */ diff --git a/src/install/plugin-link.ts b/src/install/plugin-link.ts new file mode 100644 index 000000000..85a9db01d --- /dev/null +++ b/src/install/plugin-link.ts @@ -0,0 +1,460 @@ +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'path' +import { fileURLToPath } from 'url' +import { applyEdits, findNodeAtLocation, modify, parseTree } from 'jsonc-parser' +import type { Node } from 'jsonc-parser' +import { + resolveConfigDir, + resolveOpencodeConfigCandidates, + resolvePluginShimPath, + resolveServerEntryCandidates, + resolveTuiConfigPath, + resolveVendorDir, + VENDORED_ASSETS, +} from './paths' + +/** Current on-disk state of the installed plugin shim. */ +export interface PluginShimState { + path: string + present: boolean + /** Entry specifier the installed shim currently re-exports, when parseable — an absolute path or a relative specifier. */ + target?: string +} + +/** How the installed shim locates forge's server entry. */ +export type ShimMode = 'external' | 'vendored' + +/** A `plugin` array entry in the global opencode config that declares forge. */ +export interface ConfigRegistration { + /** Absolute path of the global opencode config that declares forge. */ + file: string + /** Raw specifier text as written in the plugin array. */ + spec: string + /** 1-based line number of the entry. */ + line: number +} + +export interface LinkResult { + action: 'created' | 'updated' | 'unchanged' | 'missing-entry' + shimPath: string + target?: string +} + +export interface UnlinkResult { + action: 'removed' | 'absent' + shimPath: string +} + +/** Render the one-line server re-export shim installed into opencode's config dir. */ +export function buildShimSource(serverEntry: string): string { + return `export { default } from ${JSON.stringify(serverEntry)}\n` +} + +/** + * Relative specifier a vendored shim re-exports. opencode resolves it against + * the shim's own directory (`/plugin/`), so the folder stays + * portable to another machine. + */ +export const VENDORED_SERVER_SPEC = './opencode-forge/dist/index.js' + +/** Relative specifier for `tui.json`, resolved by opencode against the config dir. */ +export const VENDORED_TUI_SPEC = './plugin/opencode-forge/dist/tui.js' + +/** First built server entry candidate that exists on disk, if any. */ +export function resolveServerEntry(): string | undefined { + return resolveServerEntryCandidates().find((candidate) => existsSync(candidate)) +} + +/** + * Package root owning the built server entry — the directory above `dist/`, or + * the entry's parent when the module layout has no `dist` — or undefined when + * no built entry exists on disk. + */ +export function resolvePackageRoot(): string | undefined { + const entry = resolveServerEntry() + if (!entry) return undefined + const entryDir = dirname(entry) + return basename(entryDir) === 'dist' ? dirname(entryDir) : resolve(entryDir, '..') +} + +/** + * Built TUI entry of the package (`dist/tui.js`), or undefined when the build + * output is not present on disk. The TUI surface is loaded only from `tui.json`, + * so this is the spec written into that file for the external mode. + */ +export function resolveTuiEntry(): string | undefined { + const root = resolvePackageRoot() + if (!root) return undefined + const entry = join(root, 'dist', 'tui.js') + return existsSync(entry) ? entry : undefined +} + +/** Read the installed shim, extracting its re-export target when parseable. */ +export function readPluginShimState(): PluginShimState { + const path = resolvePluginShimPath() + let content: string + try { + content = readFileSync(path, 'utf-8') + } catch { + return { path, present: false } + } + const match = content.match(/^export \{ default \} from ([^\n]*)\n?$/) + if (!match) { + return { path, present: true } + } + try { + return { path, present: true, target: JSON.parse(match[1].trimEnd()) as string } + } catch { + return { path, present: true } + } +} + +/** True when the installed shim targets the vendored relative specifier. */ +export function isVendoredShim(state: PluginShimState): boolean { + return state.target === VENDORED_SERVER_SPEC +} + +/** Install the server re-export shim into opencode's global plugin directory. */ +export function linkPlugin(options: { dryRun: boolean; mode?: ShimMode }): LinkResult { + const shimPath = resolvePluginShimPath() + const entry = resolveServerEntry() + if (!entry) { + return { action: 'missing-entry', shimPath } + } + const target = options.mode === 'vendored' ? VENDORED_SERVER_SPEC : entry + const source = buildShimSource(target) + const existing = safeReadText(shimPath) + if (existing === source) { + return { action: 'unchanged', shimPath, target } + } + if (!options.dryRun) { + mkdirSync(dirname(shimPath), { recursive: true }) + writeFileSync(shimPath, source) + } + return { action: existing === undefined ? 'created' : 'updated', shimPath, target } +} + +/** Remove the installed shim from opencode's global plugin directory. */ +export function unlinkPlugin(options: { dryRun: boolean }): UnlinkResult { + const shimPath = resolvePluginShimPath() + if (!existsSync(shimPath)) { + return { action: 'absent', shimPath } + } + if (!options.dryRun) { + rmSync(shimPath, { force: true }) + } + return { action: 'removed', shimPath } +} + +export interface VendorResult { + action: 'vendored' | 'missing-entry' | 'failed' + vendorDir: string + copied: string[] + missing: string[] +} + +/** + * Copy the installed package's assets into the vendored dir so the config + * folder is self-contained. Each asset's destination is removed before copying + * so stale files never survive an upgrade, and assets absent from the package + * root are recorded rather than failing the whole operation. + */ +export function vendorPlugin(options: { dryRun: boolean }): VendorResult { + const vendorDir = resolveVendorDir() + const root = resolvePackageRoot() + if (!root) { + return { action: 'missing-entry', vendorDir, copied: [], missing: [] } + } + try { + const copied: string[] = [] + const missing: string[] = [] + for (const name of VENDORED_ASSETS) { + const src = join(root, name) + if (!existsSync(src)) { + missing.push(name) + continue + } + copied.push(name) + if (!options.dryRun) { + const dest = join(vendorDir, name) + rmSync(dest, { recursive: true, force: true }) + cpSync(src, dest, { recursive: true }) + } + } + return { action: 'vendored', vendorDir, copied, missing } + } catch { + return { action: 'failed', vendorDir, copied: [], missing: [] } + } +} + +/** Remove the vendored package copy from opencode's global plugin directory. */ +export function unvendorPlugin(options: { dryRun: boolean }): 'removed' | 'absent' { + const vendorDir = resolveVendorDir() + if (!existsSync(vendorDir)) { + return 'absent' + } + if (!options.dryRun) { + rmSync(vendorDir, { recursive: true, force: true }) + } + return 'removed' +} + +export interface TuiRegistrationResult { + action: 'created' | 'added' | 'updated' | 'present' | 'failed' + file: string + spec: string +} + +const TUI_MODIFY_OPTIONS = { formattingOptions: { insertSpaces: true, tabSize: 2 } } as const + +function tuiConfigSource(spec: string): string { + return `{\n "$schema": "https://opencode.ai/tui.json",\n "plugin": [${JSON.stringify(spec)}]\n}\n` +} + +/** + * Ensure `tui.json` lists the given plugin spec. opencode loads the TUI surface + * only from the `plugin` array in this file — there is no directory scan — so + * the entry must be written explicitly. The file is parsed and edited as JSONC + * so existing comments and trailing commas survive, and an already-present or + * stale forge entry is handled without rewriting unrelated content. + */ +export function ensureTuiRegistration(options: { dryRun: boolean; spec: string }): TuiRegistrationResult { + const file = resolveTuiConfigPath() + const report = { file, spec: options.spec } + let text: string + try { + text = readFileSync(file, 'utf-8') + } catch { + if (!options.dryRun) { + try { + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, tuiConfigSource(options.spec)) + return { action: 'created', ...report } + } catch { + return { action: 'failed', ...report } + } + } + return { action: 'created', ...report } + } + try { + const { plugin, entries } = scanPluginArray(text, resolveConfigDir()) + let next = text + let action: 'added' | 'updated' | 'present' + if (plugin) { + if (entries.some((entry) => entry.spec === options.spec)) { + action = 'present' + } else if (entries.length > 0) { + next = applyEdits(next, modify(next, ['plugin', entries[0].index], options.spec, TUI_MODIFY_OPTIONS)) + action = 'updated' + } else { + next = applyEdits(next, modify(next, ['plugin', -1], options.spec, TUI_MODIFY_OPTIONS)) + action = 'added' + } + } else { + next = applyEdits(next, modify(next, ['plugin'], [options.spec], TUI_MODIFY_OPTIONS)) + action = 'added' + } + if (!options.dryRun) { + writeFileSync(file, next) + } + return { action, ...report } + } catch { + return { action: 'failed', ...report } + } +} + +/** + * Remove every forge entry from the `tui.json` `plugin` array, highest index + * first so earlier indices stay valid. Returns `'absent'` when the file or any + * forge entry does not exist. + */ +export function removeTuiRegistration(options: { dryRun: boolean }): 'removed' | 'absent' | 'failed' { + const file = resolveTuiConfigPath() + let text: string + try { + text = readFileSync(file, 'utf-8') + } catch { + return 'absent' + } + try { + const { entries } = scanPluginArray(text, resolveConfigDir()) + if (entries.length === 0) return 'absent' + let next = text + for (const { index } of [...entries].sort((a, b) => b.index - a.index)) { + next = applyEdits(next, modify(next, ['plugin', index], undefined, TUI_MODIFY_OPTIONS)) + } + if (!options.dryRun) { + writeFileSync(file, next) + } + return 'removed' + } catch { + return 'failed' + } +} + +/** + * Every `plugin` array entry in the global opencode config that refers to forge, + * either by npm package name (`opencode-forge[@version][/subpath]`) or by a + * filesystem path whose normalized form ends in a forge `dist` layout. + */ +export function findConfigRegistrations(): ConfigRegistration[] { + const regs: ConfigRegistration[] = [] + for (const file of resolveOpencodeConfigCandidates()) { + try { + const text = readFileSync(file, 'utf-8') + for (const { node, spec } of scanPluginArray(text, dirname(file)).entries) { + regs.push({ file, spec, line: lineOf(text, node.offset) }) + } + } catch { + continue + } + } + return regs +} + +/** + * Disable a config-array registration so opencode stops double-loading forge. + * `.jsonc` entries that sit alone on their line(s) are commented out in place so + * the user's value stays recoverable; everything else is removed structurally + * with jsonc-parser, keeping `.json` files valid for strict readers. + */ +export function disableConfigRegistration( + reg: ConfigRegistration, + options: { dryRun: boolean }, +): 'commented' | 'removed' | 'failed' { + try { + const text = readFileSync(reg.file, 'utf-8') + const root = parseTree(text, undefined, { allowTrailingComma: true }) + if (!root) return 'failed' + const pluginNode = findNodeAtLocation(root, ['plugin']) + if (!pluginNode || pluginNode.type !== 'array' || !pluginNode.children) return 'failed' + const index = pluginNode.children.findIndex( + (child) => entrySpec(child) === reg.spec && lineOf(text, child.offset) === reg.line, + ) + if (index === -1) return 'failed' + const node = pluginNode.children[index] + + if (extname(reg.file) === '.jsonc' && isAloneOnLines(text, node)) { + if (!options.dryRun) { + writeFileSync(reg.file, commentOut(text, node)) + } + return 'commented' + } + + const edits = modify(text, ['plugin', index], undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }) + if (!options.dryRun) { + writeFileSync(reg.file, applyEdits(text, edits)) + } + return 'removed' + } catch { + return 'failed' + } +} + +function safeReadText(path: string): string | undefined { + try { + return readFileSync(path, 'utf-8') + } catch { + return undefined + } +} + +function entrySpec(node: Node): string | undefined { + if (node.type === 'string') return node.value + const first = node.type === 'array' ? node.children?.[0] : undefined + if (first && first.type === 'string') return first.value + return undefined +} + +interface ForgeEntry { + index: number + spec: string + node: Node +} + +function scanPluginArray(text: string, baseDir: string): { plugin?: Node; entries: ForgeEntry[] } { + const root = parseTree(text, undefined, { allowTrailingComma: true }) + if (!root) return { entries: [] } + const plugin = findNodeAtLocation(root, ['plugin']) + if (!plugin || plugin.type !== 'array' || !plugin.children) return { plugin, entries: [] } + const entries: ForgeEntry[] = [] + plugin.children.forEach((child, index) => { + const spec = entrySpec(child) + if (spec && isForgeRef(spec, baseDir)) entries.push({ index, spec, node: child }) + }) + return { plugin, entries } +} + +function isForgeRef(spec: string, baseDir: string): boolean { + return /^opencode-forge(?:@[^/]+)?(?:\/.*)?$/.test(spec) || isForgePath(spec, baseDir) +} + +function pathLikeSpec(spec: string): string | undefined { + if (spec.startsWith('file://')) { + try { + return fileURLToPath(spec) + } catch { + return undefined + } + } + if (spec.startsWith('./') || spec.startsWith('../')) return spec + if (spec.startsWith('~/')) return join(homedir(), spec.slice(2)) + if (isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return spec + return undefined +} + +/** + * A path entry refers to forge when it resolves inside the vendored package + * dir, or when it points into a `dist` directory whose owning package is + * actually named `opencode-forge`. Matching the `dist` suffix alone would + * falsely claim any unrelated local plugin built into `dist/`. + */ +function isForgePath(spec: string, baseDir: string): boolean { + const pathLike = pathLikeSpec(spec) + if (!pathLike) return false + const normalized = normalize(resolve(baseDir, pathLike)) + const vendorDir = normalize(resolveVendorDir()) + if (normalized === vendorDir || normalized.startsWith(vendorDir + sep)) return true + const base = basename(normalized) + const distDir = base === 'index.js' || base === 'tui.js' ? dirname(normalized) : normalized + if (basename(distDir) !== 'dist') return false + return readPackageName(dirname(distDir)) === 'opencode-forge' +} + +function readPackageName(dir: string): string | undefined { + try { + const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) as { name?: unknown } + return typeof parsed.name === 'string' ? parsed.name : undefined + } catch { + return undefined + } +} + +function lineOf(text: string, offset: number): number { + return text.slice(0, offset).split('\n').length +} + +function spannedLines(text: string, offset: number, length: number): { start: number; end: number } { + const start = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1 + const newline = text.indexOf('\n', offset + length) + return { start, end: newline === -1 ? text.length : newline } +} + +function isAloneOnLines(text: string, node: Node): boolean { + const { start, end } = spannedLines(text, node.offset, node.length) + const rest = text.slice(start, end).replace(text.slice(node.offset, node.offset + node.length), '') + return rest.trim() === '' || rest.trim() === ',' +} + +function commentOut(text: string, node: Node): string { + const { start, end } = spannedLines(text, node.offset, node.length) + const commented = text + .slice(start, end) + .split('\n') + .map((line) => line.replace(/^(\s*)/, '$1// ')) + .join('\n') + return text.slice(0, start) + commented + text.slice(end) +} diff --git a/src/loop/prompts.ts b/src/loop/prompts.ts index b56c46c81..b87851aea 100644 --- a/src/loop/prompts.ts +++ b/src/loop/prompts.ts @@ -23,6 +23,24 @@ export interface PromptContext { getFindingRecurrence(loopName?: string): Map } +/** + * The one and only section-summary block template shown to the loop auditor. + * The loop runner parses this block mechanically (parseSectionSummary), so the + * template must never be hand-written elsewhere — prompt markdown files refer + * to it, they do not restate it. + */ +const SECTION_SUMMARY_TEMPLATE = `${SECTION_SUMMARY_START_MARKER}\n### Done\n- bullets describing what was implemented\n### Deviations\n- bullets describing places implementation differs from this section plan, with reasons (or "none")\n### Follow-ups\n- bullets noting items deferred to later sections (or "none")\n${SECTION_SUMMARY_END_MARKER}` + +/** + * One-shot follow-up sent to the audit session when it reported no blocking + * findings for the section but omitted (or malformed) the section-summary + * block. Without the block the section counts as dirty and a full coder + * iteration is wasted on nothing. + */ +export function buildSectionSummaryRepromptText(): string { + return `Your previous audit response did not include a parseable section-summary block, and no blocking bug findings are recorded for this section.\n\n- If the section is clear: reply with ONLY the section-summary block below, reproducing the marker comments exactly.\n${SECTION_SUMMARY_TEMPLATE}\n- If the section is NOT clear: persist each blocking issue with review-write (severity: bug) and do not include the summary block.` +} + function formatSectionsSummary(digest: SectionDigestEntry[]): string { return digest.map(s => { let parts = `## Section ${s.index + 1}: ${s.title}` @@ -300,7 +318,7 @@ export function buildSectionAuditPrompt(ctx: PromptContext, state: LoopState): s header += buildCoderDecisionsAuditorBlock(ctx.getCoderDecisions(state.loopName)) - header += `\n\n---\nAudit instructions:\n- Use review-read to see findings for this section.\n- Delete resolved findings.\n- Write severity: bug findings for unmet acceptance criteria or failed verification (defaults to current section_index).\n- When the section is clear, end your response with:\n${SECTION_SUMMARY_START_MARKER}\n### Done\n- bullets describing what was implemented\n### Deviations\n- bullets describing places implementation differs from this section plan, with reasons (or "none")\n### Follow-ups\n- bullets noting items deferred to later sections (or "none")\n${SECTION_SUMMARY_END_MARKER}\n- If the completed work shows the plan can no longer achieve its objective as written, call \`plan-adjust\` with a rationale: pass \`currentSection\` to revise this section in place (write bug findings too if the existing work no longer satisfies it), and/or \`sections\` to replace the remaining sections. Never use it to relax acceptance criteria or verification; the objective is immutable. Prefer finishing the plan as written when viable.` + header += `\n\n---\nAudit instructions:\n- Review scope: this section's work is all uncommitted changes plus any commits made after the most recent \`section :\` checkpoint commit (\`git log --oneline\`; the first section has no checkpoint yet). Earlier sections are already committed and audited — read them as context only.\n- Use review-read to see findings for this section.\n- Delete resolved findings.\n- Write severity: bug findings for unmet acceptance criteria or failed verification (defaults to current section_index).\n- When the section is clear: run the proactive next-section check from your Adaptive plan adjustment rules, then end your response with the block below — when clean it may be your entire response:\n${SECTION_SUMMARY_TEMPLATE}\n- If the plan can no longer achieve its objective as written, call \`plan-adjust\` per your Adaptive plan adjustment rules. Never use it to relax acceptance criteria or verification; the objective is immutable. Prefer finishing the plan as written when viable.` const recurringBlock = buildRecurringFindingsAuditorBlock(ctx, state) if (recurringBlock) { @@ -418,7 +436,7 @@ export function buildFinalAuditPrompt(ctx: PromptContext, state: LoopState): str header += buildCoderDecisionsAuditorBlock(ctx.getCoderDecisions(state.loopName)) - header += `\n\n---\nFinal audit instructions:\n- Verify the master plan's top-level Verification commands and acceptance criteria.\n- Use the per-section ### Deviations entries to interpret discrepancies. If a discrepancy is explained by a deviation, accept it unless it materially breaks the master plan's top-level Verification.\n- Write findings with sectionIndex pointing to the section you believe contains the bug. Use crossSection: true only when the bug spans multiple sections.\n- The loop terminates automatically when there are no outstanding bug-severity findings. Do not write findings unless they describe real, blocking issues.` + header += `\n\n---\nFinal audit instructions:\n- Review scope: the loop's full accumulated changes — every \`section :\` checkpoint commit since this branch's merge-base with its base branch, plus all uncommitted and untracked changes.\n- Verify the master plan's top-level Verification commands and acceptance criteria.\n- Use the per-section ### Deviations entries to interpret discrepancies. If a discrepancy is explained by a deviation, accept it unless it materially breaks the master plan's top-level Verification.\n- Write findings with sectionIndex pointing to the section you believe contains the bug. Use crossSection: true only when the bug spans multiple sections.\n- The loop terminates automatically when there are no outstanding bug-severity findings. Do not write findings unless they describe real, blocking issues.` const recurringBlock = buildRecurringFindingsAuditorBlock(ctx, state) if (recurringBlock) { diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 84e0988bf..e3ce1cf74 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -44,6 +44,9 @@ import { findSessionAncestor } from '../utils/session-ancestry' import { classifyProviderLimit, extractErrorSignal } from './provider-limit' import { parseCoderDecisions } from '../utils/coder-decisions' import { resolvePostActionConfig } from './post-action-config' +import type { GitService } from '../utils/git-service' +import { commitWorktreeChanges } from '../workspace/worktree-commit' +import { buildSectionSummaryRepromptText } from './prompts' export interface LoopEvent { type: string @@ -75,6 +78,12 @@ export interface LoopRuntimeDeps { planAmendmentsRepo?: PlanAmendmentsRepo /** Optional injected LoopService (test seam). Defaults to a real one built from the repos. */ loopService?: LoopService + /** + * Git service used for per-section checkpoint commits. Optional: when absent + * (tests), sections are not checkpointed. Production wiring passes the real + * service from hooks/loop.ts. + */ + gitService?: GitService /** Optional parent-session lookup for ancestor-aware session→loop resolution (child/subagent support). */ getParentSessionId?: (sessionId: string) => Promise /** @@ -146,7 +155,7 @@ export interface Loop { export { isWorkspaceNotFoundError } from './runtime-workspace' export function createLoop(deps: LoopRuntimeDeps): Loop { - const { loopsRepo, plansRepo, reviewFindingsRepo, projectId, client, logger, getConfig, onTerminated, notify, loopConfig, sectionPlansRepo, loopSessionUsageRepo, loopTransitionsRepo, planAmendmentsRepo } = deps + const { loopsRepo, plansRepo, reviewFindingsRepo, projectId, client, logger, getConfig, onTerminated, notify, loopConfig, sectionPlansRepo, loopSessionUsageRepo, loopTransitionsRepo, planAmendmentsRepo, gitService } = deps // `runExclusive` (the in-loop lock using withStateLock) is declared later // in this function but is hoisted, so it's always available here. @@ -164,6 +173,34 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { const idleRetryAttempts = new Map() const stateLocks = new Map>() + /** + * Sections that already received their one summary re-prompt this audit + * round (loopName → sectionIndex). A section audit that reports zero + * blocking bugs but omits the parseable section-summary block gets exactly + * one follow-up prompt asking for the block before the loop falls back to a + * dirty rotation (which would burn a full coder iteration on nothing). + */ + const summaryRepromptedSections = new Map() + + /** + * Commit the just-completed section's work as a `section : ` + * checkpoint so the next section's audit can scope its review to changes + * made after this commit. Best-effort: failures are logged and never block + * section advancement — the next audit then simply sees the accumulated diff. + */ + function commitSectionCheckpoint(state: LoopState, idx: number): void { + if (!gitService) return + if (!state.worktree || !state.worktreeDir) return + const title = loopService.getSectionPlan(state, idx)?.title.trim() + const message = `section ${idx + 1}: ${title || 'checkpoint'}` + const outcome = commitWorktreeChanges(gitService, logger, state.worktreeDir, message) + if (outcome === 'committed') { + logger.log(`Loop: committed section checkpoint "${message}" for ${state.loopName ?? 'unknown'}`) + } else if (outcome === 'failed') { + logger.log(`Loop: section checkpoint commit failed for ${state.loopName ?? 'unknown'}; next section audit will see the accumulated diff`) + } + } + const instanceDirectory = deps.directory ? canonicalizePath(deps.directory) : null /** @@ -892,6 +929,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { idleRetryAttempts.delete(loopName) codingLaunchRecoveryAttempts.delete(loopName) coalescedLimitSessions.delete(loopName) + summaryRepromptedSections.delete(loopName) clearPromptPending(loopName, logger) clearPromptInFlight(loopName) @@ -1868,12 +1906,14 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { if (sectionSummary && sectionBugFindings.length === 0) { logger.log(`Loop: section ${idx} audit clean, marking completed`) + summaryRepromptedSections.delete(loopName) // Reset recurrence for this section so resolved findings don't falsely escalate later loopService.resetSectionRecurrence(loopName, idx) loopService.setLastAuditResult(loopName, auditText || '') loopService.completeSection(loopName, idx, sectionSummary) + commitSectionCheckpoint(currentState, idx) // Pre-check: rewind fast-path — all sections completed even though we // are not on the last one (possible after a rewind). This bypasses the @@ -1968,9 +2008,33 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { return } + // Clean-but-no-summary guard: the audit reported zero blocking bugs for + // this section but its response had no parseable summary block. Rotating + // to the coder would waste a full iteration with nothing to fix, so + // re-prompt the same audit session once for the block (or real findings). + if (!sectionSummary && sectionBugFindings.length === 0 && summaryRepromptedSections.get(loopName) !== idx) { + summaryRepromptedSections.set(loopName, idx) + logger.log(`Loop: section ${idx} audit has no blocking findings but no summary block; re-prompting auditor for ${loopName}`) + const repromptChoice = resolveLoopAuditorChoice(getConfig(), loopService, loopName, logger) + const { error } = await sendPromptWithFallback({ + loopName, + sessionId: currentState.sessionId, + promptText: buildSectionSummaryRepromptText(), + agent: 'auditor-loop', + model: repromptChoice.model, + variant: repromptChoice.variant, + }) + if (!error) { + watchdog.recordActivity(loopName, 'summary-reprompt') + return + } + logger.error(`Loop: summary re-prompt failed for ${loopName}; falling back to dirty rotation`, error) + } + const dirtyTrans = nextTransition(currentState, { type: 'section-dirty' }) if (dirtyTrans.kind !== 'rotate') return + summaryRepromptedSections.delete(loopName) logger.log(`Loop: section ${idx} audit dirty, retrying same section`) const nextIter = await nextIterationOrTerminate(loopName, currentState) diff --git a/src/prompts/agents/auditor-final-audit-addendum.md b/src/prompts/agents/auditor-final-audit-addendum.md index debc5d807..81d306931 100644 --- a/src/prompts/agents/auditor-final-audit-addendum.md +++ b/src/prompts/agents/auditor-final-audit-addendum.md @@ -5,7 +5,7 @@ This addendum applies only when the invocation is `[Final integration audit]`. S ### Scope -This is the integration review of the loop's full accumulated changes: all tracked and untracked changes in the worktree, the master plan's top-level acceptance criteria and verification commands, interactions across sections, and unresolved findings across all sections. +This is the integration review of the loop's full accumulated changes: every `section <N>:` checkpoint commit on this branch since its merge-base with the base branch, plus all uncommitted and untracked changes in the worktree; the master plan's top-level acceptance criteria and verification commands; interactions across sections; and unresolved findings across all sections. ### Verification diff --git a/src/prompts/agents/auditor-loop-addendum.md b/src/prompts/agents/auditor-loop-addendum.md index 0bc4d89e6..5d315f83c 100644 --- a/src/prompts/agents/auditor-loop-addendum.md +++ b/src/prompts/agents/auditor-loop-addendum.md @@ -25,27 +25,22 @@ The "Coder Decisions" and "Recurring Findings" rules below still apply to goal l When auditing in a sectioned loop, you audit one section at a time. The loop runner splits the master plan into sections at `<!-- forge-section -->` markers. Each section has its own acceptance criteria and verification commands. Focus your audit on the current section's content and acceptance criteria. +**Review scope.** The loop runner commits each completed section as a `section <N>: <title>` checkpoint commit. The current section's work is therefore everything NOT yet checkpointed: all uncommitted changes (`git status --short`, `git diff`, plus untracked files read in full) and any commits made after the most recent `section <N>:` checkpoint (`git log --oneline` to find it; the first section has no checkpoint yet). Treat earlier sections' committed code as read-only context, not review scope. + When writing findings, always include the appropriate `sectionIndex` to attribute the finding to a specific section. Use `crossSection: true` only when the finding spans multiple sections. Section audits do not perform broad whole-loop impact analysis (duplication of existing helpers, parallel implementations, missed callers, dead code). That analysis runs only at the final audit, which independently checks the full accumulated diff; record concrete cross-section concerns in the section summary's Follow-ups instead. Do not suppress a concrete correctness bug or broken caller discovered during a section audit. ## Section Summaries -Include a `<!-- section-summary:start -->` block at the end of your response only when the section is clear of blocking bugs: - -``` -<!-- section-summary:start --> -### Done -- bullets describing what was implemented -### Deviations -- bullets describing places implementation differs from this section plan, with reasons (or "none") -### Follow-ups -- bullets noting items deferred to later sections (or "none") -<!-- section-summary:end --> -``` +When a section audit finds no blocking bugs, end your response with a section-summary block. The audit prompt gives the exact block format (marker comments plus `### Done` / `### Deviations` / `### Follow-ups`); reproduce it exactly — the loop runner parses it mechanically, and a missing or malformed block keeps the section dirty and wastes a full iteration. Do NOT include a section summary while the section has blocking bugs. A section clear of bug findings advances to the next section — after the last section it moves to the final audit; it does not terminate the loop. The final audit still runs over all sections. +Match your response to its consumer: +- **Clean section**: only the section-summary block is machine-read; nothing else in your response is consumed. The summary block may be your entire response — skip the full report format. +- **Dirty audit**: your response text is passed verbatim to the coding agent as "Auditor feedback". Lead with the issues and their remediation; omit filler. + ## Deviation Acceptance Documented deviations and coder decisions are context and evidence, never automatic waivers. Accept a deviation only when correctness and the required outcomes/acceptance criteria remain satisfied; prefer the simpler implementation that meets the same criteria. Flag a deviation as a bug when it materially breaks required acceptance criteria or verification. @@ -71,6 +66,8 @@ Keep remediation guidance scoped to the finding. Do not design unrelated refacto ## Adaptive plan adjustment +**Proactive next-section check.** After a clean section audit, before emitting the section summary, spend a bounded check validating the next pending section against the current worktree: do the files, symbols, and helpers it references still exist under those names; has any of its work already been done or been superseded by a documented deviation; do its assumptions still hold? If it is stale, amend it with `plan-adjust` (rationale required) so the coder never implements against an outdated plan. Keep this a quick verification, not a re-planning pass, and still emit the section summary afterwards. + If, after auditing a section, the completed work makes it clear that the plan can no longer achieve its objective as written, use the `plan-adjust` tool to correct it. You can: - Revise the **section currently under audit** by passing `currentSection` (edited in place; its progress is preserved). Use this when unforeseen outcomes mean the current section itself must change to complete the loop. If your revision means the existing work no longer satisfies the section, also write bug findings so it is re-coded against the new plan. - Replace the **remaining (not yet started) sections** by passing `sections` with the full replacement list. diff --git a/src/prompts/agents/auditor.md b/src/prompts/agents/auditor.md index 6efe7fdd5..a1a9392f8 100644 --- a/src/prompts/agents/auditor.md +++ b/src/prompts/agents/auditor.md @@ -15,14 +15,14 @@ Process findings in this exact order: 1. **Read first**: Call `review-read` with no arguments to load findings for the tool's current project, loop, and section scope. 2. **Manifest**: Establish the changed-file manifest from the diff and status commands above — no substantive code analysis yet. -3. **Reconcile**: For each open finding in a changed file, check it against the current diff/files. +3. **Reconcile**: Check EVERY open finding in scope against the current code — not only findings whose file appears in the diff. A finding can be resolved by changes in a different file, and findings on pseudo-paths (e.g. `PLAN:phase-<N>`, `GOAL`, `AUDIT_SCOPE`) never appear in a diff; re-verify them too. The changed-file manifest is a starting hint, not a filter. - **Resolved**: Call `review-delete` immediately with the file and line arguments. - **Still open**: Keep it and report it under "### Previously Identified Issues". 4. **Inspect**: Analyze the diff and the changed files (see What to Look For). 5. **Validate**: Run the narrowest relevant validation (see Verification). 6. **Persist**: Store each new **bug** and **warning** with `review-write`. Do NOT store suggestions. Do not re-store resolved findings. -Use `review-write` with: `file`, `line`, `severity` ("bug" or "warning"), `description`, `scenario`, and `status` ("open" by default). Put the detailed solution, acceptance criterion, and narrow verification in `description`; they are not separate tool arguments. +Use `review-write` with: `file`, `line`, `severity` ("bug" or "warning"), `description`, and `scenario`. Put the detailed solution, acceptance criterion, and narrow verification in `description`; they are not separate tool arguments. ## What to Look For diff --git a/src/prompts/commands/execute-goal.md b/src/prompts/commands/execute-goal.md index c943dd51d..ce4a78abf 100644 --- a/src/prompts/commands/execute-goal.md +++ b/src/prompts/commands/execute-goal.md @@ -16,7 +16,7 @@ Call the `execute-goal` tool with the full, self-contained goal text: - loopName: Optional loop name. Forge slugifies it and auto-increments on collision. - maxIterations: Optional maximum loop iterations. Defaults to the plugin config `loop.defaultMaxIterations`. -This creates an isolated Forge worktree and a new dedicated code session inside it, sends the goal as that session's initial prompt, and starts the watchdog. Docker sandboxing is used automatically when configured and available. +This creates an isolated Forge worktree and a new dedicated code session inside it, sends the goal as that session's initial prompt, and starts the watchdog. `msb` microVM sandboxing is used automatically when configured and available. ## Step 3: You Are Done diff --git a/src/prompts/commands/execute-plan.md b/src/prompts/commands/execute-plan.md index 364123a4e..2a215696d 100644 --- a/src/prompts/commands/execute-plan.md +++ b/src/prompts/commands/execute-plan.md @@ -4,7 +4,7 @@ Ensure you have a clear implementation plan ready. ## Step 2: Execute the Plan -Run `execute-plan` directly — do not use the `question` tool to pick a mode. Default to `mode: loop`, which runs the iterative development loop in an isolated git worktree with Docker sandboxing used automatically when configured and available. Use `mode: new-session` only when the user explicitly asked to launch the plan in a fresh standalone session with no worktree or sandbox. +Run `execute-plan` directly — do not use the `question` tool to pick a mode. Default to `mode: loop`, which runs the iterative development loop in an isolated git worktree with `msb` microVM sandboxing used automatically when configured and available. Use `mode: new-session` only when the user explicitly asked to launch the plan in a fresh standalone session with no worktree or sandbox. Args: - plan: Optional full implementation plan. If omitted, Forge reads the captured plan for the current session. diff --git a/src/prompts/loader.ts b/src/prompts/loader.ts index 3101f141a..2317bf1ee 100644 --- a/src/prompts/loader.ts +++ b/src/prompts/loader.ts @@ -1,8 +1,9 @@ import { readFileSync, existsSync } from 'fs' -import { dirname, join } from 'path' -import { fileURLToPath } from 'url' +import { join } from 'path' +import { resolveShippedRoot } from '../utils/shipped-paths' -export const BUNDLED_PROMPTS_DIR = dirname(fileURLToPath(import.meta.url)) +/** Directory containing the bundled prompts (`<shippedRoot>/prompts`). */ +export const BUNDLED_PROMPTS_DIR = join(resolveShippedRoot(import.meta.url), 'prompts') const promptCache = new Map<string, string>() diff --git a/src/sandbox/config-warnings.ts b/src/sandbox/config-warnings.ts index 768e3a8af..5b4dd9f1b 100644 --- a/src/sandbox/config-warnings.ts +++ b/src/sandbox/config-warnings.ts @@ -1,7 +1,7 @@ import { isRecord } from '../utils/is-record' /** - * Return one message per legacy `sandbox` config key that sbx can no longer + * Return one message per legacy `sandbox` config key that msb can no longer * express, so a stale `forge-config.jsonc` reports exactly which keys stopped * mattering. Operates on the raw parsed JSONC value because these keys no * longer exist on `SandboxConfig`. Non-object input returns an empty list. @@ -11,31 +11,34 @@ 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'") + const mode = rawSandbox.mode + if (mode === 'docker') { + warnings.push("sandbox.mode 'docker' is ignored: the msb migration replaces the Docker driver; use mode 'msb'") + } else if (typeof mode === 'string' && mode !== 'msb') { + warnings.push(`sandbox.mode ${JSON.stringify(mode)} is replaced: the msb driver is the only supported sandbox mode; use mode 'msb'`) } if ('projectMountPath' in rawSandbox) { - warnings.push('sandbox.projectMountPath is ignored: sbx mounts the source project read-only at its own host path') + warnings.push('sandbox.projectMountPath is ignored: msb mounts the source project read-only at its own host path') } const resources = rawSandbox.resources if (isRecord(resources)) { if ('shmSize' in resources) { - warnings.push('sandbox.resources.shmSize is ignored: sbx does not support a shared-memory flag; remove it') + warnings.push('sandbox.resources.shmSize is ignored: msb does not support a shared-memory flag; remove it') } if ('memorySwap' in resources) { - warnings.push('sandbox.resources.memorySwap is ignored: sbx does not support a separate swap limit; remove it') + warnings.push('sandbox.resources.memorySwap is ignored: msb does not support a separate swap limit; remove it') } } const network = rawSandbox.network if (isRecord(network) && 'hostGateway' in network) { - warnings.push('sandbox.network.hostGateway is ignored: sbx blocks host loopback — use sandbox.network.allow') + warnings.push('sandbox.network.hostGateway is ignored: msb blocks host loopback — use sandbox.network.allow') } const mounts = rawSandbox.mounts if (Array.isArray(mounts) && mounts.some((m) => isRecord(m) && 'container' in m)) { - warnings.push('sandbox.mounts[].container is ignored: sbx mounts every workspace at its identical host path') + warnings.push('sandbox.mounts[].container is ignored: msb mounts every workspace at its identical host path') } return warnings diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts index 5f549ed65..07ba2b873 100644 --- a/src/sandbox/context.ts +++ b/src/sandbox/context.ts @@ -1,4 +1,4 @@ -import type { SandboxRuntime } from './sbx' +import type { SandboxRuntime } from './msb' import type { PluginConfig, SandboxMountConfig } from '../types' import type { SandboxMount } from './path' import { resolveLoopAllowedDirectories } from '../constants/loop' @@ -8,7 +8,6 @@ export interface SandboxContext { containerName: string hostDir: string mounts: SandboxMount[] - envFile?: string } /** @@ -23,6 +22,7 @@ export const SANDBOX_CONTEXT_NOTE = [ '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.', 'Passwordless sudo is available for installing missing tools system-wide.', + 'Docker is available inside the sandbox: run forge-dockerd-start to ensure the daemon is running (idempotent, safe to run any time).', ].join('\n') export interface SandboxLoopContextState { @@ -35,7 +35,7 @@ export interface SandboxLoopContextState { export interface SandboxContextManager { runtime: SandboxRuntime restore(worktreeName: string, projectDir: string, startedAt: string): Promise<void> - getActive(worktreeName: string): { containerName: string; projectDir: string; mounts: SandboxMount[]; envFile?: string } | null + getActive(worktreeName: string): { containerName: string; projectDir: string; mounts: SandboxMount[] } | null ensureRunning(worktreeName: string, projectDir: string, startedAt?: string): Promise<string> } @@ -64,7 +64,6 @@ export async function resolveSandboxContextForLoop( containerName: active.containerName, hostDir: active.projectDir, mounts: active.mounts ?? [{ hostDir: active.projectDir, containerDir: active.projectDir }], - envFile: active.envFile, } } @@ -100,7 +99,7 @@ export function resolveSandboxMountConfigs(config: PluginConfig | undefined): Sa * * A sandbox is only usable when BOTH conditions hold: * - the user has not opted out via `sandbox.enabled: false`, and - * - a sandbox manager was constructed (Docker mode active). + * - a sandbox manager was constructed (msb mode active). * * Honoring the config here (not just the manager's existence) keeps this the single * source of truth for the bash/sh permission routing: when the sandbox is off, loops diff --git a/src/sandbox/exec-fs.ts b/src/sandbox/exec-fs.ts index c5e2a72fa..e90c7ac4a 100644 --- a/src/sandbox/exec-fs.ts +++ b/src/sandbox/exec-fs.ts @@ -1,10 +1,9 @@ -import type { SandboxRuntime } from './sbx' +import type { SandboxRuntime } from './msb' interface SandboxExecutionDeps { runtime: SandboxRuntime containerName: string hostDir: string - envFile?: string } function quoteShellArg(value: string): string { @@ -20,13 +19,13 @@ export async function executeSandboxGlob( pattern: string, searchPath?: string, ): Promise<string> { - const { runtime, containerName, hostDir, envFile } = sandbox + const { runtime, containerName, hostDir } = sandbox const path = searchPath || hostDir const cmd = `rg --files --glob ${quoteShellArg(pattern)} ${quoteShellArg(path)} 2>/dev/null | head -100` try { - const result = await runtime.exec(containerName, cmd, { timeout: 30000, envFile, cwd: hostDir }) + const result = await runtime.exec(containerName, cmd, { timeout: 30000, cwd: hostDir }) if (!result.stdout.trim()) return 'No files found' @@ -57,7 +56,7 @@ export async function executeSandboxGrep( pattern: string, options?: { path?: string; include?: string }, ): Promise<string> { - const { runtime, containerName, hostDir, envFile } = sandbox + const { runtime, containerName, hostDir } = sandbox const searchPath = options?.path || hostDir let cmd = `rg -nH --hidden --no-messages --field-match-separator='|' --regexp ${quoteShellArg(pattern)}` @@ -67,7 +66,7 @@ export async function executeSandboxGrep( cmd += ` ${quoteShellArg(searchPath)} 2>/dev/null | head -100` try { - const result = await runtime.exec(containerName, cmd, { timeout: 30000, envFile, cwd: hostDir }) + const result = await runtime.exec(containerName, cmd, { timeout: 30000, cwd: hostDir }) if (!result.stdout.trim()) return 'No files found' diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index 9b6d734b3..e313c3470 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -1,15 +1,14 @@ -import type { SandboxRuntime, SandboxWorkspace } from './sbx' -import { describeSbxUnavailable, type SbxAvailability } from './sbx' -import type { Logger, SandboxResources, SandboxMountConfig } from '../types' +import type { SandboxRuntime, SandboxWorkspace } from './msb' +import { buildNetworkAllow, egressRestrictionRequested, describeMsbUnavailable, type MsbAvailability } from './msb' +import type { Logger, SandboxResources, SandboxMountConfig, SandboxSecretConfig } from '../types' import { resolve, join, isAbsolute, posix as posixPath } from 'path' -import { mkdirSync, existsSync, writeFileSync, chmodSync, rmSync } from 'fs' +import { mkdirSync, existsSync } from 'fs' import { defaultGitService, type GitService } from '../utils/git-service' import { canonicalizePath, isSameOrDescendantPath, type SandboxMount } from './path' import { formatTemplateBuildCommands } from './template' export interface SandboxManagerConfig { image: string - dataDir?: string resources?: SandboxResources sourceProjectDir?: string mountProjectReadonly?: boolean @@ -29,11 +28,14 @@ export interface SandboxManagerConfig { */ tmpDir?: string /** - * Network policy for the sbx proxy. `env` lists host environment variable names to pass through - * into the sandbox on every exec (written to a per-sandbox env file under `<dataDir>/sandbox-env/`). - * `allow` lists egress hosts to permit on the sbx network proxy's default-deny policy. + * Network policy for the msb sandbox. `env` lists host environment variable names to inject + * into the guest at create time (bare `-e <NAME>`, so values stay off the command line). + * `secrets` lists host-held credentials that never enter the guest (msb substitutes them only + * for the listed hosts at the network boundary). `allow` opts into egress restriction: when it + * is empty msb's own allow-public default applies, and configuring any host switches the + * sandbox to deny-by-default with one allow rule per validated host. */ - network?: { env?: string[]; allow?: string[] } + network?: { env?: string[]; allow?: string[]; secrets?: SandboxSecretConfig[] } } const DEFAULT_RESOURCES: Required<Pick<SandboxResources, 'memory' | 'cpus'>> = { @@ -67,7 +69,7 @@ function isStrictDescendantPath(path: string, prefix: string): boolean { /** * Whether a new workspace cannot coexist with an already-accepted one on an overlapping path. - * sbx accepts nested workspace mounts, but a read-only mount applies to its whole subtree, so + * msb accepts nested workspace mounts, but a read-only mount applies to its whole subtree, so * flags cannot differ across a nesting boundary: a read-only ancestor silently makes a * read-write descendant read-only, and a read-write descendant of a read-only mount never takes * effect. The one safe flag mismatch is a read-only mount strictly inside a read-write mount, @@ -135,7 +137,6 @@ export interface ActiveSandbox { projectDir: string startedAt: string mounts: SandboxMount[] - envFile?: string } export interface SandboxManager { @@ -150,16 +151,9 @@ export interface SandboxManager { ensureRunning(worktreeName: string, projectDir: string, startedAt?: string): Promise<string> } -/** - * Maps the resolved mount plan to `sbx create` workspaces. sbx accepts nested workspace - * mounts, so overlapping read-write mounts (worktree + git dirs) coexist; a mount is dropped - * only when its read-only flag conflicts with an accepted mount's (see `mountConflictsWith`). - * Callers must still pass mounts in priority order (worktree → git dirs → read-only project → - * tool-output → temp → custom) so the first-accepted flag wins when a conflict is unavoidable. - */ -export function buildSandboxWorkspaces(mounts: SandboxMount[], logger: Logger): SandboxWorkspace[] { +function dropConflictingMounts(mounts: SandboxMount[], logger: Logger): SandboxMount[] { const accepted: SandboxMount[] = [] - const workspaces: SandboxWorkspace[] = [] + const kept: SandboxMount[] = [] for (const mount of mounts) { if (accepted.some((existing) => mountConflictsWith(mount, existing))) { logger.log(`Sandbox: dropping workspace ${mount.hostDir} because it overlaps an already-mounted host dir with conflicting permissions`) @@ -167,9 +161,29 @@ export function buildSandboxWorkspaces(mounts: SandboxMount[], logger: Logger): } if (accepted.some((existing) => mountAlreadyCovered(mount, existing))) continue accepted.push(mount) - workspaces.push({ hostDir: mount.hostDir, readOnly: mount.readOnly }) + kept.push(mount) } - return workspaces + return kept +} + +/** + * Maps the resolved mount plan to `msb create` workspaces. msb accepts nested workspace + * mounts, so overlapping read-write mounts (worktree + git dirs) coexist; a mount is dropped + * only when its read-only flag conflicts with an accepted mount's (see `mountConflictsWith`). + * Callers must still pass mounts in priority order (worktree → git dirs → read-only project → + * tool-output → temp → custom) so the first-accepted flag wins when a conflict is unavoidable. + * + * Host paths are canonicalized here because msb refuses to mount a host path that traverses a + * symlink, failing the entire sandbox with `ENOTDIR`. On macOS that breaks every `os.tmpdir()` + * mount, since `/var` is a symlink to `private/var`. The container path is deliberately left + * uncanonicalized so absolute paths handed to the agent resolve identically inside the sandbox. + */ +export function buildSandboxWorkspaces(mounts: SandboxMount[], logger: Logger): SandboxWorkspace[] { + return dropConflictingMounts(mounts, logger).map((mount) => ({ + hostDir: canonicalizePath(mount.hostDir), + containerDir: mount.containerDir, + readOnly: mount.readOnly, + })) } export function createSandboxManager( @@ -182,22 +196,24 @@ export function createSandboxManager( const lastLivenessCheck = new Map<string, number>() const ensureRunningInFlight = new Map<string, Promise<string>>() const gitMountCache = new Map<string, SandboxMount[]>() - let runtimeAvailableCache: { value: SbxAvailability; at: number } | null = null + const convergedSecrets = new Set<string>() + const handledSecretEnvs = new Map<string, Set<string>>() + const warnedUnsetSecretEnv = new Set<string>() + let runtimeAvailableCache: { value: MsbAvailability; at: number } | null = null let imageReady = false - let allowListApplied = false async function ensureRuntimeAvailable(): Promise<void> { 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(describeMsbUnavailable(runtimeAvailableCache.value)) } return } const result = await runtime.checkAvailable() - // An inconclusive probe says nothing about the daemon: `sbx daemon status` queues behind - // in-flight sandbox work, so starting a second loop while the first one is busy can exhaust the - // query bound even though the daemon is healthy. Failing on it would block loop launches under + // An inconclusive probe says nothing about availability: the probe can exhaust its query + // bound under startup load (every worktree probes independently), so a timeout or throw is + // not evidence the runtime is unusable. Failing on it would block loop launches under // exactly the concurrency forge exists to provide, and caching it would extend one slow probe // into a window of refusals. Proceed instead and let the real operation report authoritatively. if (!result.available && result.reason === 'unknown') { @@ -206,7 +222,7 @@ export function createSandboxManager( } runtimeAvailableCache = { value: result, at: now } if (!result.available) { - throw new Error(describeSbxUnavailable(result)) + throw new Error(describeMsbUnavailable(result)) } } @@ -214,12 +230,12 @@ export function createSandboxManager( if (imageReady) return const exists = await runtime.templateExists(config.image) if (!exists) { - // A daemon that cannot answer `sbx template ls` is indistinguishable from a missing template, + // A runtime that cannot answer `msb images` is indistinguishable from a missing template, // so confirm it is really reachable before telling the user to rebuild the image. const availability = await runtime.checkAvailable() if (!availability.available) { if (availability.reason === 'unknown') return - throw new Error(describeSbxUnavailable(availability)) + throw new Error(describeMsbUnavailable(availability)) } const buildHint = ` ${formatTemplateBuildCommands( config.buildContextDir ?? '<build-context-dir>', @@ -236,7 +252,7 @@ export function createSandboxManager( function buildMountPlan(projectDir: string): { mounts: SandboxMount[] } { const absolute = resolve(projectDir) - // `sbx` mounts every workspace at its identical host path (there is no separate + // `msb` mounts every workspace at its identical host path (there is no separate // `/workspace` container path), so the primary worktree mount is hostDir == containerDir. const worktreeMount: SandboxMount = { hostDir: absolute, containerDir: absolute } @@ -266,7 +282,7 @@ export function createSandboxManager( // Priority order is load-bearing: worktree first, then git dirs (outer/common dir first so // it survives any conflict and keeps the whole git metadata region writable), then the - // read-only project mount, then tool-output/temp/custom. sbx accepts nested workspaces, so + // read-only project mount, then tool-output/temp/custom. msb accepts nested workspaces, so // read-write mounts (worktree + git dirs) all mount even when one nests inside another; the // read-only project workspace is dropped because it is an ancestor of the writable git // workspaces and a read-only ancestor silently makes them read-only inside the sandbox. @@ -281,17 +297,7 @@ export function createSandboxManager( ...customMounts, ] - const mounts: SandboxMount[] = [] - const accepted: SandboxMount[] = [] - for (const mount of candidates) { - if (accepted.some((existing) => mountConflictsWith(mount, existing))) { - logger.log(`Sandbox: dropping workspace ${mount.hostDir} because it overlaps an already-mounted host dir with conflicting permissions`) - continue - } - if (accepted.some((existing) => mountAlreadyCovered(mount, existing))) continue - accepted.push(mount) - mounts.push(mount) - } + const mounts = dropConflictingMounts(candidates, logger) return { mounts } } @@ -350,7 +356,7 @@ export function createSandboxManager( // The git metadata region is mounted read-write so in-sandbox git works, which would also let // the sandbox plant a hook that runs on the host under the user's account. Forge's own git // disables hooksPath (see `git-service`), but the user's git in the same repository does not, - // so the hooks directory is re-mounted read-only inside the writable region. `sbx` workspaces + // so the hooks directory is re-mounted read-only inside the writable region. `msb` workspaces // are directories only, so the repo-local config file cannot be protected the same way. const hooksDir = join(resolvedCommonDir, 'hooks') if (existsSync(hooksDir)) { @@ -361,75 +367,126 @@ export function createSandboxManager( return result } - function writeEnvPassthroughFile(containerName: string): string | undefined { - const names = config.network?.env - if (!names || names.length === 0) return undefined - const dataDir = config.dataDir - if (!dataDir) return undefined - - const lines: string[] = [] - for (const name of names) { - const value = process.env[name] - if (value !== undefined) { - lines.push(`${name}=${value}`) + /** + * Single point that records a usable sandbox in the active map, shared by the create and + * adopt paths in `start` and by `resolveUsableSandbox`. An existing entry's `startedAt` wins + * so adopting a sandbox never resets the time it actually came up. A precomputed mount plan + * is reused when provided so the create path does not run it twice. + */ + function registerActiveSandbox(worktreeName: string, containerName: string, projectDir: string, startedAt?: string, mounts?: SandboxMount[]): void { + const active = activeSandboxes.get(worktreeName) + activeSandboxes.set(worktreeName, { + containerName, + projectDir: resolve(projectDir), + startedAt: active?.startedAt ?? startedAt ?? new Date().toISOString(), + mounts: mounts ?? buildMountPlan(projectDir).mounts, + }) + } + + /** + * Resolves the configured env passthrough against the live host environment. msb fails the + * create when a bare `-e NAME` references an unset host variable, so only defined values are + * forwarded and the omission is logged. + */ + function resolvePassthroughEnv(): string[] { + return (config.network?.env ?? []).filter((name) => { + if (process.env[name] === undefined) { + logger.log(`Sandbox: skipping env passthrough ${name}: host variable is not set`) + return false } - } - if (lines.length === 0) return undefined - - const dir = join(dataDir, 'sandbox-env') - mkdirSync(dir, { recursive: true }) - const filePath = join(dir, `${containerName}.env`) - writeFileSync(filePath, lines.join('\n') + '\n', { encoding: 'utf-8' }) - chmodSync(filePath, 0o600) - return filePath + return true + }) } /** - * Applies the configured egress allowlist to the sbx network proxy. Policy rules are global to - * sbx, not per sandbox, so they are applied at most once per manager instance. A host that - * fails to allow is logged but never throws: an unusable rule must not block a loop that does - * not need that host. + * Resolves the configured secrets against the live host environment. Entries without an env + * name or allowed hosts are misconfigurations, and msb refuses a secret whose host variable is + * unset at create, so each skipped entry is logged with its reason. */ - async function applyNetworkAllowList(): Promise<void> { - if (allowListApplied) return - allowListApplied = true - for (const host of config.network?.allow ?? []) { - const trimmed = host.trim() - if (!trimmed) continue - const ok = await runtime.allowNetworkHost(trimmed) - if (!ok) { - logger.log(`Sandbox: failed to allow network host "${trimmed}"`) + function resolveSandboxSecrets(): SandboxSecretConfig[] { + const resolved: SandboxSecretConfig[] = [] + for (const raw of config.network?.secrets ?? []) { + const env = raw.env.trim() + if (!env) { + logger.log('Sandbox: skipping secret: missing env name') + continue + } + const hosts = (raw.hosts ?? []).map((h) => h.trim()).filter(Boolean) + if (hosts.length === 0) { + logger.log(`Sandbox: skipping secret ${env}: no allowed hosts`) + continue } + if (process.env[env] === undefined) { + if (!warnedUnsetSecretEnv.has(env)) { + warnedUnsetSecretEnv.add(env) + logger.log(`Sandbox: skipping secret ${env}: host variable is not set; sandboxed shell commands will fail until the variable is exported in the environment that launches opencode (configured under sandbox.network.secrets)`) + } + continue + } + resolved.push({ env, hosts }) } + return resolved } /** - * 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 - * sandbox never resets the time it actually came up. + * Rotates the configured host-held secrets on a sandbox forge is adopting. Create-time + * bindings are captured once, but adoption reuses a long-lived container across plugin + * restarts, so a rotated host token would otherwise stay stale for the life of the sandbox. + * Runs only on adopt paths: the fresh-create path already bound the same filtered list. + * Returns false (without marking the container converged) when the rotation fails, so the + * caller fails the adoption and a later attempt can retry. */ - function registerActiveSandbox(worktreeName: string, containerName: string, projectDir: string, startedAt?: string): void { - const active = activeSandboxes.get(worktreeName) - activeSandboxes.set(worktreeName, { - containerName, - projectDir: resolve(projectDir), - startedAt: active?.startedAt ?? startedAt ?? new Date().toISOString(), - mounts: buildMountPlan(projectDir).mounts, - envFile: writeEnvPassthroughFile(containerName), - }) + async function refreshSecrets(containerName: string): Promise<boolean> { + if (convergedSecrets.has(containerName)) return true + const secrets = resolveSandboxSecrets() + if (secrets.length === 0) { + convergedSecrets.add(containerName) + return true + } + warnUncoveredSecretHosts(containerName, secrets) + if (!(await runtime.refreshSandboxSecrets(containerName, secrets))) { + logger.log(`Sandbox: failed to refresh secrets for ${containerName}`) + return false + } + convergedSecrets.add(containerName) + recordHandledSecretEnvs(containerName, secrets) + return true + } + + function recordHandledSecretEnvs(containerName: string, secrets: SandboxSecretConfig[]): void { + const known = handledSecretEnvs.get(containerName) ?? new Set<string>() + for (const secret of secrets) known.add(secret.env) + handledSecretEnvs.set(containerName, known) + } + + function warnUncoveredSecretHosts(containerName: string, secrets: SandboxSecretConfig[]): void { + const known = handledSecretEnvs.get(containerName) + const introduced = known ? secrets.filter((s) => !known.has(s.env)) : secrets + if (introduced.length === 0) return + logger.log(`Sandbox: egress for secret host(s) of ${introduced.map((s) => s.env).join(', ')} may be unreachable in ${containerName}: msb cannot change egress rules on an existing sandbox, so recreate the sandbox for the new host(s) to be allowed`) } async function start(worktreeName: string, projectDir: string, startedAt?: string): Promise<{ containerName: string }> { await ensureRuntimeAvailable() await ensureTemplate() - await applyNetworkAllowList() const containerName = runtime.sandboxContainerName(worktreeName) const absoluteProjectDir = resolve(projectDir) const state = await runtime.getSandboxState(containerName) + // `unknown` means the state query failed and says nothing about the sandbox: adopting + // could register a non-existent container as usable, and creating could collide with a + // live one. Fail closed and let the caller surface the indeterminate state. + if (state === 'unknown') { + throw new Error( + `Could not determine whether sandbox ${containerName} exists (state query failed); refusing to start`, + ) + } if (state !== 'missing') { logger.log(`Sandbox ${containerName} already exists (${state}), adopting`) + if (!(await refreshSecrets(containerName))) { + throw new Error(`Failed to refresh secrets for sandbox ${containerName}; refusing to adopt`) + } registerActiveSandbox(worktreeName, containerName, projectDir, startedAt) return { containerName } } @@ -438,20 +495,28 @@ export function createSandboxManager( const workspaces = buildSandboxWorkspaces(mounts, logger) const resources: SandboxResources = { memory: config.resources?.memory ?? DEFAULT_RESOURCES.memory, + maxMemory: config.resources?.maxMemory, cpus: config.resources?.cpus ?? DEFAULT_RESOURCES.cpus, + maxCpus: config.resources?.maxCpus, + dockerDisk: config.resources?.dockerDisk, } - logger.log(`Creating sandbox ${containerName} for ${absoluteProjectDir} (memory=${resources.memory} cpus=${resources.cpus})`) - await runtime.createSandbox(containerName, workspaces, { template: config.image, resources }) - - const active: ActiveSandbox = { - containerName, - projectDir: absoluteProjectDir, - startedAt: startedAt ?? new Date().toISOString(), - mounts, - envFile: writeEnvPassthroughFile(containerName), - } - - activeSandboxes.set(worktreeName, active) + // Secret destinations are unioned into the egress allow-list: msb's proxy is deny-by-default + // at the sandbox level, so a secrets-only configuration would otherwise never reach its hosts. + const secrets = resolveSandboxSecrets() + const memoryLabel = resources.maxMemory ? `${resources.memory}/max ${resources.maxMemory}` : resources.memory + const cpusLabel = resources.maxCpus ? `${resources.cpus}/max ${resources.maxCpus}` : resources.cpus + logger.log(`Creating sandbox ${containerName} for ${absoluteProjectDir} (memory=${memoryLabel} cpus=${cpusLabel} workspaces=${workspaces.length})`) + await runtime.createSandbox(containerName, workspaces, { + image: config.image, + resources, + networkAllow: buildNetworkAllow(config.network?.allow, secrets, logger), + restrictEgress: egressRestrictionRequested(config.network?.allow, secrets), + env: resolvePassthroughEnv(), + secrets, + }) + convergedSecrets.add(containerName) + recordHandledSecretEnvs(containerName, secrets) + registerActiveSandbox(worktreeName, containerName, projectDir, startedAt, mounts) logger.log(`Sandbox ${containerName} started`) return { containerName } @@ -461,7 +526,28 @@ export function createSandboxManager( const active = activeSandboxes.get(worktreeName) const containerName = active?.containerName || runtime.sandboxContainerName(worktreeName) - // Cleanup (env file, in-memory map entry) always runs; the removal failure is rethrown so + // Fail-closed on the five-state contract: `unknown` means the state query failed and says + // nothing about the sandbox, so removal could destroy a live container that a concurrent or + // indeterminate query cannot see. Preserve the active-map entry (if any) so callers can + // observe the indeterminate state, and refuse to touch the sandbox. `missing` is a confirmed + // absence: clear stale local bookkeeping without invoking msb. + const state = await runtime.getSandboxState(containerName) + if (state === 'unknown') { + const err = new Error( + `Could not determine whether sandbox ${containerName} exists (state query failed); refusing to remove`, + ) + logger.log(`Sandbox ${containerName} stop: ${err.message}`) + throw err + } + if (state === 'missing') { + activeSandboxes.delete(worktreeName) + convergedSecrets.delete(containerName) + handledSecretEnvs.delete(containerName) + logger.log(`Sandbox ${containerName} already gone`) + return + } + + // Cleanup (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. let removalError: unknown = null @@ -473,17 +559,11 @@ export function createSandboxManager( const errMsg = err instanceof Error ? err.message : String(err) logger.log(`Sandbox ${containerName} removal: ${errMsg}`) } finally { - // Cleanup of the in-memory map entry must never be skipped: an env-file deletion failure - // must not leave stale manager state that would trigger indefinite fail-closed retries for a - // container that was already removed. - if (active?.envFile) { - try { - rmSync(active.envFile, { force: true }) - } catch (err) { - logger.log(`Sandbox: failed to remove env file ${active.envFile}: ${err instanceof Error ? err.message : String(err)}`) - } - } + // Cleanup of the in-memory map entry must never be skipped: a stale entry would leave the + // manager believing a removed container is live, blocking recreation. activeSandboxes.delete(worktreeName) + convergedSecrets.delete(containerName) + handledSecretEnvs.delete(containerName) } if (removalError) throw removalError } @@ -497,7 +577,7 @@ export function createSandboxManager( } /** - * A `stopped` sandbox is live: `sbx` suspends idle microVMs and `sbx exec` resumes them in + * A `stopped` sandbox is live: msb suspends idle microVMs and `msb exec` resumes them in * place. Only a confirmed-`missing` sandbox invalidates the map entry — `unknown` means the * status query failed and is not evidence the sandbox is gone. */ @@ -534,6 +614,8 @@ export function createSandboxManager( try { await runtime.removeSandbox(name) removed++ + convergedSecrets.delete(name) + handledSecretEnvs.delete(name) logger.log(`Removed orphaned sandbox: ${name}`) } catch (err) { const errMsg = err instanceof Error ? err.message : String(err) @@ -543,6 +625,8 @@ export function createSandboxManager( if (!preserveWorktrees) { activeSandboxes.clear() + convergedSecrets.clear() + handledSecretEnvs.clear() } else { for (const key of activeSandboxes.keys()) { if (!preserveWorktrees.includes(key)) { @@ -560,8 +644,8 @@ export function createSandboxManager( /** * Single decision point for "is this worktree's sandbox usable?", shared by the mapped and - * unmapped paths. `running` and `stopped` are both usable — `sbx` suspends idle microVMs to - * `stopped` and `sbx exec` resumes them in place, so recreating one would needlessly destroy + * unmapped paths. `running` and `stopped` are both usable — msb suspends idle microVMs to + * `stopped` and `msb exec` resumes them in place, so recreating one would needlessly destroy * container-local state. `unknown` means the status query failed and says nothing about the * sandbox, so an existing entry is kept as-is (without refreshing the liveness timestamp, so * the next call re-checks) and only a confirmed-`missing` sandbox is created. @@ -572,6 +656,9 @@ export function createSandboxManager( const state = await runtime.getSandboxState(containerName) if (state === 'running' || state === 'stopped') { + if (!(await refreshSecrets(containerName))) { + throw new Error(`Failed to refresh secrets for sandbox ${containerName}; refusing to adopt`) + } registerActiveSandbox(worktreeName, containerName, projectDir, startedAt) lastLivenessCheck.set(worktreeName, Date.now()) return containerName @@ -597,7 +684,7 @@ export function createSandboxManager( } // Single-flight per worktree: concurrent callers would otherwise each run the create path and - // race `sbx`, which answers the loser with `409 Conflict ... has an operation in progress`. + // race one another, with the loser's create rejected while the winner's is still in flight. const inFlight = ensureRunningInFlight.get(worktreeName) if (inFlight) return inFlight diff --git a/src/sandbox/msb.ts b/src/sandbox/msb.ts new file mode 100644 index 000000000..0a9b8e922 --- /dev/null +++ b/src/sandbox/msb.ts @@ -0,0 +1,643 @@ +import type { Logger, SandboxResources, SandboxSecretConfig } from '../types' +import { runCommand, COMMAND_TIMEOUT_EXIT_CODE, type CommandResult } from './process' + +export function sanitizeMsbName(raw: string): string { + const name = raw + .toLowerCase() + .replace(/[^a-z0-9.+-]+/g, '-') + .replace(/^[-.]+|[-.]+$/g, '') + .substring(0, 60) + .replace(/[-.]+$/g, '') + return name || 'sandbox' +} + +export function sandboxContainerName(worktreeName: string): string { + return `forge-${sanitizeMsbName(worktreeName)}` +} + +export interface SandboxWorkspace { + /** + * Host path msb mounts from. Must already be canonicalized: msb cannot mount a host path that + * traverses a symlink and fails the whole sandbox with `ENOTDIR`, which on macOS hits every + * `os.tmpdir()` path because `/var` is a symlink to `private/var`. + */ + hostDir: string + /** Path the mount appears at inside the sandbox. Kept uncanonicalized so absolute host paths the agent was given still resolve. */ + containerDir: string + readOnly?: boolean +} + +export interface BuildMsbExecOpts { + workdir?: string + timeoutMs?: number +} + +export function buildMsbExecArgs(name: string, command: string, opts?: BuildMsbExecOpts): string[] { + const args = ['exec', name, '--no-tty', '--quiet'] + if (opts?.workdir) args.push('-w', opts.workdir) + if (opts?.timeoutMs) args.push('--timeout', `${Math.ceil(opts.timeoutMs / 1000)}s`) + args.push('--', 'sh', '-c', command) + return args +} + +const MSB_SIZE_RE = /^\d+(\.\d+)?[kmg]b?$/i +const MSB_DOCKER_DISK_DEFAULT = '16g' + +export function parseMsbCpus(raw: string | undefined, logger: Logger): number | undefined { + if (raw === undefined || raw.trim() === '') return undefined + const value = Number(raw) + if (Number.isNaN(value) || !Number.isFinite(value)) { + logger.log(`Sandbox: non-numeric --cpus value ${JSON.stringify(raw)} ignored`) + return undefined + } + const floored = Math.floor(value) + if (floored !== value) { + logger.log(`Sandbox: msb --cpus is integer-only; rounding cpus="${raw}" down to ${floored}`) + } + return Math.max(1, floored) +} + +export function normalizeMsbSize(raw: string | undefined, logger: Logger): string | undefined { + if (raw === undefined || raw.trim() === '') return undefined + if (!MSB_SIZE_RE.test(raw)) { + logger.log(`Sandbox: unrecognized size value ${JSON.stringify(raw)} ignored`) + return undefined + } + return raw.toLowerCase().replace(/b$/, '') +} + +/** + * Normalizes secrets into `{ env, hosts }` pairs. Entries with a blank or `=`-bearing env + * name or an empty host list are misconfigurations that msb refuses anyway; skipping them + * keeps credential values out of forge's argv. Single source for the create and modify + * (secret-rotation) argument builders. + */ +function normalizeSecrets(secrets: SandboxSecretConfig[] | undefined): Array<{ env: string; hosts: string[] }> { + const out: Array<{ env: string; hosts: string[] }> = [] + for (const secret of secrets ?? []) { + const env = secret.env.trim() + const hosts = secret.hosts.map((h) => h.trim()).filter(Boolean) + if (!env || env.includes('=') || hosts.length === 0) continue + out.push({ env, hosts }) + } + return out +} + +/** Builds `--secret <env>@<hosts>` flag pairs, one pair per normalized secret. */ +function buildSecretFlags(secrets: SandboxSecretConfig[] | undefined): string[] { + const flags: string[] = [] + for (const secret of normalizeSecrets(secrets)) { + flags.push('--secret', `${secret.env}@${secret.hosts.join(',')}`) + } + return flags +} + +const EGRESS_ALLOW_ALL_TOKENS = new Set(['*', '**']) + +/** + * Reports whether the configured allow-list explicitly opens sandbox egress to everything. + * An explicit `*` or `**` must behave identically to an omitted allow-list (no net flags, + * msb's own allow-by-default applies), because treating it as a host and flipping to + * `--net-default deny` with zero rules silently inverts "allow everything" into a total + * lockout. Scope is the allow list only: a secret's destination hosts declare where that + * secret may be sent, not global egress policy, so a wildcard there remains invalid and is + * still rejected by the host validator. Precedence: a wildcard entry anywhere in the list + * beats narrower entries in the same list rather than intersecting with them. + */ +export function egressAllowsAll(allow: string[] | undefined): boolean { + return (allow ?? []).some((entry) => EGRESS_ALLOW_ALL_TOKENS.has(entry.trim())) +} + +function egressHostRejectionReason(host: string): string | undefined { + if (host.includes(',')) return 'commas separate rule tokens, not hosts' + if (host.includes(':')) return 'port-qualified hosts need the tcp/udp rule form' + if (host.includes('@')) return 'the @ character is reserved for rule targets' + if (host === '*') return 'the bare wildcard is not a valid egress host' + if (host.startsWith('*.') && host.slice(2).split('.').filter(Boolean).length < 2) { + return 'wildcard suffixes need at least two labels' + } + if (host.startsWith('suffix=') && host.slice(7).split('.').filter(Boolean).length < 2) { + return 'suffix= domains need at least two labels' + } + if (!host.includes('.') && !host.startsWith('domain=')) { + return 'bare single-label hosts are ambiguous; use domain=name' + } + return undefined +} + +function collectEgressHostTokens(allow: string[] | undefined, secrets: SandboxSecretConfig[] | undefined): string[] { + const tokens: string[] = [] + for (const raw of allow ?? []) { + const host = raw.trim() + if (host) tokens.push(host) + } + for (const secret of normalizeSecrets(secrets)) { + for (const host of secret.hosts) { + const trimmed = host.trim() + if (trimmed) tokens.push(trimmed) + } + } + return tokens +} + +/** + * Unions the configured egress allow-list with the destination hosts of the configured secrets + * and validates each token. Egress restriction is opt-in: an empty configuration emits no network + * flags and msb's own allow-by-default applies, while any configured token flips the sandbox to + * deny-by-default, so a secret whose hosts are not also allow-listed could never reach its + * destination. An explicit allow-all wildcard (`*` or `**`) short-circuits to an empty rule set, + * leaving egress unrestricted exactly as an omitted allow-list would. Entries are trimmed, blanks + * dropped, and the result deduplicated. + */ +export function buildNetworkAllow( + allow: string[] | undefined, + secrets: SandboxSecretConfig[] | undefined, + logger?: Logger, +): string[] { + if (egressAllowsAll(allow)) { + logger?.log('Sandbox: wildcard allow-list leaves sandbox egress unrestricted') + return [] + } + const tokens = collectEgressHostTokens(allow, secrets) + const hosts = new Set<string>() + for (const host of tokens) { + const reason = egressHostRejectionReason(host) + if (reason) { + logger?.log(`Sandbox: skipping egress host ${JSON.stringify(host)}: ${reason}`) + continue + } + hosts.add(host) + } + const result = [...hosts] + if (logger && tokens.length > 0 && result.length === 0) { + logger.log('Sandbox: every configured egress host was rejected as invalid; sandbox egress is fully denied') + } + return result +} + +export function egressRestrictionRequested( + allow: string[] | undefined, + secrets: SandboxSecretConfig[] | undefined, +): boolean { + if (egressAllowsAll(allow)) return false + return collectEgressHostTokens(allow, secrets).length > 0 +} + +export function dockerDataVolumeName(containerName: string): string { + return `${sanitizeMsbName(containerName)}-docker-data` +} + +export function buildMsbCreateArgs( + name: string, + workspaces: SandboxWorkspace[], + opts: { + image: string + memory?: string + maxMemory?: string + cpus?: number + maxCpus?: number + networkAllow?: string[] + restrictEgress?: boolean + dockerDisk?: string + env?: string[] + secrets?: SandboxSecretConfig[] + }, +): string[] { + if (workspaces.length === 0) { + throw new Error('buildMsbCreateArgs requires at least one workspace') + } + const args = ['create', opts.image, '--name', name, '--quiet'] + if (opts.cpus !== undefined) args.push('-c', String(opts.cpus)) + if (opts.maxCpus !== undefined) args.push('--max-cpus', String(opts.maxCpus)) + if (opts.memory) args.push('-m', opts.memory) + if (opts.maxMemory) args.push('--max-memory', opts.maxMemory) + for (const ws of workspaces) { + args.push('-v', ws.readOnly ? `${ws.hostDir}:${ws.containerDir}:ro` : `${ws.hostDir}:${ws.containerDir}`) + } + const dockerDisk = opts.dockerDisk || MSB_DOCKER_DISK_DEFAULT + args.push('--mount-named', `${dockerDataVolumeName(name)}:/var/lib/docker:kind=disk,size=${dockerDisk}`) + const restrictEgress = + opts.restrictEgress === true || (opts.networkAllow ?? []).some((host) => host.trim() !== '') + if (restrictEgress) { + args.push('--net-default', 'deny') + for (const host of opts.networkAllow ?? []) { + const trimmed = host.trim() + if (trimmed) args.push('--net-rule', `allow@${trimmed}`) + } + } + // Bare `-e <NAME>` only: msb resolves the key from its own environment at start, so the value + // never appears in forge's argv (and never in `ps` output), unlike `-e NAME=VALUE`. + for (const rawName of opts.env ?? []) { + const name = rawName.trim() + if (!name || name.includes('=')) continue + args.push('-e', name) + } + // Reference form only (`env@host[,host...]`): msb rejects the inline `ENV=VALUE@HOST` form + // outright, and a secret with no allowed destination is refused, so blank or `=`-bearing env + // names and empty host lists are skipped. + args.push(...buildSecretFlags(opts.secrets)) + return args +} + +export type SandboxState = 'running' | 'stopped' | 'transient' | 'missing' | 'unknown' + +export interface MsbSandboxEntry { + name: string + status: string + state: SandboxState +} + +export function mapMsbStatus(status: string): SandboxState { + switch (status.toLowerCase()) { + case 'running': + return 'running' + case 'stopped': + case 'crashed': + return 'stopped' + case 'created': + case 'starting': + case 'draining': + case 'paused': + // Known msb states that are not directly executable but prove the sandbox exists + // (`Created`/`Starting` are cloud-only upstream today; `Draining`/`Paused` occur on local + // runtimes). They must never collapse into `unknown`, which callers read as a query failure + // and refuse to act on. + return 'transient' + default: + // An unrecognized status string is not evidence of any particular state: keep it on the + // fail-closed `unknown` path rather than trusting it as usable or absent. + return 'unknown' + } +} + +export function parseMsbSandboxListOrNull(stdout: string): MsbSandboxEntry[] | null { + if (stdout.trim() === '') return [] + let data: unknown + try { + data = JSON.parse(stdout) + } catch { + return null + } + if (!Array.isArray(data)) return null + const out: MsbSandboxEntry[] = [] + for (const raw of data) { + if (!raw || typeof raw !== 'object') continue + const entry = raw as Record<string, unknown> + const name = typeof entry.name === 'string' ? entry.name : '' + if (!name) continue + const status = typeof entry.status === 'string' ? entry.status : '' + out.push({ name, status, state: mapMsbStatus(status) }) + } + return out +} + +export function parseMsbSandboxList(stdout: string): MsbSandboxEntry[] { + return parseMsbSandboxListOrNull(stdout) ?? [] +} + +/** + * Extracts the env names of the secrets currently bound to a sandbox from + * `msb inspect --format json` (`config.network.secrets.secrets[].env_var`). Returns `null` + * when the payload cannot be trusted; an absent secrets section means no secrets are bound + * and yields `[]`. + */ +export function parseMsbInspectSecretNames(stdout: string): string[] | null { + let data: unknown + try { + data = JSON.parse(stdout) + } catch { + return null + } + if (!data || typeof data !== 'object' || Array.isArray(data)) return null + const config = (data as Record<string, unknown>).config + if (!config || typeof config !== 'object' || Array.isArray(config)) return null + const network = (config as Record<string, unknown>).network + if (!network || typeof network !== 'object' || Array.isArray(network)) return null + const secrets = (network as Record<string, unknown>).secrets + if (secrets === undefined || secrets === null) return [] + if (typeof secrets !== 'object' || Array.isArray(secrets)) return null + const list = (secrets as Record<string, unknown>).secrets + if (!Array.isArray(list)) return null + const names: string[] = [] + for (const raw of list) { + if (!raw || typeof raw !== 'object') continue + const envVar = (raw as Record<string, unknown>).env_var + if (typeof envVar === 'string' && envVar) names.push(envVar) + } + return names +} + +export function parseMsbImageList(stdout: string): string[] { + let data: unknown + try { + data = JSON.parse(stdout) + } catch { + return [] + } + if (!Array.isArray(data)) return [] + const references: string[] = [] + for (const raw of data) { + if (!raw || typeof raw !== 'object') continue + const reference = (raw as Record<string, unknown>).reference + if (typeof reference === 'string' && reference) references.push(reference) + } + return references +} + +/** + * Splits an image reference into repository and tag. A colon is only a tag separator when it + * appears after the last `/`; a colon before that is part of a registry authority (e.g. + * `localhost:5000/oc-forge-sandbox`), so such a tagless reference keeps the full string as the + * repository and defaults the tag to `latest`. + */ +function splitImageReference(ref: string): { repository: string; tag: string } { + const lastSlash = ref.lastIndexOf('/') + const lastColon = ref.lastIndexOf(':') + if (lastColon > lastSlash) { + return { repository: ref.slice(0, lastColon), tag: ref.slice(lastColon + 1) } + } + return { repository: ref, tag: 'latest' } +} + +export function msbImageMatches(references: string[], ref: string): boolean { + const { repository: name, tag } = splitImageReference(ref) + return references.some((reference) => { + const { repository, tag: entryTag } = splitImageReference(reference) + return entryTag === tag && (repository === name || repository.endsWith(`/${name}`)) + }) +} + +export type MsbAvailability = + | { available: true } + | { available: false; reason: 'not-installed' | 'host-unsupported' | 'unknown'; detail?: string } + +export type CommandRunner = ( + args: string[], + opts?: { timeout?: number; stdin?: string; abort?: AbortSignal }, +) => Promise<CommandResult> + +const MSB_QUERY_TIMEOUT = 30000 +const MSB_NOT_INSTALLED_RE = /ENOENT|command not found/i + +export async function checkMsbAvailability(run: CommandRunner): Promise<MsbAvailability> { + let result: CommandResult + try { + result = await run(['doctor'], { timeout: MSB_QUERY_TIMEOUT }) + } catch { + return { available: false, reason: 'unknown' } + } + if (result.exitCode === 0) { + return { available: true } + } + if (result.exitCode === COMMAND_TIMEOUT_EXIT_CODE) { + return { + available: false, + reason: 'unknown', + detail: `\`msb doctor\` did not answer within ${MSB_QUERY_TIMEOUT}ms`, + } + } + const combined = `${result.stdout}\n${result.stderr}` + if (MSB_NOT_INSTALLED_RE.test(combined)) { + return { available: false, reason: 'not-installed' } + } + return { available: false, reason: 'host-unsupported', detail: combined.trim() } +} + +export function describeMsbUnavailable( + result: Extract<MsbAvailability, { available: false }>, +): string { + switch (result.reason) { + case 'not-installed': + return 'The msb sandbox CLI is not installed. Install it with `curl -fsSL https://install.microsandbox.dev | sh`, then try again.' + case 'host-unsupported': + return 'This host cannot run microVMs. Run `msb doctor` for details, then try again.' + case 'unknown': + return `Could not determine sandbox availability. ${result.detail ?? 'Unknown error.'}` + } +} + +export const MSB_DEFAULT_TIMEOUT = 120000 + +/** Options for creating a sandbox. Network egress is expressed here, at create time, + * because msb network rules are per sandbox rather than daemon-global. */ +export interface CreateSandboxOpts { + image: string + resources?: SandboxResources + networkAllow?: string[] + restrictEgress?: boolean + /** Host environment variable names injected into the guest via bare `-e <NAME>`. */ + env?: string[] + /** Host-held credentials bound via `--secret <env>@<hosts>`; values never enter the guest. */ + secrets?: SandboxSecretConfig[] +} + +/** Options for a non-piped sandbox exec. `cwd` maps to the native `-w` flag, so no + * `cd '<cwd>' &&` prefix is needed in the command string. */ +export interface SandboxExecOpts { + timeout?: number + cwd?: string + abort?: AbortSignal +} + +/** Runtime facade over the `msb` CLI. Deliberately lean: no stdin-pipe exec (no production + * caller, and `msb exec` has no stdin-pipe flag) and no daemon-global policy calls (network + * rules live in `CreateSandboxOpts.networkAllow`). */ +export interface SandboxRuntime { + checkAvailable(): Promise<MsbAvailability> + templateExists(ref: string): Promise<boolean> + loadTemplate(tarPath: string, ref: string): Promise<void> + createSandbox(name: string, workspaces: SandboxWorkspace[], opts: CreateSandboxOpts): Promise<void> + removeSandbox(name: string): Promise<void> + exec(name: string, command: string, opts?: SandboxExecOpts): Promise<CommandResult> + getSandboxState(name: string): Promise<SandboxState> + sandboxContainerName(worktreeName: string): string + listSandboxesByPrefix(prefix: string): Promise<string[]> + /** Converges the secrets bound to an existing sandbox to `secrets` via `msb modify`: + * `--secret-rm` for names no longer desired, `--secret` for the desired set, and `--restart` + * only when a new name is introduced (msb classifies placeholder additions as restart-required + * while rotations and removals apply live). Returns `false` on a non-zero exit or a throw; + * never rejects. A no-op (nothing bound and nothing desired) returns `true` without invoking + * `msb modify`. */ + refreshSandboxSecrets(name: string, secrets: SandboxSecretConfig[]): Promise<boolean> +} + +const MSB_TEMPLATE_LOAD_TIMEOUT = 600000 +const MSB_REMOVE_MISSING_RE = /not found|no such sandbox|unknown sandbox|does not exist/i +const MSB_VOLUME_REMOVE_MISSING_RE = /not found|no such volume|unknown volume|does not exist/i + +/** + * Matches msb's "already exists" failure from `create`. A create that fails partway through leaves + * an orphaned sandbox directory that msb still counts as existing, while `ls` omits it and `rm` + * reports it as missing. `getSandboxState` therefore resolves `missing`, so every retry fails + * identically and the sandbox is permanently unusable until the directory is deleted by hand. + * Recreating with `--replace` is msb's own documented way out. + */ +const MSB_CREATE_EXISTS_RE = /already exists/i + +/** + * Assembles a `SandboxRuntime` from the pure `msb` helpers, routing every method through an + * injectable `CommandRunner`. The default runner spawns the `msb` binary; tests inject a fake. + */ +export function createMsbRuntime(logger: Logger, opts?: { run?: CommandRunner }): SandboxRuntime { + const run: CommandRunner = + opts?.run ?? ((args, o) => runCommand('msb', args, { ...o, logger, logLabel: 'msb' })) + + async function checkAvailable(): Promise<MsbAvailability> { + return checkMsbAvailability(run) + } + + async function templateExists(ref: string): Promise<boolean> { + try { + const result = await run(['images', '--format', 'json']) + if (result.exitCode !== 0) return false + return msbImageMatches(parseMsbImageList(result.stdout), ref) + } catch { + return false + } + } + + async function loadTemplate(tarPath: string, ref: string): Promise<void> { + const result = await run(['load', '--input', tarPath, '--tag', ref, '--quiet'], { + timeout: MSB_TEMPLATE_LOAD_TIMEOUT, + }) + if (result.exitCode !== 0) { + throw new Error(`Failed to load sandbox template: ${result.stderr || result.stdout}`) + } + } + + async function createSandbox( + name: string, + workspaces: SandboxWorkspace[], + opts: CreateSandboxOpts, + ): Promise<void> { + const args = buildMsbCreateArgs(name, workspaces, { + image: opts.image, + memory: normalizeMsbSize(opts.resources?.memory, logger), + maxMemory: normalizeMsbSize(opts.resources?.maxMemory, logger), + cpus: parseMsbCpus(opts.resources?.cpus, logger), + maxCpus: parseMsbCpus(opts.resources?.maxCpus, logger), + networkAllow: opts.networkAllow, + restrictEgress: opts.restrictEgress, + dockerDisk: normalizeMsbSize(opts.resources?.dockerDisk, logger), + env: opts.env, + secrets: opts.secrets, + }) + const result = await run(args, { timeout: MSB_DEFAULT_TIMEOUT }) + if (result.exitCode === 0) return + if (!MSB_CREATE_EXISTS_RE.test(`${result.stdout}\n${result.stderr}`)) { + throw new Error(`Failed to create sandbox: ${result.stderr}`) + } + logger.log( + `Sandbox: msb reports ${name} already exists but it was not reusable (orphaned state); recreating with --replace`, + ) + const replaced = await run([...args, '--replace'], { timeout: MSB_DEFAULT_TIMEOUT }) + if (replaced.exitCode !== 0) { + throw new Error(`Failed to create sandbox: ${replaced.stderr}`) + } + } + + async function removeDockerDataVolume(name: string): Promise<void> { + const volume = dockerDataVolumeName(name) + const result = await run(['volume', 'rm', volume]) + if (result.exitCode !== 0 && !MSB_VOLUME_REMOVE_MISSING_RE.test(`${result.stdout}\n${result.stderr}`)) { + throw new Error(`Failed to remove docker data volume: ${result.stderr}`) + } + } + + async function removeSandbox(name: string): Promise<void> { + const result = await run(['rm', '--force', name, '--quiet']) + if (result.exitCode !== 0 && !MSB_REMOVE_MISSING_RE.test(`${result.stdout}\n${result.stderr}`)) { + throw new Error(`Failed to remove sandbox: ${result.stderr}`) + } + try { + await removeDockerDataVolume(name) + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + logger.log(`Sandbox: failed to remove docker data volume for ${name}: ${errMsg}`) + } + } + + async function exec(name: string, command: string, opts?: SandboxExecOpts): Promise<CommandResult> { + const timeout = opts?.timeout ?? MSB_DEFAULT_TIMEOUT + const args = buildMsbExecArgs(name, command, { workdir: opts?.cwd, timeoutMs: timeout }) + return run(args, { timeout, abort: opts?.abort }) + } + + async function getSandboxState(name: string): Promise<SandboxState> { + let result: CommandResult + try { + result = await run(['ls', '--format', 'json'], { timeout: MSB_QUERY_TIMEOUT }) + } catch { + return 'unknown' + } + if (result.exitCode !== 0) return 'unknown' + const entries = parseMsbSandboxListOrNull(result.stdout) + if (!entries) return 'unknown' + const entry = entries.find((e) => e.name === name) + if (!entry) return 'missing' + return entry.state + } + + async function listSandboxesByPrefix(prefix: string): Promise<string[]> { + try { + const result = await run(['ls', '--format', 'json'], { timeout: MSB_QUERY_TIMEOUT }) + if (result.exitCode !== 0) return [] + return parseMsbSandboxList(result.stdout) + .map((e) => e.name) + .filter((n) => n.startsWith(prefix)) + } catch { + return [] + } + } + + async function refreshSandboxSecrets(name: string, secrets: SandboxSecretConfig[]): Promise<boolean> { + // `msb modify --secret` is per-flag additive, so the current bound set must be read back + // before applying changes; otherwise a secret removed from config stays bound forever. + // A failed or unparseable inspect carries no information, so refuse to touch anything. + let current: string[] + try { + const inspect = await run(['inspect', name, '--format', 'json'], { timeout: MSB_QUERY_TIMEOUT }) + if (inspect.exitCode !== 0) return false + const parsed = parseMsbInspectSecretNames(inspect.stdout) + if (parsed === null) return false + current = parsed + } catch { + return false + } + + const desired = normalizeSecrets(secrets) + const desiredNames = new Set(desired.map((s) => s.env)) + const currentNames = new Set(current) + const stale = current.filter((n) => !desiredNames.has(n)) + if (stale.length === 0 && desired.length === 0) return true + + // Introducing a new env name installs a new placeholder, which msb classifies as + // restart-required ("placeholder changes need a restart"); without `--restart` the whole + // modification is rejected and nothing applies. Rotations of an existing name and removals + // apply live, so they stay restart-free. + const introduced = desired.some((s) => !currentNames.has(s.env)) + const args = ['modify', name] + if (introduced) args.push('--restart') + for (const staleName of stale) args.push('--secret-rm', staleName) + args.push(...buildSecretFlags(secrets)) + try { + const result = await run(args, { timeout: introduced ? MSB_DEFAULT_TIMEOUT : MSB_QUERY_TIMEOUT }) + return result.exitCode === 0 + } catch { + return false + } + } + + return { + checkAvailable, + templateExists, + loadTemplate, + createSandbox, + removeSandbox, + exec, + getSandboxState, + sandboxContainerName, + listSandboxesByPrefix, + refreshSandboxSecrets, + } +} diff --git a/src/sandbox/sbx.ts b/src/sandbox/sbx.ts deleted file mode 100644 index de597930b..000000000 --- a/src/sandbox/sbx.ts +++ /dev/null @@ -1,467 +0,0 @@ -/** - * Pure helpers for driving the `sbx` sandbox CLI. No runtime assembly lives here; - * these functions only shape names and argument vectors so they are trivially testable. - */ -import type { Logger, SandboxResources } from '../types' -import { runCommand, COMMAND_TIMEOUT_EXIT_CODE, type CommandResult } from './process' - -/** - * Sanitizes a raw string into a name `sbx create --name` accepts. `sbx` allows only - * letters, numbers, hyphens, periods and plus signs. Lowercases, collapses every run of - * disallowed characters into a single `-`, strips leading/trailing `-` and `.`, and - * truncates to 60 characters (re-stripping any trailing `-`/`.` created by truncation). - * Empty input returns `'sandbox'`. - */ -export function sanitizeSbxName(raw: string): string { - const name = raw - .toLowerCase() - .replace(/[^a-z0-9.+-]+/g, '-') - .replace(/^[-.]+|[-.]+$/g, '') - .substring(0, 60) - .replace(/[-.]+$/g, '') - return name || 'sandbox' -} - -/** - * Deterministic sandbox container name for a loop worktree. The symbol name and - * signature are kept identical to the old Docker driver's `sandboxContainerName` so - * existing call sites need no import change when this module takes over. - */ -export function sandboxContainerName(worktreeName: string): string { - return `forge-${sanitizeSbxName(worktreeName)}` -} - -export interface BuildSbxExecOpts { - user?: string - interactive?: boolean - envFile?: string - workdir?: string -} - -/** - * Builds an `sbx exec` argument vector. Emits `exec`, then `-i` when interactive, then - * `-u <user>`, `--env-file <envFile>`, `-w <workdir>`, then `name`, `sh`, `-c`, `command`. - * Flags are omitted when their option is undefined or empty. - */ -export function buildSbxExecArgs(name: string, command: string, opts?: BuildSbxExecOpts): string[] { - const args = ['exec'] - if (opts?.interactive) args.push('-i') - if (opts?.user) args.push('-u', opts.user) - if (opts?.envFile) args.push('--env-file', opts.envFile) - if (opts?.workdir) args.push('-w', opts.workdir) - args.push(name, 'sh', '-c', command) - return args -} - -/** 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 - -/** - * Coerces a raw `sbx create --cpus` value. `sbx`'s `--cpus` flag is integer-only while - * `SandboxResources.cpus` is a string, so this parses a float and rounds down to at least 1. - * Returns `undefined` (with a log) for non-numeric input. - */ -export function parseSbxCpus(raw: string | undefined, logger: Logger): number | undefined { - if (raw === undefined || raw.trim() === '') return undefined - const value = Number(raw) - if (Number.isNaN(value) || !Number.isFinite(value)) { - logger.log(`Sandbox: non-numeric --cpus value ${JSON.stringify(raw)} ignored`) - return undefined - } - const floored = Math.floor(value) - if (floored !== value) { - logger.log(`Sandbox: sbx --cpus is integer-only; rounding cpus="${raw}" down to ${floored}`) - } - return Math.max(1, floored) -} - -/** - * Normalizes a raw `sbx create --memory` value. Accepts binary units such as `1024m` and `8g` - * (optionally with a trailing `b`), lowercased and without the trailing `b`. Logs and returns - * `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$/, '') -} - -/** - * Builds an `sbx create` argument vector for a shell sandbox. Emits `create shell --quiet - * --name <name>`, then `--template`, `--memory`, `--cpus` when present, then one positional - * per workspace (`hostDir` for read-write, `${hostDir}:ro` for read-only). The primary - * (worktree) workspace is required, so an empty array throws. - */ -export function buildSbxCreateArgs( - name: string, - workspaces: SandboxWorkspace[], - opts: { template?: string; memory?: string; cpus?: number } = {}, -): string[] { - if (workspaces.length === 0) { - throw new Error('buildSbxCreateArgs requires at least one workspace') - } - const args = ['create', 'shell', '--quiet', '--name', name] - if (opts.template) args.push('--template', opts.template) - if (opts.memory) args.push('--memory', opts.memory) - if (opts.cpus !== undefined) args.push('--cpus', String(opts.cpus)) - for (const ws of workspaces) { - args.push(ws.readOnly ? `${ws.hostDir}:ro` : ws.hostDir) - } - return args -} - -/** A single sandbox reported by `sbx ls --json`, with the raw status and liveness derived from it. */ -export interface SbxSandboxEntry { - name: string - status: string - running: boolean -} - -/** - * Parses `sbx ls --json` output into sandbox entries. The JSON shape is not contractually - * pinned, so this is defensive: `JSON.parse` failures return `[]`; it accepts either a bare - * array or an object whose first array-valued property holds the entries (covers the observed - * `{"sandboxes":[]}` shape without hardcoding the key). For each element it reads `name` from - * `name`/`Name`/`sandbox` and the raw status from `status`/`Status`/`state`/`State` (kept on the - * entry so callers can tell a suspended sandbox from a dead one); `running` is true only when the - * lowercased status starts with `running`. Entries without a non-empty string name are dropped. - */ -export function parseSbxSandboxList(stdout: string): SbxSandboxEntry[] { - return parseSbxSandboxListOrNull(stdout) ?? [] -} - -/** - * Same parse as `parseSbxSandboxList` but distinguishes "parsed, nothing matched" from "could not - * parse at all", which `getSandboxState` needs: a `sbx ls` that exits 0 while emitting truncated or - * schema-changed output says nothing about the sandbox, and reporting `missing` there would let a - * caller destroy or duplicate a live sandbox. Empty output is a legitimately empty list, not a - * parse failure, so a genuinely absent sandbox can still be created. - */ -function parseSbxSandboxListOrNull(stdout: string): SbxSandboxEntry[] | null { - if (stdout.trim() === '') return [] - let data: unknown - try { - data = JSON.parse(stdout) - } catch { - return null - } - let entries: unknown[] | null = null - if (Array.isArray(data)) { - entries = data - } else if (data && typeof data === 'object') { - for (const value of Object.values(data as Record<string, unknown>)) { - if (Array.isArray(value)) { - entries = value - break - } - } - } - // Valid JSON in an unrecognized shape (an error object, a scalar, a future nested schema) is a - // failure to read the inventory, not an empty inventory. Reporting it as an empty list would let - // callers conclude `missing` and destroy or duplicate a live sandbox. - if (!entries) return null - const out: SbxSandboxEntry[] = [] - for (const raw of entries) { - if (!raw || typeof raw !== 'object') continue - const entry = raw as Record<string, unknown> - const name = typeof entry.name === 'string' ? entry.name - : typeof entry.Name === 'string' ? entry.Name - : typeof entry.sandbox === 'string' ? entry.sandbox - : '' - if (!name) continue - const status = typeof entry.status === 'string' ? entry.status - : typeof entry.Status === 'string' ? entry.Status - : typeof entry.state === 'string' ? entry.state - : typeof entry.State === 'string' ? entry.State - : '' - out.push({ name, status, running: status.toLowerCase().startsWith('running') }) - } - return out -} - -/** A template row reported by `sbx template ls` (whitespace-aligned table, no JSON flag). */ -export interface SbxTemplateEntry { - repository: string - tag: string -} - -/** - * Parses `sbx template ls` table output into template entries. The table has a - * `REPOSITORY TAG IMAGE ID FLAVOR CREATED` header and whitespace-aligned columns, so - * this skips blank lines and the header row (first field `REPOSITORY`), splits each - * remaining line on runs of whitespace, and reads fields `[0]` and `[1]` as repository - * and tag, dropping lines with fewer than two fields. - */ -export function parseSbxTemplateList(stdout: string): SbxTemplateEntry[] { - const entries: SbxTemplateEntry[] = [] - for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed) continue - const fields = trimmed.split(/\s+/) - if (fields[0] === 'REPOSITORY' || fields.length < 2) continue - entries.push({ repository: fields[0], tag: fields[1] }) - } - return entries -} - -/** - * Whether any template matches a reference like `oc-forge-sandbox:latest`. Splits `ref` on - * the last `:` into name and tag (defaulting the tag to `latest`); matches when the tag is - * equal and the entry repository equals the name or ends with `/${name}`, so a bare - * `oc-forge-sandbox:latest` matches a registry-qualified `docker.io/library/oc-forge-sandbox`. - */ -export function sbxTemplateMatches(entries: SbxTemplateEntry[], ref: string): boolean { - const lastColon = ref.lastIndexOf(':') - const name = lastColon === -1 ? ref : ref.slice(0, lastColon) - const tag = lastColon === -1 ? 'latest' : ref.slice(lastColon + 1) - return entries.some( - (e) => e.tag === tag && (e.repository === name || e.repository.endsWith(`/${name}`)), - ) -} - -/** Result of probing whether the `sbx` sandbox daemon is usable. */ -export type SbxAvailability = - | { available: true } - | { available: false; reason: 'not-installed' | 'daemon-down' | 'unknown'; detail?: string } - -/** - * Injectable command seam used by the runtime helpers so every method is testable without - * spawning a real `sbx` binary. `args` is the full vector (e.g. `['daemon', 'status']`). - */ -export type CommandRunner = ( - args: string[], - opts?: { timeout?: number; stdin?: string; abort?: AbortSignal }, -) => Promise<CommandResult> - -const SBX_RUNNING_RE = /^\s*status:\s*running/im -const SBX_NOT_INSTALLED_RE = /ENOENT|not found|command not found/i - -/** - * Bound for the short informational queries (`daemon status`, `ls --json`). The daemon serializes - * these behind in-flight sandbox work, so with several loops running at once they can take seconds; - * the previous 5s bound made a merely busy daemon indistinguishable from an absent one. Generous, - * but still bounded so a wedged daemon cannot hang a loop launch. - */ -const SBX_QUERY_TIMEOUT = 30000 - -/** - * 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 run that produced no answer at all — - * a rejection, or a timeout because the daemon was busy serving other sandboxes — yields - * `'unknown'`: it is not evidence the daemon is absent, and reporting `'daemon-down'` there would - * fail loop launches with remediation advice for a daemon that is actually running. - */ -export async function checkSbxAvailability(run: CommandRunner): Promise<SbxAvailability> { - let result: CommandResult - try { - result = await run(['daemon', 'status'], { timeout: SBX_QUERY_TIMEOUT }) - } catch { - return { available: false, reason: 'unknown' } - } - if (result.exitCode === 0 && SBX_RUNNING_RE.test(result.stdout)) { - return { available: true } - } - const combined = `${result.stdout}\n${result.stderr}` - if (SBX_NOT_INSTALLED_RE.test(combined)) { - return { available: false, reason: 'not-installed' } - } - if (result.exitCode === COMMAND_TIMEOUT_EXIT_CODE) { - return { - available: false, - reason: 'unknown', - detail: `\`sbx daemon status\` did not answer within ${SBX_QUERY_TIMEOUT}ms`, - } - } - return { available: false, reason: 'daemon-down', detail: combined.trim() } -} - -/** The single source of the user-facing remediation message for an unavailable sandbox. */ -export function describeSbxUnavailable( - result: Extract<SbxAvailability, { available: false }>, -): string { - switch (result.reason) { - case 'not-installed': - return 'The sbx sandbox CLI is not installed. Install the sbx CLI and run `sbx login` to authenticate, then try again.' - case 'daemon-down': - return 'The sbx daemon is not running. Start it with `sbx daemon start`, then try again.' - case 'unknown': - return `Could not determine sandbox availability. ${result.detail ?? 'Unknown error.'}` - } -} - -/** Options for creating a sandbox. */ -export interface CreateSandboxOpts { - template?: string - resources?: SandboxResources -} - -/** Options for a non-piped sandbox exec. */ -export interface SandboxExecOpts { - timeout?: number - cwd?: string - abort?: AbortSignal - envFile?: string -} - -/** - * Lifecycle state of a named sandbox. `stopped` is a normal suspended microVM that `sbx exec` - * resumes in place, so it must never be treated as gone. `unknown` means the status query itself - * failed and carries no information about the sandbox — callers must not destroy anything on it. - */ -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<SbxAvailability> - templateExists(ref: string): Promise<boolean> - loadTemplate(tarPath: string): Promise<void> - createSandbox(name: string, workspaces: SandboxWorkspace[], opts?: CreateSandboxOpts): Promise<void> - removeSandbox(name: string): Promise<void> - exec(name: string, command: string, opts?: SandboxExecOpts): Promise<CommandResult> - execPipe(name: string, command: string, stdin: string, opts?: { timeout?: number; abort?: AbortSignal; envFile?: string }): Promise<CommandResult> - getSandboxState(name: string): Promise<SandboxState> - sandboxContainerName(worktreeName: string): string - listSandboxesByPrefix(prefix: string): Promise<string[]> - allowNetworkHost(host: string): Promise<boolean> -} - -/** - * Upper bound for a single `sbx` invocation, including `sbx create`. Provisioning a microVM - * sandbox is far slower than the old container start, so this is also the only correct bound - * for anything that waits on a sandbox becoming available (see `waitForSandboxReady`). - */ -export const SBX_DEFAULT_TIMEOUT = 120000 -const SBX_TEMPLATE_LOAD_TIMEOUT = 600000 -const SBX_REMOVE_MISSING_RE = /not found|no such sandbox|unknown sandbox/i - -/** - * 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. - */ -export function createSbxRuntime(logger: Logger, opts?: { run?: CommandRunner }): SandboxRuntime { - const run: CommandRunner = - opts?.run ?? ((args, o) => runCommand('sbx', args, { ...o, logger, logLabel: 'sbx' })) - - async function checkAvailable(): Promise<SbxAvailability> { - return checkSbxAvailability(run) - } - - async function templateExists(ref: string): Promise<boolean> { - try { - const result = await run(['template', 'ls']) - if (result.exitCode !== 0) return false - return sbxTemplateMatches(parseSbxTemplateList(result.stdout), ref) - } catch { - return false - } - } - - async function loadTemplate(tarPath: string): Promise<void> { - 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}`) - } - } - - async function createSandbox( - name: string, - workspaces: SandboxWorkspace[], - opts?: CreateSandboxOpts, - ): Promise<void> { - const args = buildSbxCreateArgs(name, workspaces, { - template: opts?.template, - memory: normalizeSbxMemory(opts?.resources?.memory, logger), - cpus: parseSbxCpus(opts?.resources?.cpus, logger), - }) - const result = await run(args, { timeout: SBX_DEFAULT_TIMEOUT }) - if (result.exitCode !== 0) { - throw new Error(`Failed to create sandbox: ${result.stderr}`) - } - } - - async function removeSandbox(name: string): Promise<void> { - 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}`) - } - } - - async function exec(name: string, command: string, opts?: SandboxExecOpts): Promise<CommandResult> { - let fullCommand = command - if (opts?.cwd) { - const safeCwd = opts.cwd.replace(/'/g, "'\\''") - fullCommand = `cd '${safeCwd}' && ${command}` - } - const args = buildSbxExecArgs(name, fullCommand, { envFile: opts?.envFile }) - return run(args, { timeout: opts?.timeout ?? SBX_DEFAULT_TIMEOUT, abort: opts?.abort }) - } - - async function execPipe( - name: string, - command: string, - stdin: string, - opts?: { timeout?: number; abort?: AbortSignal; envFile?: string }, - ): Promise<CommandResult> { - const args = buildSbxExecArgs(name, command, { interactive: true, envFile: opts?.envFile }) - return run(args, { timeout: opts?.timeout ?? SBX_DEFAULT_TIMEOUT, stdin, abort: opts?.abort }) - } - - async function getSandboxState(name: string): Promise<SandboxState> { - let result: CommandResult - try { - result = await run(['ls', '--json'], { timeout: SBX_QUERY_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<string[]> { - try { - const result = await run(['ls', '--json'], { timeout: SBX_QUERY_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<boolean> { - try { - const result = await run(['policy', 'allow', 'network', host]) - return result.exitCode === 0 - } catch { - return false - } - } - - return { - checkAvailable, - templateExists, - loadTemplate, - createSandbox, - removeSandbox, - exec, - execPipe, - getSandboxState, - sandboxContainerName, - listSandboxesByPrefix, - allowNetworkHost, - } -} diff --git a/src/sandbox/session-controller.ts b/src/sandbox/session-controller.ts index 1707f3b47..3f1bd2db2 100644 --- a/src/sandbox/session-controller.ts +++ b/src/sandbox/session-controller.ts @@ -3,7 +3,7 @@ import { isAbsolute, relative, resolve, sep } from 'path' import type { Logger } from '../types' import type { SessionSandboxAppliedState, SessionSandboxDesiredState, SessionSandboxPreferencesRepo } from '../storage' import type { SandboxContext } from './context' -import type { SandboxRuntime } from './sbx' +import type { SandboxRuntime } from './msb' import type { ActiveSandbox } from './manager' import { findSessionAncestor } from '../utils/session-ancestry' @@ -102,10 +102,11 @@ export interface SessionSandboxController { /** Cap on reconcile re-runs within a single tick when the desired revision keeps moving. */ const MAX_SUPERSEDE_ITERATIONS = 8 const MAX_FAST_IDLE_POLLS = 4 +const MAX_REMOVAL_RETRY_DELAY_MS = 30_000 /** * Derives the logical manager key for a project. This is a stable, non-final key passed to - * `SandboxManager.ensureRunning`/`stop`; `sbx.sandboxContainerName` remains the only place the + * `SandboxManager.ensureRunning`/`stop`; `msb.sandboxContainerName` remains the only place the * `forge-` prefix is added. Keyed by project id to match the granularity of the desired/applied * preference rows, which are stored per project: one project row therefore maps to exactly one * host container even when the project spans several checkout directories. Deterministic so a @@ -192,6 +193,8 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep let selectedProjectDirectory: string | null = null let pollTimer: ReturnType<typeof setTimeout> | null = null let idlePolls = 0 + let removalRetryDelayMs = 0 + let removalRetryRevision: string | null = null let reconciling = false let disposed = false let startPromise: Promise<void> | null = null @@ -374,8 +377,8 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep lastValidatedRevision = null failedSelection = { sessionId, error: msg } // A failed start may leave a partially-started (or previously running) container, even on a - // first start: ensureRunning can create the container and then fail (e.g. env-file - // generation). Always attempt deterministic-key cleanup; if it fails, retain retryable + // first start: ensureRunning can create the container and then fail (e.g. create-time + // secret binding). Always attempt deterministic-key cleanup; if it fails, retain retryable // ownership so the next reconcile tick retries the removal rather than acknowledging the // failure settled while a container is still live. const stopped = await bestEffortStop() @@ -424,7 +427,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep /** * Acts on a single desired/applied pair. Returns the desired revision processed, or null - * when there is no desired state (controller remains off). Actual SBX start/stop always + * when there is no desired state (controller remains off). Actual msb start/stop always * completes before the matching applied row is written. */ async function reconcilePair( @@ -622,7 +625,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep // `restoringPersistedOn` was already set before the ownership check, so a restore that // aborts before `hostActive` is confirmed still tears the manager key down on disposal. // Recheck that the selected session has not since entered an active loop. Loop-first - // resolution ignores the host binding, so a session that started a loop while host SBX was + // resolution ignores the host binding, so a session that started a loop while host msb was // ON must have its host sandbox stopped and the acknowledgement flipped to OFF-with-error; // otherwise the container keeps running and the sidebar stays ON while the loop actually // runs unsandboxed. This runs on every trusted-ON tick (the cheap lookup, not container @@ -698,8 +701,14 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep acknowledgedSessionId = null hostActive = true failedSelection = desired.sessionId ? { sessionId: desired.sessionId, error: String(err) } : null + removalRetryRevision = desired.revision + removalRetryDelayMs = removalRetryDelayMs === 0 + ? pollIntervalMs + : Math.min(removalRetryDelayMs * 2, MAX_REMOVAL_RETRY_DELAY_MS) return desired.revision } + removalRetryDelayMs = 0 + removalRetryRevision = null hostActive = false acknowledgedSessionId = null failedSelection = null @@ -729,7 +738,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep if (desired.enabled) { // Reject an ON request that carries no session to bind: starting a container for a null // session would orphan it (bind(null) clears ownership, so it could never be used or - // cleaned up). Acknowledge OFF-with-error instead of starting SBX. + // cleaned up). Acknowledge OFF-with-error instead of starting msb. if (!desired.sessionId) { // No container is ever started for a null-session request; only stop a live container from // a prior binding (hostActive) to avoid leaking it when the selection becomes session-less. @@ -757,7 +766,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep return desired.revision } // Refuse to bind a host sandbox to an active loop session: loop-first resolution ignores - // the host binding, so acknowledging ON here would report an SBX state that is never used. + // the host binding, so acknowledging ON here would report an msb state that is never used. if (deps.resolveActiveLoopForSession) { const inLoop = await deps.resolveActiveLoopForSession(desired.sessionId) if (inLoop?.active) { @@ -831,6 +840,10 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep hostActive = true lastValidatedRevision = null failedSelection = desired.sessionId ? { sessionId: desired.sessionId, error: msg } : null + removalRetryRevision = desired.revision + removalRetryDelayMs = removalRetryDelayMs === 0 + ? pollIntervalMs + : Math.min(removalRetryDelayMs * 2, MAX_REMOVAL_RETRY_DELAY_MS) writeApplied({ version: 1, revision: desired.revision, @@ -845,6 +858,8 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep acknowledgedSessionId = null failedSelection = null lastValidatedRevision = null + removalRetryDelayMs = 0 + removalRetryRevision = null writeApplied({ version: 1, revision: desired.revision, @@ -889,14 +904,19 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep function schedulePoll(): void { if (disposed || pollTimer !== null) return const desired = preferences.getDesired(projectId) + if (removalRetryDelayMs > 0 && desired?.revision !== removalRetryRevision) { + removalRetryDelayMs = 0 + removalRetryRevision = null + } const active = hostActive || pendingCleanup || (desired?.enabled === true && failedSelection === null) if (active) idlePolls = 0 const fast = active || idlePolls < MAX_FAST_IDLE_POLLS if (!active) idlePolls += 1 + const delay = removalRetryDelayMs > 0 ? removalRetryDelayMs : (fast ? pollIntervalMs : pollIntervalMs * 10) pollTimer = setTimeout(() => { pollTimer = null void tick() - }, fast ? pollIntervalMs : pollIntervalMs * 10) + }, delay) } async function resolveSandboxForSession( @@ -949,7 +969,7 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep } catch (err) { const msg = err instanceof Error ? err.message : String(err) logger.log(`[session-sandbox] ensureRunning failed during restore: ${msg}`) - // A container-restore failure (e.g. env-file generation) can leave a partially-created or + // A container-restore failure (e.g. create-time secret binding) can leave a partially-created or // orphaned container and a stale applied-ON row. Route through the same cleanup and // OFF-with-error transition as a failed start so the live container is removed, the stale // acknowledgement is corrected, and the selected session stays fail-closed. Attribute the @@ -1001,7 +1021,6 @@ export function createSessionSandboxController(deps: SessionSandboxControllerDep containerName: active.containerName, hostDir: active.projectDir, mounts: active.mounts ?? [{ hostDir: active.projectDir, containerDir: active.projectDir }], - envFile: active.envFile, } }) } diff --git a/src/sandbox/shell-shim.ts b/src/sandbox/shell-shim.ts index 26a1756bd..583d90821 100644 --- a/src/sandbox/shell-shim.ts +++ b/src/sandbox/shell-shim.ts @@ -9,7 +9,6 @@ export const SHELL_SHIM_FILENAME = 'forge-shell' * opencode `shell.env` plugin hook, so only agent shell commands are affected. */ export const SHIM_ENV_CONTAINER = 'FORGE_SANDBOX_CONTAINER' -export const SHIM_ENV_ENV_FILE = 'FORGE_SANDBOX_ENV_FILE' export const SHIM_ENV_HOST_SHELL = 'FORGE_HOST_SHELL' /** @@ -38,15 +37,14 @@ export function buildShimScript(hostShell: string): string { # 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. +# \`msb exec\`. All other sessions fall through to the host shell unchanged. # -# Fail-closed: when a container is expected, any sbx failure surfaces as a -# non-zero exit — the command must never silently run on the host instead. +# Fail-closed: when a container is expected, any msb failure must surface as a +# non-zero exit — the command must never silently run on the host instead. msb +# exec propagates the guest command's exit code verbatim, so the bash tool keeps +# seeing real exit statuses. 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 "$@" + exec msb exec --quiet "$${SHIM_ENV_CONTAINER}" --no-tty -w "$PWD" -- bash "$@" fi exec "\${${SHIM_ENV_HOST_SHELL}:-${hostShell}}" "$@" ` diff --git a/src/sandbox/template.ts b/src/sandbox/template.ts index 133f9b21f..4ec4d3aeb 100644 --- a/src/sandbox/template.ts +++ b/src/sandbox/template.ts @@ -1,6 +1,6 @@ /** - * Builds the sandbox template image with Docker and loads it into the `sbx` store. - * Docker still produces the image, but `sbx` needs it in its own image store, so the + * Builds the sandbox template image with Docker and loads it into the `msb` image store. + * Docker still produces the image, but `msb` needs it in its own image store, so the * palette command becomes build -> save -> load. Both steps live here so there is a * single point of truth for the sequence and its failure messages. */ @@ -16,7 +16,7 @@ export const DEFAULT_SANDBOX_IMAGE = 'oc-forge-sandbox:latest' export interface BuildTemplateDeps { runCommand: typeof runCommand - loadTemplate: (tar: string) => Promise<void> + loadTemplate: (tar: string, ref: string) => Promise<void> logger: Logger tmpDir: string } @@ -33,7 +33,7 @@ export function buildTemplateDockerArgs(options?: SandboxTemplateOptions): strin export function formatTemplateBuildCommands(contextDir: string, tag: string, options?: SandboxTemplateOptions): string { const build = ['docker', 'build', ...buildTemplateDockerArgs(options), '-t', tag, `"${contextDir}"`].join(' ') - return `${build} && docker save ${tag} -o <tar> && sbx template load <tar>` + return `${build} && docker save ${tag} -o <tar> && msb load --input <tar> --tag ${tag}` } function dockerStageError(stage: 'build' | 'save', result: CommandResult): Error { @@ -43,7 +43,7 @@ function dockerStageError(stage: 'build' | 'save', result: CommandResult): Error return new Error(`Docker ${stage} timed out after ${seconds} seconds.`) } if (DOCKER_NOT_FOUND_RE.test(output)) { - return new Error('Docker CLI not found. Building the sandbox template requires Docker; the sbx runtime itself does not.') + return new Error('Docker CLI not found. Building the sandbox template requires Docker; the msb runtime itself does not.') } const lastLine = output.split('\n').filter(Boolean).at(-1)?.trim() return new Error(`Docker ${stage} failed: ${lastLine ?? output.trim()}`) @@ -51,7 +51,7 @@ function dockerStageError(stage: 'build' | 'save', result: CommandResult): Error /** * Builds `<tag>` from `contextDir` with Docker, saves it to a temp tar, loads that tar - * into the sbx template store, and removes the tar on both the success and failure paths. + * into the msb image store, and removes the tar on both the success and failure paths. */ export async function buildAndLoadSandboxTemplate( contextDir: string, @@ -75,7 +75,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..d13f0d490 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -98,7 +98,7 @@ export interface ForgeLoopExtra { pendingAttachStartedAt?: number /** Whether the loop runs sandboxed. Written by the attach hook and remote launches; read on re-attach. */ sandboxEnabled?: boolean - /** Docker container name when the loop runs sandboxed. */ + /** msb container name when the loop runs sandboxed. */ sandboxContainer?: string } @@ -739,16 +739,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 cn = runtime.sandboxContainerName(loopName) - if (await runtime.getSandboxState(cn) !== 'missing') { - await runtime.removeSandbox(cn) - } - } catch (cleanupErr) { - deps.logger.error('attachLoopToSession: failed to remove sandbox container after timeout', cleanupErr) - } + await deps.sandboxManager.stop(loopName).catch((err) => deps.logger.error('attachLoopToSession: failed to remove sandbox container after timeout', err)) deps.loop.unregisterSessionReverseIndex(sessionId) deps.loop.service.deleteState(loopName) return { ok: false, code: 'internal_error', message: `Sandbox not ready: ${waitResult.reason}` } @@ -1869,8 +1860,8 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (restartSandbox && deps.sandboxManager) { try { - const sbxResult = await deps.sandboxManager.start(stoppedState.loopName, stoppedState.worktreeDir) - deps.logger.log(`loop-restart: started sandbox container ${sbxResult.containerName}`) + const sandboxResult = await deps.sandboxManager.start(stoppedState.loopName, stoppedState.worktreeDir) + deps.logger.log(`loop-restart: started sandbox container ${sandboxResult.containerName}`) } catch (err) { deps.logger.error('loop-restart: failed to start sandbox container', err) return { ok: false, error: 'Restart failed: could not start sandbox container.' } diff --git a/src/storage/migrations/index.ts b/src/storage/migrations/index.ts index 76f7542c3..e45592656 100644 --- a/src/storage/migrations/index.ts +++ b/src/storage/migrations/index.ts @@ -1,11 +1,9 @@ import { Database } from 'bun:sqlite' import { readFileSync } from 'fs' import { join } from 'path' -import { fileURLToPath } from 'url' -import { dirname } from 'path' +import { resolveShippedRoot } from '../../utils/shipped-paths' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +const migrationsDir = join(resolveShippedRoot(import.meta.url), 'storage', 'migrations') interface Migration { id: string @@ -14,7 +12,7 @@ interface Migration { } function loadSql(filename: string): string { - return readFileSync(join(__dirname, filename), 'utf-8') + return readFileSync(join(migrationsDir, filename), 'utf-8') } export const migrations: Migration[] = [ diff --git a/src/tools/review.ts b/src/tools/review.ts index 65ea16e00..2d5593a26 100644 --- a/src/tools/review.ts +++ b/src/tools/review.ts @@ -34,14 +34,13 @@ export function createReviewTools(ctx: ToolContext): Record<string, ReturnType<t return { 'review-write': tool({ - description: 'Store a code review finding with file location, severity, and description. Automatically injects loopName and sectionIndex from the current loop section. Use crossSection: true to write a cross-section finding (sectionIndex null). Use sectionIndex to override the auto-injected value.', + description: 'Store a code review finding with file location, severity, and description. Automatically injects loopName and sectionIndex from the current loop section (not during the final audit — pass sectionIndex explicitly there). Use crossSection: true to write a cross-section finding (sectionIndex null). Use sectionIndex to override the auto-injected value.', args: { file: z.string().describe('The file path where the finding is located'), line: z.number().describe('The line number of the finding'), severity: z.enum(['bug', 'warning']).describe('The severity of the finding'), description: z.string().describe('Clear description of the issue'), scenario: z.string().optional().describe('The specific conditions under which this issue manifests'), - status: z.string().default('open').describe('The status of the finding (default: "open")'), crossSection: z.boolean().optional().describe('Set true if the finding spans multiple sections. Defaults to false.'), sectionIndex: z.number().optional().describe('Explicitly set section index. Defaults to current section in a sectioned loop.'), }, @@ -60,7 +59,10 @@ export function createReviewTools(ctx: ToolContext): Record<string, ReturnType<t row.loopName = await resolveLoopName(toolCtx) if (row.loopName) { const loopState = loop.service.getActiveState(row.loopName) - if (loopState && loopState.totalSections > 0) { + // During the final audit the "current section" is just the last section; + // auto-injecting it would misattribute cross-section findings, so the + // final audit must pass sectionIndex explicitly (matching read/delete). + if (loopState && loopState.totalSections > 0 && !isFinalAuditScope(loopState.phase)) { row.sectionIndex = loopState.currentSectionIndex } } diff --git a/src/tui.tsx b/src/tui.tsx index a57350b9f..346c00d65 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 { createMsbRuntime } from './sandbox/msb' import { buildAndLoadSandboxTemplate, DEFAULT_SANDBOX_IMAGE } from './sandbox/template' import { runCommand } from './sandbox/process' import { isSandboxConfigEnabled } from './sandbox/context' @@ -51,7 +51,7 @@ type TuiOptions = { type ForgeConnectionStatus = 'connecting' | 'connected' | 'unavailable' -const SBX_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const MSB_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] function SandboxLoadingSpinner(props: { api: TuiPluginApi }) { const [frame, setFrame] = createSignal(0) @@ -59,11 +59,11 @@ function SandboxLoadingSpinner(props: { api: TuiPluginApi }) { createEffect(() => { if (!animationsEnabled()) return - const timer = setInterval(() => setFrame((current) => (current + 1) % SBX_SPINNER_FRAMES.length), 80) + const timer = setInterval(() => setFrame((current) => (current + 1) % MSB_SPINNER_FRAMES.length), 80) onCleanup(() => clearInterval(timer)) }) - return <text fg={props.api.theme.current.textMuted}>{animationsEnabled() ? SBX_SPINNER_FRAMES[frame()] : '⋯'}</text> + return <text fg={props.api.theme.current.textMuted}>{animationsEnabled() ? MSB_SPINNER_FRAMES[frame()] : '⋯'}</text> } function SandboxStatusText(props: { api: TuiPluginApi; preference: () => SessionSandboxPreference | null; sessionId?: string }) { @@ -80,10 +80,10 @@ function SandboxStatusText(props: { api: TuiPluginApi; preference: () => Session return ( <Show when={status() === 'loading'} - fallback={<text fg={statusColor()}>· SBX {status()}</text>} + fallback={<text fg={statusColor()}>· MSB {status()}</text>} > <box flexDirection="row" gap={1}> - <text fg={theme().textMuted}>· SBX</text> + <text fg={theme().textMuted}>· MSB</text> <SandboxLoadingSpinner api={props.api} /> </box> </Show> @@ -249,7 +249,7 @@ function SandboxBuildDialog(props: { try { await buildAndLoadSandboxTemplate(props.buildContextDir, props.image, { runCommand, - loadTemplate: (tar) => createSbxRuntime(logger).loadTemplate(tar), + loadTemplate: (tar, ref) => createMsbRuntime(logger).loadTemplate(tar, ref), logger, tmpDir: tmpdir(), }, { browserControl: props.browserControl }) @@ -274,7 +274,7 @@ function SandboxBuildDialog(props: { <box paddingBottom={1}> <text fg={theme().textMuted}> - This builds the sandbox image with Docker, then loads it into sbx. + This builds the sandbox image with Docker, then loads it into msb. </text> </box> <box paddingBottom={1}> @@ -479,7 +479,7 @@ const tui: TuiPlugin = async (api) => { setSandboxProjectId(projectId) } // Each failure below reports a distinct cause. The TUI has no usable log sink (console output - // corrupts the rendered screen, which is why the sbx runtime here is given a no-op logger), so + // corrupts the rendered screen, which is why the msb runtime here is given a no-op logger), so // the reason has to travel in the toast or it is lost entirely. if (!projectId) { api.ui.toast({ message: 'Sandbox toggle unavailable: could not resolve this project', variant: 'warning', duration: 5000 }) @@ -641,7 +641,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 msb', category: 'Forge', namespace: 'palette', run: () => { runBuildSandboxImage() }, diff --git a/src/types.ts b/src/types.ts index 4a5102005..0f098c840 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,7 +78,7 @@ export interface LoopConfig { * worktree isolation (e.g. an Obsidian vault). Each entry is granted via `external_directory` * allow rules layered over the default deny, and is additionally bind-mounted read-only into * the sandbox so in-container `bash`/`glob`/`grep` resolve the same tree host `read` does. - * Always an absolute host path: sbx mounts every workspace at its identical host path, so the + * Always an absolute host path: msb mounts every workspace at its identical host path, so the * same value is correct on both sides. Use `sandbox.mounts` for read-write container access. */ allowExternalDirectories?: string[] @@ -101,31 +101,60 @@ export interface LoopConfig { permissions?: LoopPermissionsConfig } +/** + * A host-held credential bound to a sandbox at create time. The real value stays on the host: + * msb keeps a source reference to the environment variable, exposes a `$MSB_<env>` placeholder + * inside the sandbox, and substitutes the real value only for the listed hosts at the network + * boundary — the value never enters the guest. + */ +export interface SandboxSecretConfig { + /** Host environment variable name that holds the secret value. */ + env: string + /** Hostnames allowed to receive the real value at the network boundary. */ + hosts: string[] +} + /** * Network access configuration for the sandbox. - * Controls egress allow-listing and environment passthrough. + * Controls egress allow-listing and credential delivery. */ export interface SandboxNetworkConfig { - /** Environment variable names to pass through from host process into the sandbox. */ + /** Environment variable names to pass through from the host process into the sandbox at + * create time. Only names that are set in the host process are injected, as plain guest + * environment variables (msb resolves a bare name from its own environment, so values + * never appear on forge's command line). */ env?: string[] - /** Hostnames to allow through the sbx network proxy via `sbx policy allow network`. */ + /** Hostnames to allow through msb's per-sandbox egress proxy via `--net-rule allow@<host>`. */ allow?: string[] + /** Host-held credentials exposed to the sandbox only as `$MSB_<env>` placeholders. The real + * value never enters the guest: msb substitutes it only for the listed hosts at the network + * boundary. */ + secrets?: SandboxSecretConfig[] } /** - * Resource limits for the sandbox. Maps directly to `sbx create` flags. - * sbx defaults are often too tight for many real projects — `pnpm install` + * Resource limits for the sandbox. Maps directly to `msb create` flags. + * msb 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 allocated at boot, e.g. '8g', '1024m'. Maps to `msb create -m`. */ memory?: string - /** Number of CPUs. `sbx create --cpus` is integer-only. */ + /** Boot-time ceiling for hotpluggable memory, e.g. '16g'. Maps to `msb create --max-memory`. + * Omit to pin the sandbox at `memory`. msb requires it to be >= `memory`. */ + maxMemory?: string + /** Number of CPUs allocated at boot. `msb create -c` is integer-only. */ cpus?: string + /** Boot-time ceiling for virtual CPUs. Maps to `msb create --max-cpus`, integer-only. + * Omit to pin the sandbox at `cpus`. msb requires it to be >= `cpus`. */ + maxCpus?: string + /** Size of the dedicated disk backing the sandbox's Docker Engine data dir (`/var/lib/docker`), + * e.g. '16g'. Maps to the `--mount-named ...:kind=disk,size=<size>` volume. Defaults to '16g'. */ + dockerDisk?: string } /** - * A single custom mount for the sbx sandbox. `sbx` always mounts a workspace + * A single custom mount for the msb sandbox. `msb` always mounts a workspace * at its identical host path, so only the host path is specified. */ export interface SandboxMountConfig { @@ -140,23 +169,23 @@ export interface SandboxImageFeaturesConfig { } /** - * Configuration for the sandbox execution environment (sbx). + * Configuration for the sandbox execution environment (msb). */ export interface SandboxConfig { - /** Sandbox mode. Currently only 'sbx' is supported. Reserved for future modes. */ - mode?: 'sbx' - /** Enable sandboxed execution. When false, loops run in worktree-only mode even if sbx is available. Default: true. */ + /** Sandbox mode. Currently only 'msb' is supported. Reserved for future modes. */ + mode?: 'msb' + /** Enable sandboxed execution. When false, loops run in worktree-only mode even if msb is available. Default: true. */ enabled?: boolean - /** sbx template tag to use for sandboxed execution. */ + /** msb image reference (tag) to use for sandboxed execution. */ image?: string imageFeatures?: SandboxImageFeaturesConfig /** Resource limits. Defaults to memory=8g, cpus=4. */ resources?: SandboxResources /** Mount the source project directory read-only. Defaults to true. */ mountProjectReadonly?: boolean - /** Additional host directories to mount into the sbx sandbox. */ + /** Additional host directories to mount into the msb sandbox. */ mounts?: SandboxMountConfig[] - /** Network access configuration (egress allow-list, env passthrough). */ + /** Network access configuration (egress allow-list, env passthrough, host-held secrets). */ network?: SandboxNetworkConfig } diff --git a/src/utils/git-service.ts b/src/utils/git-service.ts index 3d528c2be..32f54ea2e 100644 --- a/src/utils/git-service.ts +++ b/src/utils/git-service.ts @@ -26,8 +26,10 @@ export interface GitService { push(cwd: string, remote: string, refspec: string, force: boolean): GitResult fetchRef(cwd: string, remote: string, ref: string): GitResult worktreeAdd(cwd: string, directory: string, branch: string, createBranch: boolean, startPoint?: string): GitResult + worktreeList(cwd: string): GitResult worktreeRemove(cwd: string, directory: string): GitResult worktreePrune(cwd: string): GitResult + branchDelete(cwd: string, branch: string): GitResult } /** @@ -121,6 +123,10 @@ export function createGitService(): GitService { return runGit(createBranch ? ['worktree', 'add', directory, '-b', branch] : ['worktree', 'add', directory, branch], cwd) }, + worktreeList(cwd: string): GitResult { + return runGit(['worktree', 'list', '--porcelain'], cwd) + }, + worktreeRemove(cwd: string, directory: string): GitResult { return runGit(['worktree', 'remove', '-f', directory], cwd) }, @@ -128,6 +134,10 @@ export function createGitService(): GitService { worktreePrune(cwd: string): GitResult { return runGit(['worktree', 'prune'], cwd) }, + + branchDelete(cwd: string, branch: string): GitResult { + return runGit(['branch', '-D', branch], cwd) + }, } } diff --git a/src/utils/sandbox-ready.ts b/src/utils/sandbox-ready.ts index c8b02a62c..eca8d3a03 100644 --- a/src/utils/sandbox-ready.ts +++ b/src/utils/sandbox-ready.ts @@ -7,14 +7,14 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' -import { SBX_DEFAULT_TIMEOUT } from '../sandbox/sbx' +import { MSB_DEFAULT_TIMEOUT } from '../sandbox/msb' export interface WaitForSandboxOptions { projectId: string loopName: string dbPath: string pollMs?: number // default 200 - /** Defaults to the sbx provisioning bound, so the wait never expires while `sbx create` is still allowed to run. */ + /** Defaults to the msb provisioning bound, so the wait never expires while `msb create` is still allowed to run. */ timeoutMs?: number } @@ -34,7 +34,7 @@ export type WaitForSandboxResult = export async function waitForSandboxReady(opts: WaitForSandboxOptions): Promise<WaitForSandboxResult> { const { projectId, loopName, dbPath } = opts const pollMs = opts.pollMs ?? 200 - const timeoutMs = opts.timeoutMs ?? SBX_DEFAULT_TIMEOUT + const timeoutMs = opts.timeoutMs ?? MSB_DEFAULT_TIMEOUT const startTime = Date.now() // Check if database exists diff --git a/src/utils/section-summary.ts b/src/utils/section-summary.ts index c5e727aa0..ea8464fb7 100644 --- a/src/utils/section-summary.ts +++ b/src/utils/section-summary.ts @@ -1,6 +1,2 @@ export const SECTION_SUMMARY_START_MARKER = '<!-- section-summary:start -->' export const SECTION_SUMMARY_END_MARKER = '<!-- section-summary:end -->' - -export function hasSectionSummaryMarkers(text: string): boolean { - return text.includes(SECTION_SUMMARY_START_MARKER) && text.includes(SECTION_SUMMARY_END_MARKER) -} diff --git a/src/utils/shipped-paths.ts b/src/utils/shipped-paths.ts new file mode 100644 index 000000000..80ac3d6dc --- /dev/null +++ b/src/utils/shipped-paths.ts @@ -0,0 +1,38 @@ +import { basename, dirname } from 'path' +import { fileURLToPath } from 'url' + +/** + * Resolve the root of the shipped module tree from the URL of a compiled or + * source module inside it. + * + * Why this exists: several modules independently derived on-disk locations from + * `import.meta.url`, each one hard-coding the unbundled `tsc` output layout + * (`dist/install/paths.js`, `dist/storage/migrations/index.js`, and + * `dist/prompts/loader.js` respectively). Once the server entry is bundled into + * a single `dist/index.js`, every `import.meta.url` inside that bundle collapses + * to the same file, so those per-module anchors would all misresolve. This + * function is the single shared anchor: it walks up from the module's own + * directory to the nearest `dist` (published build) or `src` (source runs via + * `bun`) ancestor, which is the layout root for every shipped or in-development + * module. + * + * Nearest-match is deliberate: a `dist` or `src` directory higher in the user's + * absolute path (for example `/Users/x/src/...`) must not win over the one that + * actually contains the module tree. + * + * If the filesystem root is reached without finding a `dist` or `src` ancestor, + * the module's own directory is returned unchanged as a safe fallback, so + * callers keep a deterministic base instead of throwing. + */ +export function resolveShippedRoot(moduleUrl: string): string { + const startDir = dirname(fileURLToPath(moduleUrl)) + let current = startDir + while (true) { + if (basename(current) === 'dist' || basename(current) === 'src') { + return current + } + const parent = dirname(current) + if (parent === current) return startDir + current = parent + } +} diff --git a/src/workspace/forge-adapter.ts b/src/workspace/forge-adapter.ts index 5568b21cb..48f69a8c0 100644 --- a/src/workspace/forge-adapter.ts +++ b/src/workspace/forge-adapter.ts @@ -1,6 +1,6 @@ import { join } from 'path' import { mkdir } from 'fs/promises' -import { existsSync, readFileSync, appendFileSync, rmSync } from 'fs' +import { existsSync, readFileSync, appendFileSync } from 'fs' import type { WorkspaceAdapter, WorkspaceInfo } from '@opencode-ai/plugin' import type { Logger } from '../types' import type { SandboxManager } from '../sandbox/manager' @@ -9,7 +9,8 @@ import { cleanupLoopWorktree } from '../utils/worktree-cleanup' import { defaultGitService, type GitService } from '../utils/git-service' import { forgeSyncRef, DEFAULT_GIT_REMOTE } from '../utils/remote-config' import { writeWorktreeOpencodeConfig, WORKTREE_OPENCODE_CONFIG_FILENAME } from './worktree-opencode-config' -import { sandboxContainerName } from '../sandbox/sbx' +import { commitWorktreeChanges } from './worktree-commit' +import { sandboxContainerName } from '../sandbox/msb' /** @@ -108,51 +109,20 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd } } - /** - * Remove the forge-written `opencode.jsonc` before a teardown commit so the - * inline per-loop config never enters loop history. Only an untracked file is - * removed: when the repository already tracks an `opencode.jsonc`, forge never - * wrote it (skip-if-exists), so it is left untouched and its edits still commit. - * The worktree itself is torn down at teardown, so the removed file is not lost. - */ - function removeForgeWrittenOpencodeConfig(directory: string): void { - const configPath = join(directory, WORKTREE_OPENCODE_CONFIG_FILENAME) - if (!existsSync(configPath)) return - if (git.isPathTracked(directory, WORKTREE_OPENCODE_CONFIG_FILENAME)) return - try { - rmSync(configPath, { force: true }) - logger.log(`forge-adapter: removed forge-written ${WORKTREE_OPENCODE_CONFIG_FILENAME} before commit in ${directory}`) - } catch (err) { - logger.log(`forge-adapter: could not remove ${WORKTREE_OPENCODE_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`) - } - } - async function stepCommitChanges(loopName: string, directory: string, branchLabel: string, ctx: TeardownContext): Promise<void> { if (!ctx.doCommit || !existsSync(directory)) return - removeForgeWrittenOpencodeConfig(directory) - try { - const addResult = git.addAll(directory) - if (!addResult.ok) { - logger.log(`forge-adapter: git add failed during teardown: ${addResult.stderr.trim() || 'unknown error'}`) - return - } - - const statusResult = git.statusPorcelain(directory) - if (!statusResult.ok || !statusResult.stdout.trim()) { - logger.log(`forge-adapter: no pending changes to commit on ${branchLabel}`) - return - } - const iterLabel = ctx.iteration === 1 ? 'iteration' : 'iterations' const message = `loop: ${loopName} ${ctx.reasonLabel} after ${ctx.iteration} ${iterLabel}` - const commitResult = git.commit(directory, message) + const outcome = commitWorktreeChanges(git, logger, directory, message) - if (commitResult.ok) { + if (outcome === 'committed') { logger.log(`forge-adapter: committed pending changes on ${branchLabel}`) + } else if (outcome === 'no-changes') { + logger.log(`forge-adapter: no pending changes to commit on ${branchLabel}`) } else { - logger.log(`forge-adapter: commit failed on ${branchLabel}: ${commitResult.stderr.trim() || 'unknown error'}`) + logger.log(`forge-adapter: commit failed on ${branchLabel}`) } } catch (err) { logger.error('forge-adapter: commit step threw during teardown', err) diff --git a/src/workspace/worktree-commit.ts b/src/workspace/worktree-commit.ts new file mode 100644 index 000000000..f18790721 --- /dev/null +++ b/src/workspace/worktree-commit.ts @@ -0,0 +1,58 @@ +import { join } from 'path' +import { existsSync, rmSync } from 'fs' +import type { Logger } from '../types' +import type { GitService } from '../utils/git-service' +import { WORKTREE_OPENCODE_CONFIG_FILENAME } from './worktree-opencode-config' + +export type WorktreeCommitOutcome = 'committed' | 'no-changes' | 'failed' + +/** + * Remove the forge-written `opencode.jsonc` before committing so the inline + * per-loop config never enters loop history. Only an untracked file is + * removed: when the repository already tracks an `opencode.jsonc`, forge never + * wrote it (skip-if-exists), so it is left untouched and its edits still commit. + */ +function removeForgeWrittenOpencodeConfig(git: GitService, logger: Logger, directory: string): void { + const configPath = join(directory, WORKTREE_OPENCODE_CONFIG_FILENAME) + if (!existsSync(configPath)) return + if (git.isPathTracked(directory, WORKTREE_OPENCODE_CONFIG_FILENAME)) return + try { + rmSync(configPath, { force: true }) + logger.log(`worktree-commit: removed forge-written ${WORKTREE_OPENCODE_CONFIG_FILENAME} before commit in ${directory}`) + } catch (err) { + logger.log(`worktree-commit: could not remove ${WORKTREE_OPENCODE_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`) + } +} + +/** + * Single point of truth for committing a forge worktree's pending changes: + * strips the forge-written opencode config, stages everything, and commits. + * Used by both the teardown commit (forge-adapter) and the per-section + * checkpoint commits (loop runtime). Failures are logged, never thrown — a + * missed commit degrades bookkeeping, it must not break the caller. + */ +export function commitWorktreeChanges(git: GitService, logger: Logger, directory: string, message: string): WorktreeCommitOutcome { + removeForgeWrittenOpencodeConfig(git, logger, directory) + + const addResult = git.addAll(directory) + if (!addResult.ok) { + logger.log(`worktree-commit: git add failed in ${directory}: ${addResult.stderr.trim() || 'unknown error'}`) + return 'failed' + } + + const statusResult = git.statusPorcelain(directory) + if (!statusResult.ok) { + logger.log(`worktree-commit: git status failed in ${directory}: ${statusResult.stderr.trim() || 'unknown error'}`) + return 'failed' + } + if (!statusResult.stdout.trim()) { + return 'no-changes' + } + + const commitResult = git.commit(directory, message) + if (!commitResult.ok) { + logger.log(`worktree-commit: commit failed in ${directory}: ${commitResult.stderr.trim() || 'unknown error'}`) + return 'failed' + } + return 'committed' +} diff --git a/test/agents.test.ts b/test/agents.test.ts index 47321a582..4c6f0718d 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -122,13 +122,17 @@ describe('Agent definitions', () => { test('auditor-loop prompt includes LOOP_ADDENDUM and FINAL_AUDIT_ADDENDUM content', () => { const prompt = auditorLoopAgent.systemPrompt expect(prompt).toContain('<!-- forge-section -->') - expect(prompt).toContain('section-summary:start') + expect(prompt).toContain('section-summary block') expect(prompt).toContain('### Done') expect(prompt).toContain('### Deviations') expect(prompt).toContain('### Follow-ups') expect(prompt.toLowerCase()).toContain('deviation acceptance') }) + test('auditor-loop system prompt does not duplicate the summary marker template (owned by buildSectionAuditPrompt)', () => { + expect(auditorLoopAgent.systemPrompt).not.toContain('section-summary:start') + }) + test('auditor-loop final rules contain direct Whole-Change Impact Analysis with the four concrete categories', () => { const prompt = auditorLoopAgent.systemPrompt expect(prompt).toContain('### Whole-Change Impact Analysis') diff --git a/test/helpers/fake-git.ts b/test/helpers/fake-git.ts index 7f2d2481d..c7855ef28 100644 --- a/test/helpers/fake-git.ts +++ b/test/helpers/fake-git.ts @@ -21,8 +21,10 @@ export function createFakeGitService(overrides?: Partial<GitService>): GitServic push: vi.fn<[string, string, string, boolean], GitResult>(() => ({ ...defaultOk })), fetchRef: vi.fn<[string, string, string], GitResult>(() => ({ ...defaultOk })), worktreeAdd: vi.fn<[string, string, string, boolean, string?], GitResult>(() => ({ ...defaultOk })), + worktreeList: vi.fn<[string], GitResult>(() => ({ ...defaultOk })), worktreeRemove: vi.fn<[string, string], GitResult>(() => ({ ...defaultOk })), worktreePrune: vi.fn<[string], GitResult>(() => ({ ...defaultOk })), + branchDelete: vi.fn<[string, string], GitResult>(() => ({ ...defaultOk })), ...overrides, } } diff --git a/test/helpers/sandbox-mocks.ts b/test/helpers/sandbox-mocks.ts index 56ada5815..86e5d9ea5 100644 --- a/test/helpers/sandbox-mocks.ts +++ b/test/helpers/sandbox-mocks.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest' -import type { SandboxWorkspace, SandboxRuntime, SandboxState } from '../../src/sandbox/sbx' -import type { SandboxResources } from '../../src/types' +import type { SandboxWorkspace, SandboxRuntime, SandboxState } from '../../src/sandbox/msb' +import type { SandboxResources, SandboxSecretConfig } from '../../src/types' /** * Mock SandboxRuntime plus the test helpers used by the manager suites. Extending @@ -9,8 +9,13 @@ import type { SandboxResources } from '../../src/types' */ export interface MockSandboxRuntime extends SandboxRuntime { getCreateSandboxCalls(): Array< - [string, SandboxWorkspace[], { template?: string; resources?: SandboxResources } | undefined] + [ + string, + SandboxWorkspace[], + { image?: string; resources?: SandboxResources; networkAllow?: string[]; env?: string[]; secrets?: SandboxSecretConfig[] } | undefined, + ] > + getRefreshSecretCalls(): Array<[string, SandboxSecretConfig[]]> getRemoveSandboxCalls(): string[] setSandboxes(newSandboxes: string[]): void setRunning(name: string, running: boolean): void @@ -26,9 +31,14 @@ export interface MockSandboxRuntime extends SandboxRuntime { */ export function createMockSandboxRuntime(): MockSandboxRuntime { const createSandboxCalls: Array< - [string, SandboxWorkspace[], { template?: string; resources?: SandboxResources } | undefined] + [ + string, + SandboxWorkspace[], + { image?: string; resources?: SandboxResources; networkAllow?: string[]; env?: string[]; secrets?: SandboxSecretConfig[] } | undefined, + ] > = [] const removeSandboxCalls: string[] = [] + const refreshSecretCalls: Array<[string, SandboxSecretConfig[]]> = [] let sandboxes = ['forge-foo', 'forge-bar'] const sandboxStates = new Map<string, SandboxState>() let shouldBeAvailable = true @@ -38,13 +48,13 @@ export function createMockSandboxRuntime(): MockSandboxRuntime { const mock: MockSandboxRuntime = { checkAvailable: async () => shouldBeAvailable ? { available: true as const } - : { available: false as const, reason: 'daemon-down' as const, detail: 'mock daemon down' }, + : { available: false as const, reason: 'host-unsupported' as const, detail: 'mock daemon down' }, templateExists: async () => shouldTemplateExist, loadTemplate: async () => {}, createSandbox: async ( name: string, workspaces: SandboxWorkspace[], - opts?: { template?: string; resources?: SandboxResources }, + opts?: { image?: string; resources?: SandboxResources; networkAllow?: string[]; env?: string[]; secrets?: SandboxSecretConfig[] }, ) => { createSandboxCalls.push([name, workspaces, opts]) sandboxStates.set(name, 'running') @@ -56,12 +66,15 @@ export function createMockSandboxRuntime(): MockSandboxRuntime { } }, exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - execPipe: async () => ({ stdout: '', stderr: '', exitCode: 0 }), getSandboxState: async (name: string) => sandboxStates.get(name) ?? 'missing', sandboxContainerName: (worktreeName: string) => `forge-${worktreeName}`, listSandboxesByPrefix: async (prefix: string) => sandboxes.filter((n) => n.startsWith(prefix)), - allowNetworkHost: async () => true, + refreshSandboxSecrets: async (name: string, secrets: SandboxSecretConfig[]) => { + refreshSecretCalls.push([name, secrets]) + return true + }, getCreateSandboxCalls: () => createSandboxCalls, + getRefreshSecretCalls: () => refreshSecretCalls, getRemoveSandboxCalls: () => removeSandboxCalls, setSandboxes: (newSandboxes: string[]) => { sandboxes = newSandboxes diff --git a/test/hooks/loop-section-advancement.test.ts b/test/hooks/loop-section-advancement.test.ts index 478593e44..aeeae64fd 100644 --- a/test/hooks/loop-section-advancement.test.ts +++ b/test/hooks/loop-section-advancement.test.ts @@ -593,6 +593,95 @@ describe('Loop Section Advancement', () => { }) }) + describe('Section checkpoint commits', () => { + function createFakeGitService(overrides: { statusStdout?: string } = {}) { + const commits: Array<{ cwd: string; message: string }> = [] + const ok = { ok: true, status: 0, stdout: '', stderr: '' } + const git = { + addAll: () => ({ ...ok }), + statusPorcelain: () => ({ ...ok, stdout: overrides.statusStdout ?? ' M src/file.ts' }), + commit: (cwd: string, message: string) => { + commits.push({ cwd, message }) + return { ...ok } + }, + isPathTracked: () => false, + } as unknown as import('../../src/utils/git-service').GitService + return { git, commits } + } + + function buildHandler(gitService: import('../../src/utils/git-service').GitService) { + const { client: forgeClient } = createFakeForgeClient({ + session: { messages: async () => assistantMessage(sectionSummaryText('completed section 1 cleanly')) }, + }) + return createLoopEventHandler( + loopsRepo, + plansRepo, + reviewFindingsRepo, + projectId, + forgeClient, + mockLogger, + () => mockConfig, + undefined, + tempDir, + undefined, + sectionPlansRepo, + undefined, + undefined, + undefined, + loopTransitionsRepo, + undefined, + undefined, + gitService, + ) + } + + test('a clean section advance commits a "section N: title" checkpoint in the worktree', async () => { + insertLoop({ phase: 'auditing', current_section_index: 0, total_sections: 2, worktree_dir: tempDir }) + insertSectionPlan(0, 'Section 1', 'Content 1', 'in_progress') + insertSectionPlan(1, 'Section 2', 'Content 2', 'pending') + + const { git, commits } = createFakeGitService() + const handler = buildHandler(git) + + await handler.onEvent({ + event: { + type: 'session.status', + properties: { status: { type: 'idle' }, sessionID: 'sess-1' }, + }, + }) + + expect(commits).toHaveLength(1) + expect(commits[0].cwd).toBe(tempDir) + expect(commits[0].message).toBe('section 1: Section 1') + + handler.clearAllRetryTimeouts() + }) + + test('no checkpoint commit when the worktree has no pending changes', async () => { + insertLoop({ phase: 'auditing', current_section_index: 0, total_sections: 2, worktree_dir: tempDir }) + insertSectionPlan(0, 'Section 1', 'Content 1', 'in_progress') + insertSectionPlan(1, 'Section 2', 'Content 2', 'pending') + + const { git, commits } = createFakeGitService({ statusStdout: '' }) + const handler = buildHandler(git) + + await handler.onEvent({ + event: { + type: 'session.status', + properties: { status: { type: 'idle' }, sessionID: 'sess-1' }, + }, + }) + + expect(commits).toHaveLength(0) + + // The section still advanced — the checkpoint is bookkeeping, not a gate. + const after = loopService.getActiveState('test-loop')! + expect(after.currentSectionIndex).toBe(1) + + handler.clearAllRetryTimeouts() + }) + }) + describe('Cross-cutting: persisted transition log', () => { /** * Drive the real runtime via createLoopEventHandler and assert that each @@ -662,6 +751,17 @@ describe('Loop Section Advancement', () => { insertLoop({ phase: 'auditing', current_section_index: 0, total_sections: 2, iteration: 1, max_iterations: 1 }) insertSectionPlan(0, 'Section 1', 'Content 1', 'in_progress') insertSectionPlan(1, 'Section 2', 'Content 2', 'pending') + // An outstanding bug finding makes the audit genuinely dirty (a summary-less + // audit with zero findings would take the summary re-prompt path instead). + reviewFindingsRepo.write({ + projectId, + file: 'src/broken.ts', + line: 5, + severity: 'bug', + description: 'Found an issue', + loopName: 'test-loop', + sectionIndex: 0, + }) // Dirty audit text (no section-summary) routes through the section-dirty // branch, which calls nextIterationOrTerminate; the cap terminates the diff --git a/test/hooks/loop-section-audit-retry.test.ts b/test/hooks/loop-section-audit-retry.test.ts index 127067f0e..1e338d4e1 100644 --- a/test/hooks/loop-section-audit-retry.test.ts +++ b/test/hooks/loop-section-audit-retry.test.ts @@ -122,6 +122,15 @@ describe('Loop Section Audit Retry', () => { ], }) loopService.startSection(state.loopName, 0) + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + file: 'src/broken.ts', + line: 5, + severity: 'bug', + description: 'Found an issue', + loopName: state.loopName, + sectionIndex: 0, + }) const { logger } = createCapturingLogger() @@ -172,6 +181,15 @@ describe('Loop Section Audit Retry', () => { ], }) loopService.startSection(state.loopName, 0) + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + file: 'src/broken.ts', + line: 5, + severity: 'bug', + description: 'Found an issue', + loopName: state.loopName, + sectionIndex: 0, + }) loopService.incrementSectionAttempts(state.loopName, 0) const planBefore = loopService.getSectionPlan(state, 0)! @@ -207,6 +225,106 @@ describe('Loop Section Audit Retry', () => { }) }) + describe('clean-but-no-summary re-prompt guard', () => { + test('audit with zero section bug findings and no summary block re-prompts the auditor instead of rotating', async () => { + // Unique loop/session: the re-prompt marks the module-global idle-gate + // awaiting-busy for this loop, which must not leak into other tests. + const state = makeState({ loopName: 'reprompt-loop-1', sessionId: 'reprompt-session-1', currentSectionIndex: 0, totalSections: 2, phase: 'auditing' }) + loopService.setState(state.loopName, state) + + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName: state.loopName, + sections: [ + { index: 0, title: 'Section A', content: 'Content A' }, + { index: 1, title: 'Section B', content: 'Content B' }, + ], + }) + loopService.startSection(state.loopName, 0) + + const { logger } = createCapturingLogger() + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [{ info: { role: 'assistant' }, parts: [{ type: 'text' as const, text: 'Everything looks fine.' }] }], + }, + }) + const getConfig = () => mockConfig as PluginConfig + + const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, forgeClient, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) + + await handler.onEvent({ + event: { + type: 'session.status', + properties: { sessionID: state.sessionId, status: { type: 'idle' } }, + }, + }) + + // Re-prompted the same audit session for the summary block; no rotation. + const repromptCall = (forgeClient.session.promptAsync as any).mock.calls.find( + (call: any) => call[0]?.parts?.some((p: any) => typeof p.text === 'string' && p.text.includes('section-summary block')) + ) + expect(repromptCall).toBeDefined() + expect(repromptCall[0].sessionID).toBe(state.sessionId) + + const plan = loopService.getSectionPlan(state, 0)! + expect(plan.attempts).toBe(0) + const after = loopService.getActiveState(state.loopName)! + expect(after.phase).toBe('auditing') + expect(after.currentSectionIndex).toBe(0) + expect(after.iteration).toBe(1) + }) + + test('second summary-less audit response falls back to dirty rotation (one re-prompt per round)', async () => { + const state = makeState({ loopName: 'reprompt-loop-2', sessionId: 'reprompt-session-2', currentSectionIndex: 0, totalSections: 2, phase: 'auditing' }) + loopService.setState(state.loopName, state) + + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName: state.loopName, + sections: [ + { index: 0, title: 'Section A', content: 'Content A' }, + { index: 1, title: 'Section B', content: 'Content B' }, + ], + }) + loopService.startSection(state.loopName, 0) + + const { logger } = createCapturingLogger() + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [{ info: { role: 'assistant' }, parts: [{ type: 'text' as const, text: 'Still no summary block.' }] }], + }, + }) + const getConfig = () => mockConfig as PluginConfig + + const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, forgeClient, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) + + const idleEvent = { + event: { + type: 'session.status', + properties: { sessionID: state.sessionId, status: { type: 'idle' } }, + }, + } + + // First idle → re-prompt. Busy clears the awaiting-busy gate, then the + // re-prompted response comes back idle without a summary again. + await handler.onEvent(idleEvent) + await handler.onEvent({ + event: { + type: 'session.status', + properties: { sessionID: state.sessionId, status: { type: 'busy' } }, + }, + }) + await handler.onEvent(idleEvent) + + // Fallback: dirty rotation consumed an iteration and incremented attempts. + const plan = loopService.getSectionPlan(state, 0)! + expect(plan.attempts).toBe(1) + const after = loopService.getActiveState(state.loopName)! + expect(after.phase).toBe('coding') + expect(after.currentSectionIndex).toBe(0) + }) + }) + describe('clean audit after dirty', () => { test('clean audit via idle event completes section 0, advances to section 1, and prior summary appears in next section prompt', async () => { const state = makeState({ currentSectionIndex: 0, totalSections: 2, phase: 'auditing' }) @@ -334,6 +452,15 @@ describe('Loop Section Audit Retry', () => { ], }) loopService.startSection(state.loopName, 0) + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + file: 'src/broken.ts', + line: 5, + severity: 'bug', + description: 'Found an issue', + loopName: state.loopName, + sectionIndex: 0, + }) const { logger } = createCapturingLogger() diff --git a/test/hooks/shell-env.test.ts b/test/hooks/shell-env.test.ts index 748cbb6db..ba26a3501 100644 --- a/test/hooks/shell-env.test.ts +++ b/test/hooks/shell-env.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, vi } from 'vitest' import { createShellEnvHook } from '../../src/hooks/shell-env' -import { SHIM_ENV_CONTAINER, SHIM_ENV_ENV_FILE, SHIM_ENV_HOST_SHELL } from '../../src/sandbox/shell-shim' +import { SHIM_ENV_CONTAINER, SHIM_ENV_HOST_SHELL } from '../../src/sandbox/shell-shim' import type { Logger } from '../../src/types' import type { SandboxContext } from '../../src/sandbox/context' @@ -17,13 +17,12 @@ function makeSandboxContext(overrides: Partial<SandboxContext> = {}): SandboxCon } describe('createShellEnvHook', () => { - test('injects container and env file when a sandbox context is resolved', async () => { + test('injects the container name when a sandbox context is resolved', async () => { const hook = createShellEnvHook({ resolveSandboxForSession: vi.fn(async () => makeSandboxContext({ containerName: 'forge-loop-a', hostDir: '/wt', - envFile: '/data/forge/sandbox-env/forge-loop-a.env', }), ), getUserConfiguredShell: () => undefined, @@ -34,24 +33,9 @@ describe('createShellEnvHook', () => { await hook({ cwd: '/wt', sessionID: 'ses_1' }, output) expect(output.env[SHIM_ENV_CONTAINER]).toBe('forge-loop-a') - expect(output.env[SHIM_ENV_ENV_FILE]).toBe('/data/forge/sandbox-env/forge-loop-a.env') expect(output.env[SHIM_ENV_HOST_SHELL]).toBeUndefined() }) - test('injects container without an env-file variable when the sandbox has none', async () => { - const hook = createShellEnvHook({ - resolveSandboxForSession: vi.fn(async () => makeSandboxContext({ containerName: 'forge-loop-a', hostDir: '/wt' })), - getUserConfiguredShell: () => undefined, - logger, - }) - const output = { env: {} as Record<string, string> } - - await hook({ cwd: '/wt', sessionID: 'ses_1' }, output) - - expect(output.env[SHIM_ENV_CONTAINER]).toBe('forge-loop-a') - expect(output.env[SHIM_ENV_ENV_FILE]).toBeUndefined() - }) - test('injects nothing container-related when no sandbox is resolved', async () => { const hook = createShellEnvHook({ resolveSandboxForSession: vi.fn(async () => null), diff --git a/test/install/plugin-link.test.ts b/test/install/plugin-link.test.ts new file mode 100644 index 000000000..c872b61b9 --- /dev/null +++ b/test/install/plugin-link.test.ts @@ -0,0 +1,413 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { basename, join } from 'path' +import { tmpdir } from 'os' +import { + buildShimSource, + disableConfigRegistration, + ensureTuiRegistration, + findConfigRegistrations, + isVendoredShim, + linkPlugin, + readPluginShimState, + removeTuiRegistration, + resolveServerEntry, + resolveTuiEntry, + unlinkPlugin, + unvendorPlugin, + vendorPlugin, + VENDORED_TUI_SPEC, + type ConfigRegistration, +} from '../../src/install/plugin-link' +import { resolvePluginShimDir, resolvePluginShimPath, resolveTuiConfigPath, resolveVendorDir } from '../../src/install/paths' + +let configHome: string +const inheritedXdgConfigHome = process.env.XDG_CONFIG_HOME + +beforeEach(() => { + configHome = mkdtempSync(join(tmpdir(), 'forge-link-')) + process.env.XDG_CONFIG_HOME = configHome +}) + +afterEach(() => { + if (inheritedXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = inheritedXdgConfigHome + } + rmSync(configHome, { recursive: true, force: true }) +}) + +function writeGlobalConfig(name: string, lines: string[]): string { + const configDir = join(configHome, 'opencode') + mkdirSync(configDir, { recursive: true }) + const file = join(configDir, name) + writeFileSync(file, lines.join('\n')) + return file +} + +describe('buildShimSource', () => { + test('produces a valid single-line re-export', () => { + expect(buildShimSource('/abs/path/dist/index.js')).toBe('export { default } from "/abs/path/dist/index.js"\n') + }) + + test('escapes backslashes and double quotes in the entry path', () => { + const source = buildShimSource('C:\\Users\\a"b\\dist\\index.js') + expect(source).toBe('export { default } from "C:\\\\Users\\\\a\\"b\\\\dist\\\\index.js"\n') + }) +}) + +describe('linkPlugin', () => { + test('creates the shim and the plugin directory, then reports unchanged and updated', () => { + const created = linkPlugin({ dryRun: false }) + expect(created.action).toBe('created') + expect(created.target).toBe(resolveServerEntry()) + expect(existsSync(resolvePluginShimDir())).toBe(true) + expect(existsSync(resolvePluginShimPath())).toBe(true) + expect(readFileSync(resolvePluginShimPath(), 'utf-8')).toBe(buildShimSource(resolveServerEntry()!)) + + expect(linkPlugin({ dryRun: false }).action).toBe('unchanged') + + writeFileSync(resolvePluginShimPath(), 'export { default } from "/somewhere/else"\n') + const updated = linkPlugin({ dryRun: false }) + expect(updated.action).toBe('updated') + expect(readFileSync(resolvePluginShimPath(), 'utf-8')).toBe(buildShimSource(resolveServerEntry()!)) + }) + + test('dry run reports the action without writing anything', () => { + const result = linkPlugin({ dryRun: true }) + expect(result.action).toBe('created') + expect(existsSync(resolvePluginShimDir())).toBe(false) + expect(existsSync(resolvePluginShimPath())).toBe(false) + }) + + test('vendored mode writes a relative shim that round-trips as vendored', () => { + const result = linkPlugin({ dryRun: false, mode: 'vendored' }) + expect(result.action).toBe('created') + expect(result.target).toBe('./opencode-forge/dist/index.js') + expect(readFileSync(resolvePluginShimPath(), 'utf-8')).toBe('export { default } from "./opencode-forge/dist/index.js"\n') + + const state = readPluginShimState() + expect(state.present).toBe(true) + expect(state.target).toBe('./opencode-forge/dist/index.js') + expect(isVendoredShim(state)).toBe(true) + + linkPlugin({ dryRun: false }) + expect(isVendoredShim(readPluginShimState())).toBe(false) + }) + + test('omitting mode keeps the absolute external shim', () => { + const result = linkPlugin({ dryRun: false }) + expect(result.action).toBe('created') + expect(result.target).toBe(resolveServerEntry()) + expect(readPluginShimState().target).toBe(resolveServerEntry()) + expect(isVendoredShim(readPluginShimState())).toBe(false) + }) +}) + +describe('readPluginShimState', () => { + test('round-trips the target written by linkPlugin', () => { + linkPlugin({ dryRun: false }) + const state = readPluginShimState() + expect(state.present).toBe(true) + expect(state.target).toBe(resolveServerEntry()) + }) + + test('returns present with no target for garbage content and absent when missing', () => { + const missing = readPluginShimState() + expect(missing.present).toBe(false) + expect(missing.target).toBeUndefined() + + mkdirSync(resolvePluginShimDir(), { recursive: true }) + writeFileSync(resolvePluginShimPath(), 'module.exports = 42') + const garbage = readPluginShimState() + expect(garbage.present).toBe(true) + expect(garbage.target).toBeUndefined() + }) +}) + +describe('unlinkPlugin', () => { + test('removes the shim and reports absent when already gone', () => { + linkPlugin({ dryRun: false }) + const removed = unlinkPlugin({ dryRun: false }) + expect(removed.action).toBe('removed') + expect(existsSync(resolvePluginShimPath())).toBe(false) + + const absent = unlinkPlugin({ dryRun: false }) + expect(absent.action).toBe('absent') + }) +}) + +describe('vendorPlugin', () => { + test('copies the real package assets into the vendored layout', () => { + const result = vendorPlugin({ dryRun: false }) + expect(result.action).toBe('vendored') + expect(result.vendorDir).toBe(resolveVendorDir()) + expect(result.copied).toEqual(['package.json', 'forge-config.jsonc', 'dist', 'container', 'skills']) + expect(result.missing).toEqual([]) + + const vendorDir = resolveVendorDir() + expect(existsSync(join(vendorDir, 'dist', 'index.js'))).toBe(true) + expect(existsSync(join(vendorDir, 'container'))).toBe(true) + expect(existsSync(join(vendorDir, 'skills'))).toBe(true) + expect(existsSync(join(vendorDir, 'forge-config.jsonc'))).toBe(true) + }) + + test('is idempotent and removes stale files from the destination', () => { + vendorPlugin({ dryRun: false }) + writeFileSync(join(resolveVendorDir(), 'dist', 'junk.js'), 'junk') + const result = vendorPlugin({ dryRun: false }) + expect(result.action).toBe('vendored') + expect(existsSync(join(resolveVendorDir(), 'dist', 'junk.js'))).toBe(false) + expect(existsSync(join(resolveVendorDir(), 'dist', 'index.js'))).toBe(true) + }) + + test('dry run reports the copy without writing anything', () => { + const result = vendorPlugin({ dryRun: true }) + expect(result.action).toBe('vendored') + expect(result.copied).toEqual(['package.json', 'forge-config.jsonc', 'dist', 'container', 'skills']) + expect(existsSync(resolveVendorDir())).toBe(false) + }) +}) + +describe('unvendorPlugin', () => { + test('removes the vendored directory and reports absent when already gone', () => { + vendorPlugin({ dryRun: false }) + expect(existsSync(resolveVendorDir())).toBe(true) + expect(unvendorPlugin({ dryRun: false })).toBe('removed') + expect(existsSync(resolveVendorDir())).toBe(false) + expect(unvendorPlugin({ dryRun: false })).toBe('absent') + }) +}) + +describe('findConfigRegistrations', () => { + function writeDistPackage(name: string): string { + const root = mkdtempSync(join(tmpdir(), 'forge-pkg-')) + mkdirSync(join(root, 'dist'), { recursive: true }) + writeFileSync(join(root, 'package.json'), JSON.stringify({ name })) + return join(root, 'dist') + } + + test('detects forge entries by npm name, version, path, and array form', () => { + const forgeDist = writeDistPackage('opencode-forge') + writeGlobalConfig('opencode.jsonc', [ + '{', + ' "plugin": [', + ' "opencode-forge",', + ' "opencode-forge@0.8.8",', + ` ${JSON.stringify(forgeDist)},`, + ' ["opencode-forge@1.0.0", { "x": 1 }],', + ' "opencode-eyesight@0.1.9"', + ' // "opencode-forge"', + ' ]', + '}', + '', + ]) + + const regs = findConfigRegistrations() + expect(regs.map((r) => r.spec)).toEqual([ + 'opencode-forge', + 'opencode-forge@0.8.8', + forgeDist, + 'opencode-forge@1.0.0', + ]) + expect(regs.map((r) => r.line)).toEqual([3, 4, 5, 6]) + rmSync(forgeDist, { recursive: true, force: true }) + }) + + test('ignores a dist path belonging to an unrelated package', () => { + const otherDist = writeDistPackage('some-other-plugin') + writeGlobalConfig('opencode.jsonc', [ + '{', + ' "plugin": [', + ` ${JSON.stringify(otherDist)},`, + ` ${JSON.stringify(join(otherDist, 'index.js'))}`, + ' ]', + '}', + '', + ]) + + expect(findConfigRegistrations()).toEqual([]) + rmSync(otherDist, { recursive: true, force: true }) + }) +}) + +describe('disableConfigRegistration', () => { + test('comments out a sole forge entry in jsonc in place', () => { + const file = writeGlobalConfig('opencode.jsonc', [ + '{', + ' // plugin declarations', + ' "plugin": [', + ' "opencode-eyesight@0.1.9",', + ' "opencode-forge",', + ' "opencode-eyesight@0.1.9"', + ' ]', + '}', + '', + ]) + const original = readFileSync(file, 'utf-8') + const reg: ConfigRegistration = { file, spec: 'opencode-forge', line: 5 } + + expect(disableConfigRegistration(reg, { dryRun: true })).toBe('commented') + expect(readFileSync(file, 'utf-8')).toBe(original) + + expect(disableConfigRegistration(reg, { dryRun: false })).toBe('commented') + expect(readFileSync(file, 'utf-8')).toBe( + [ + '{', + ' // plugin declarations', + ' "plugin": [', + ' "opencode-eyesight@0.1.9",', + ' // "opencode-forge",', + ' "opencode-eyesight@0.1.9"', + ' ]', + '}', + '', + ].join('\n'), + ) + }) + + test('removes the forge entry from a json file leaving valid JSON', () => { + const file = writeGlobalConfig('opencode.json', [ + '{', + ' "plugin": [', + ' "opencode-eyesight@0.1.9",', + ' "opencode-forge",', + ' "opencode-eyesight@0.1.9"', + ' ]', + '}', + '', + ]) + + const action = disableConfigRegistration({ file, spec: 'opencode-forge', line: 4 }, { dryRun: false }) + expect(action).toBe('removed') + + const next = readFileSync(file, 'utf-8') + expect(() => JSON.parse(next)).not.toThrow() + expect(next).not.toContain('opencode-forge') + }) +}) + +describe('ensureTuiRegistration', () => { + test('creates tui.json with the schema key and the spec when missing', () => { + const result = ensureTuiRegistration({ dryRun: false, spec: VENDORED_TUI_SPEC }) + expect(result.action).toBe('created') + expect(result.file).toBe(resolveTuiConfigPath()) + expect(result.spec).toBe(VENDORED_TUI_SPEC) + expect(existsSync(resolveTuiConfigPath())).toBe(true) + const text = readFileSync(resolveTuiConfigPath(), 'utf-8') + expect(text).toContain('"$schema": "https://opencode.ai/tui.json"') + expect(text).toContain(JSON.stringify(VENDORED_TUI_SPEC)) + expect(() => JSON.parse(text)).not.toThrow() + }) + + test('appends the spec to a commented trailing-comma file without disturbing comments', () => { + writeGlobalConfig('tui.json', [ + '{', + ' // TUI plugins are not auto-discovered; list them explicitly.', + ' "plugin": [', + ' "some-other-plugin",', + ' ],', + '}', + '', + ]) + const result = ensureTuiRegistration({ dryRun: false, spec: VENDORED_TUI_SPEC }) + expect(result.action).toBe('added') + const text = readFileSync(resolveTuiConfigPath(), 'utf-8') + expect(text).toContain('// TUI plugins are not auto-discovered; list them explicitly.') + expect(text).toContain('"some-other-plugin"') + expect(text).toContain(JSON.stringify(VENDORED_TUI_SPEC)) + }) + + test('returns present and leaves the file byte-identical when the spec already exists', () => { + ensureTuiRegistration({ dryRun: false, spec: VENDORED_TUI_SPEC }) + const before = readFileSync(resolveTuiConfigPath(), 'utf-8') + const result = ensureTuiRegistration({ dryRun: false, spec: VENDORED_TUI_SPEC }) + expect(result.action).toBe('present') + expect(readFileSync(resolveTuiConfigPath(), 'utf-8')).toBe(before) + }) + + test('replaces a stale forge entry while keeping unrelated entries and comments', () => { + writeGlobalConfig('tui.json', [ + '{', + ' // user comment', + ' "plugin": [', + ' "some-other-plugin",', + ' "opencode-forge@0.8.8",', + ' ],', + '}', + '', + ]) + const result = ensureTuiRegistration({ dryRun: false, spec: VENDORED_TUI_SPEC }) + expect(result.action).toBe('updated') + const text = readFileSync(resolveTuiConfigPath(), 'utf-8') + expect(text).toContain('// user comment') + expect(text).toContain('"some-other-plugin"') + expect(text).not.toContain('opencode-forge@0.8.8') + expect(text).toContain(JSON.stringify(VENDORED_TUI_SPEC)) + }) +}) + +describe('removeTuiRegistration', () => { + test('removes forge entries, reports absent on a second call, and keeps unrelated entries', () => { + const file = writeGlobalConfig('tui.json', [ + '{', + ' "plugin": [', + ' "unrelated",', + ' "opencode-forge",', + ' "opencode-forge@0.8.8",', + ' "other",', + ' ],', + '}', + '', + ]) + expect(removeTuiRegistration({ dryRun: false })).toBe('removed') + const text = readFileSync(file, 'utf-8') + expect(text).toContain('"unrelated"') + expect(text).toContain('"other"') + expect(text).not.toContain('opencode-forge') + expect(removeTuiRegistration({ dryRun: false })).toBe('absent') + }) +}) + +describe('relative forge path detection', () => { + test('resolves a relative forge path against the config file directory, not the process cwd', () => { + const configDir = join(configHome, 'opencode') + mkdirSync(join(configDir, 'vendor', 'opencode-forge', 'dist'), { recursive: true }) + writeFileSync(join(configDir, 'vendor', 'opencode-forge', 'package.json'), JSON.stringify({ name: 'opencode-forge' })) + writeFileSync(join(configDir, 'vendor', 'opencode-forge', 'dist', 'index.js'), '// built') + writeGlobalConfig('opencode.jsonc', [ + '{', + ' "plugin": [', + ' "./vendor/opencode-forge/dist/index.js",', + ' ],', + '}', + '', + ]) + expect(findConfigRegistrations().map((r) => r.spec)).toEqual(['./vendor/opencode-forge/dist/index.js']) + }) + + test('recognizes vendored entries by directory containment even without a package.json', () => { + const configDir = join(configHome, 'opencode') + mkdirSync(join(configDir, 'plugin', 'opencode-forge', 'dist'), { recursive: true }) + writeFileSync(join(configDir, 'plugin', 'opencode-forge', 'dist', 'tui.js'), '// built') + writeGlobalConfig('opencode.jsonc', [ + '{', + ' "plugin": [', + ' "./plugin/opencode-forge/dist/tui.js",', + ' ],', + '}', + '', + ]) + expect(findConfigRegistrations().map((r) => r.spec)).toEqual(['./plugin/opencode-forge/dist/tui.js']) + }) +}) + +describe('resolveTuiEntry', () => { + test('points at the built dist/tui.js when present', () => { + const entry = resolveTuiEntry() + expect(entry).toBeDefined() + expect(existsSync(entry!)).toBe(true) + expect(basename(entry!)).toBe('tui.js') + }) +}) diff --git a/test/loop/runtime-service-seam.test.ts b/test/loop/runtime-service-seam.test.ts index e5fe7ca3f..fcafe60a1 100644 --- a/test/loop/runtime-service-seam.test.ts +++ b/test/loop/runtime-service-seam.test.ts @@ -214,6 +214,20 @@ describe('LoopService seam — sectioned dirty audit max-iterations safety net', kind: 'plan', } const fakeService = makeStatefulFakeLoopService(initialState) + // An outstanding bug finding makes the audit genuinely dirty; a summary-less + // audit with zero findings would take the summary re-prompt path instead. + fakeService.getOutstandingFindings = vi.fn(() => [ + { + projectId: 'test-project', + file: 'src/broken.ts', + line: 1, + severity: 'bug', + description: 'remaining bug', + scenario: null, + loopName: initialState.loopName, + sectionIndex: 0, + } as any, + ]) const { client: fakeClient } = createFakeForgeClient({ session: { messages: async () => [ diff --git a/test/plugin.test.ts b/test/plugin.test.ts index ffd37058d..5348a6239 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -167,7 +167,7 @@ describe('createForgePlugin', () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, sandbox: { - mode: 'sbx', + mode: 'msb', }, } @@ -580,7 +580,7 @@ describe('createForgePlugin', () => { test('Plugin initializes successfully with sandbox.enabled=false', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } const plugin = createForgePlugin(config) @@ -601,6 +601,28 @@ describe('createForgePlugin', () => { expect(typeof hooks).toBe('object') }) + test('Plugin init fails closed when the sandbox is enabled but the shell shim cannot be installed', async () => { + const blocker = join(testDir, 'blocker') + writeFileSync(blocker, 'x') + const config: PluginConfig = { + dataDir: join(blocker, '.opencode', 'memory'), + sandbox: { mode: 'msb' }, + } + + const plugin = createForgePlugin(config) + + const mockInput = { + directory: testDir, + worktree: testDir, + client: {} as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + await expect(plugin(mockInput as unknown as PluginInput)).rejects.toThrow(/shell shim unavailable/) + }) + test('Logs legacy sandbox config warnings for a Docker config', async () => { const logFile = join(testDir, 'forge.log') const legacySandbox = { @@ -638,6 +660,85 @@ describe('createForgePlugin', () => { expect(logContents).toContain('sandbox.mounts[].container is ignored') }) + test('Logs exactly one msb-replacement warning for a legacy sbx-mode config and still initializes', async () => { + const logFile = join(testDir, 'forge.log') + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + logging: { enabled: true, file: logFile }, + sandbox: { mode: 'sbx', enabled: false } as PluginConfig['sandbox'], + } + + const plugin = createForgePlugin(config) + const mockInput = { + directory: testDir, + worktree: testDir, + client: {} as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput as unknown as PluginInput) + currentHooks = hooks as { getCleanup?: () => Promise<void> } + + const logContents = readFileSync(logFile, 'utf-8') + const warningLines = logContents.split('\n').filter((line) => line.includes('sandbox.mode')) + expect(warningLines).toHaveLength(1) + expect(warningLines[0]).toContain('msb') + expect(warningLines[0]).toContain('use mode') + expect(hooks).toBeDefined() + }) + + test('publishes legacy sandbox config warnings as a toast on plugin init', async () => { + let resolveToastPublish!: (entry: { url: string; body: string }) => void + const toastPublished = new Promise<{ url: string; body: string }>((resolve) => { + resolveToastPublish = resolve + }) + const mockFetch = async (input: RequestInfo | URL): Promise<Response> => { + let url: string + let body = '' + if (typeof input === 'string') { + url = input + } else if (input instanceof Request) { + url = input.url + body = await input.clone().text() + } else { + url = String(input) + } + const entry = { url, body } + if (url.includes('/tui/publish')) { + resolveToastPublish(entry) + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + sandbox: { mode: 'sbx', enabled: false } as PluginConfig['sandbox'], + } + + const plugin = createForgePlugin(config) + const mockInput = { + directory: testDir, + worktree: testDir, + client: { _client: { getConfig: () => ({ fetch: mockFetch }) } } as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput as unknown as PluginInput) + currentHooks = hooks as { getCleanup?: () => Promise<void> } + + const toastPublish = await toastPublished + const body = JSON.parse(toastPublish.body) as { properties: { title: string; variant: string; message: string } } + expect(body.properties.title).toBe('Forge sandbox config') + expect(body.properties.variant).toBe('warning') + expect(body.properties.message).toContain('sandbox.mode') + }) + test('Logs loop.permissions config warnings for a bad config on plugin init', async () => { const logFile = join(testDir, 'forge.log') const config: PluginConfig = { @@ -666,7 +767,7 @@ describe('createForgePlugin', () => { test('host session sandbox startup does not block init and routing waits fail-closed', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } const setupDb = initializeDatabase(config.dataDir!) @@ -756,7 +857,7 @@ describe('createForgePlugin', () => { test('a transient ancestry lookup failure does not block native tools in tool.execute.before', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx' }, + sandbox: { mode: 'msb' }, } const plugin = createForgePlugin(config) @@ -799,7 +900,7 @@ describe('createForgePlugin', () => { test('shell.env retains host behavior for sessions with no sandbox via the unified resolver', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx' }, + sandbox: { mode: 'msb' }, } const plugin = createForgePlugin(config) @@ -832,11 +933,11 @@ describe('createForgePlugin', () => { test('a failed host-sandbox start makes the selected session fail closed while others stay host', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx' }, + sandbox: { mode: 'msb' }, } // Persist a desired ON for a selected session. Sandbox routing stays enabled so this exercises - // a genuine container-start failure rather than the unavailable-runtime path; `sbx` is forced + // a genuine container-start failure rather than the unavailable-runtime path; `msb` is forced // off PATH below so the start fails whether or not the CLI is installed on the host. const setupDb = initializeDatabase(config.dataDir!) createSessionSandboxPreferencesRepo(setupDb).setDesired(TEST_PROJECT_ID, { @@ -892,7 +993,7 @@ describe('createForgePlugin', () => { test('two plugin instances for one project share a single refcounted sandbox controller', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } const setupDb = initializeDatabase(config.dataDir!) @@ -952,7 +1053,7 @@ describe('createForgePlugin', () => { test('a forge worktree instance initializing first still reconciles the root session via project.worktree', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } const projectRoot = join(testDir, 'root') const worktreeDir = join(testDir, 'worktree') @@ -1007,7 +1108,7 @@ describe('createForgePlugin', () => { test('a later forge worktree instance cannot leave a root-session toggle pending', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } const projectRoot = join(testDir, 'root') const worktreeDir = join(testDir, 'worktree') @@ -1061,7 +1162,7 @@ describe('createForgePlugin', () => { test('after the creating instance is disposed, a survivor processes a new desired revision without closed-db callback failure', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } const setupDb = initializeDatabase(config.dataDir!) @@ -1130,7 +1231,7 @@ describe('createForgePlugin', () => { test('unavailable sandbox runtime acknowledges a requested ON as OFF-with-error and blocks the selected session', async () => { const config: PluginConfig = { dataDir: `${testDir}/.opencode/memory`, - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } // Persist a desired ON for a selected session. Sandbox routing is unavailable (disabled), so @@ -1188,9 +1289,9 @@ describe('createForgePlugin', () => { dataDir: `${testDir}/.opencode/memory`, // Sandbox routing is disabled so the deterministic unavailable manager is used (ensureRunning // fails closed, stop is a no-op). This makes the OFF-with-error acknowledgement independent of - // whether the `sbx` CLI is installed on the host, exercising the same fail-closed surface as a + // whether the `msb` CLI is installed on the host, exercising the same fail-closed surface as a // manager that fails to initialize. - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } const setupDb = initializeDatabase(config.dataDir!) @@ -1267,20 +1368,20 @@ describe('PluginConfig', () => { test('Accepts sandbox config', () => { const config: PluginConfig = { sandbox: { - mode: 'sbx', + mode: 'msb', image: 'custom-image:latest', }, } - expect(config.sandbox?.mode).toBe('sbx') + expect(config.sandbox?.mode).toBe('msb') }) test('Accepts sandbox.enabled flag for opting out of Docker', () => { const enabledConfig: PluginConfig = { - sandbox: { mode: 'sbx', enabled: true }, + sandbox: { mode: 'msb', enabled: true }, } const disabledConfig: PluginConfig = { - sandbox: { mode: 'sbx', enabled: false }, + sandbox: { mode: 'msb', enabled: false }, } expect(enabledConfig.sandbox?.enabled).toBe(true) diff --git a/test/prompts/loader.test.ts b/test/prompts/loader.test.ts index 96b591508..aa7fadee5 100644 --- a/test/prompts/loader.test.ts +++ b/test/prompts/loader.test.ts @@ -45,10 +45,11 @@ describe('loadPrompt', () => { rmSync(tmpDir, { recursive: true, force: true }) }) - test('auditor-loop-addendum contains the literal section-summary markers', () => { + test('auditor-loop-addendum references the summary block without hand-writing the markers (single template owner is prompts.ts)', () => { const prompt = loadPrompt(['agents', 'auditor-loop-addendum.md']) - expect(prompt).toContain(SECTION_SUMMARY_START_MARKER) - expect(prompt).toContain(SECTION_SUMMARY_END_MARKER) + expect(prompt).toContain('section-summary block') + expect(prompt).not.toContain(SECTION_SUMMARY_START_MARKER) + expect(prompt).not.toContain(SECTION_SUMMARY_END_MARKER) }) test('auditor-loop-addendum requires remediation guidance for blocking findings', () => { diff --git a/test/review.test.ts b/test/review.test.ts index 9335900b9..62faaca39 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -140,6 +140,74 @@ describe('review-write', () => { expect(findings[0].loopName).toBe('test-loop') }) + test('sectioned loop auto-injects currentSectionIndex during a section audit but not during the final audit', async () => { + const loopsRepo = createLoopsRepo(db) + const baseRow = { + projectId: 'test-project', + status: 'running', + worktree: false, + worktreeDir: TEST_DIR, + worktreeBranch: 'feature-branch', + projectDir: TEST_DIR, + maxIterations: 10, + iteration: 1, + auditCount: 0, + errorCount: 0, + executionModel: 'test-model', + auditorModel: 'test-auditor', + modelFailed: false, + sandbox: false, + sandboxContainer: null, + completedAt: null, + terminationReason: null, + completionSummary: null, + workspaceId: null, + hostSessionId: null, + startedAt: Date.now(), + totalSections: 3, + finalAuditDone: 0, + executionVariant: null, + auditorVariant: null, + kind: 'plan', + } + loopsRepo.insert({ + ...baseRow, + loopName: 'section-loop', + currentSessionId: 'section-session', + phase: 'auditing', + currentSectionIndex: 1, + }, { lastAuditResult: null }) + loopsRepo.insert({ + ...baseRow, + loopName: 'final-loop', + currentSessionId: 'final-session', + phase: 'final_auditing', + currentSectionIndex: 2, + }, { lastAuditResult: null }) + + await tools['review-write'].execute( + { file: 'src/section.ts', line: 1, severity: 'bug', description: 'Section finding' }, + { sessionID: 'section-session', directory: TEST_DIR } as any + ) + await tools['review-write'].execute( + { file: 'src/final-default.ts', line: 2, severity: 'bug', description: 'Final audit finding without explicit section' }, + { sessionID: 'final-session', directory: TEST_DIR } as any + ) + await tools['review-write'].execute( + { file: 'src/final-explicit.ts', line: 3, severity: 'bug', description: 'Final audit finding with explicit section', sectionIndex: 0 }, + { sessionID: 'final-session', directory: TEST_DIR } as any + ) + + const findings = reviewFindingsRepo.listAll('test-project') + const byFile = new Map(findings.map((f) => [f.file, f])) + // Section audit: auto-injected current section. + expect(byFile.get('src/section.ts')!.sectionIndex).toBe(1) + // Final audit: no auto-injection — the last section must not silently own it. + expect(byFile.get('src/final-default.ts')!.sectionIndex).toBeNull() + // Final audit: explicit attribution still honored. + expect(byFile.get('src/final-explicit.ts')!.sectionIndex).toBe(0) + }) + test('outside a loop session writes empty loop_name', async () => { const result = await tools['review-write'].execute( { diff --git a/test/sandbox-manager.test.ts b/test/sandbox-manager.test.ts index aeda49226..01c743e3c 100644 --- a/test/sandbox-manager.test.ts +++ b/test/sandbox-manager.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest' -import { mkdtempSync, rmSync } from 'fs' +import { mkdtempSync, realpathSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join, resolve } from 'path' import { execSync } from 'child_process' @@ -133,7 +133,7 @@ describe('SandboxManager', () => { }) describe('start', () => { - test('throws when sbx daemon is not available', async () => { + test('throws when the msb host is not available', async () => { const mockRuntime = createMockSandboxRuntime() mockRuntime.setAvailable(false) const logger = createMockLogger() @@ -143,7 +143,7 @@ describe('SandboxManager', () => { logger ) - await expect(manager.start('test', '/path')).rejects.toThrow('daemon is not running') + await expect(manager.start('test', '/path')).rejects.toThrow('This host cannot run microVMs') }) test('throws actionable error when image does not exist, without building', async () => { @@ -251,7 +251,7 @@ describe('SandboxManager', () => { // The hooks directory is the one read-only carve-out: the sandbox must not be able to // plant a hook that the user's host git would execute. const hooksDir = join(absoluteCommonDir, 'hooks') - expect(workspaces).toContainEqual({ hostDir: hooksDir, readOnly: true }) + expect(workspaces).toContainEqual({ hostDir: hooksDir, containerDir: hooksDir, readOnly: true }) expect(workspaces.filter(w => w.readOnly === true)).toHaveLength(1) } finally { rmSync(tempDir, { recursive: true, force: true }) @@ -320,12 +320,16 @@ describe('SandboxManager', () => { // The loop's own worktree is the primary writable workspace and is never dropped, even // though it lives inside the git common dir and the source project. - expect(workspaces[0]).toEqual({ hostDir: worktreeDir }) + expect(workspaces[0]).toEqual({ hostDir: realpathSync(worktreeDir), containerDir: worktreeDir }) // The git common dir (an ancestor of the worktree) is read-write and mounted alongside it, // so in-sandbox git reads/writes keep working for concurrent loops in the same project. expect(workspaces.some(w => w.hostDir === commonDir && w.readOnly !== true)).toBe(true) // ...but its hooks directory is carved out read-only so the sandbox cannot plant a hook. - expect(workspaces).toContainEqual({ hostDir: join(commonDir, 'hooks'), readOnly: true }) + expect(workspaces).toContainEqual({ + hostDir: join(commonDir, 'hooks'), + containerDir: join(commonDir, 'hooks'), + readOnly: true, + }) // The read-only project mount, an ancestor of the writable worktree, is still dropped. expect(workspaces.some(w => w.hostDir === projectDir)).toBe(false) expect(logger.log).toHaveBeenCalledWith(expect.stringMatching(/dropping workspace/)) @@ -379,10 +383,65 @@ describe('SandboxManager', () => { logger ) + mockRuntime.setSandboxState('forge-unknown', 'running') await manager.stop('unknown') expect(mockRuntime.getRemoveSandboxCalls()).toContain('forge-unknown') }) + + test('refuses to remove on an unknown state query (fail-closed)', async () => { + const mockRuntime = createMockSandboxRuntime() + const logger = createMockLogger() + const manager = createSandboxManager( + mockRuntime, + { image: 'oc-forge-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + mockRuntime.setSandboxState('forge-test', 'unknown') + await expect(manager.stop('test')).rejects.toThrow(/state query failed/) + + // `unknown` says nothing about the sandbox: no removal is issued and the active-map entry + // is preserved so callers can observe the indeterminate state. + expect(mockRuntime.getRemoveSandboxCalls()).not.toContain('forge-test') + expect(manager.isActive('test')).toBe(true) + }) + + test('removes a transient sandbox instead of refusing', async () => { + const mockRuntime = createMockSandboxRuntime() + const logger = createMockLogger() + const manager = createSandboxManager( + mockRuntime, + { image: 'oc-forge-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + mockRuntime.setSandboxState('forge-test', 'transient') + await manager.stop('test') + + // A transient state is a confirmed existence, so removal proceeds; only `unknown` refuses. + expect(mockRuntime.getRemoveSandboxCalls()).toContain('forge-test') + expect(manager.isActive('test')).toBe(false) + }) + + test('clears stale local state without removal when the sandbox is confirmed missing', async () => { + const mockRuntime = createMockSandboxRuntime() + const logger = createMockLogger() + const manager = createSandboxManager( + mockRuntime, + { image: 'oc-forge-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + mockRuntime.setSandboxState('forge-test', 'missing') + await manager.stop('test') + + expect(mockRuntime.getRemoveSandboxCalls()).not.toContain('forge-test') + expect(manager.isActive('test')).toBe(false) + }) }) describe('getActive and isActive', () => { @@ -534,7 +593,7 @@ describe('SandboxManager', () => { await manager.start('test', '/path') mockRuntime.setSandboxState('forge-test', 'stopped') - // sbx suspends idle microVMs; exec resumes them, so stopped is not dead + // msb suspends idle microVMs; exec resumes them, so stopped is not dead expect(await manager.isLive('test')).toBe(true) expect(manager.isActive('test')).toBe(true) }) @@ -572,13 +631,13 @@ describe('SandboxManager', () => { const result = await manager.start('test', '/path') expect(result.containerName).toBe('forge-test') - // Creating over an existing sandbox is what sbx answers with 409 Conflict + // Creating over an existing sandbox is what msb answers with 409 Conflict expect(mockRuntime.getCreateSandboxCalls()).toHaveLength(0) expect(mockRuntime.getRemoveSandboxCalls()).toHaveLength(0) expect(manager.isActive('test')).toBe(true) }) - test('does not create when the state query fails (unknown)', async () => { + test('adopts a transient sandbox instead of failing or duplicating it', async () => { const mockRuntime = createMockSandboxRuntime() const logger = createMockLogger() const manager = createSandboxManager( @@ -587,12 +646,37 @@ describe('SandboxManager', () => { logger ) - mockRuntime.setSandboxState('forge-test', 'unknown') + mockRuntime.setSandboxState('forge-test', 'transient') - await manager.start('test', '/path') + const result = await manager.start('test', '/path') + // A transient state (draining/paused/starting/created) proves the sandbox exists, so the + // start is adopted rather than refused as a query failure or duplicated into a 409. + expect(result.containerName).toBe('forge-test') expect(mockRuntime.getCreateSandboxCalls()).toHaveLength(0) expect(mockRuntime.getRemoveSandboxCalls()).toHaveLength(0) + expect(manager.isActive('test')).toBe(true) + expect(logger.log).not.toHaveBeenCalledWith(expect.stringMatching(/state query failed/)) + }) + + test('fails closed when the state query fails (unknown)', async () => { + const mockRuntime = createMockSandboxRuntime() + const logger = createMockLogger() + const manager = createSandboxManager( + mockRuntime, + { image: 'oc-forge-sandbox:latest' }, + logger + ) + + mockRuntime.setSandboxState('forge-test', 'unknown') + + // `unknown` says nothing about the sandbox: it must neither be created over nor + // adopted as usable, so start refuses and no secret refresh is issued. + await expect(manager.start('test', '/path')).rejects.toThrow(/refusing to start/) + expect(mockRuntime.getCreateSandboxCalls()).toHaveLength(0) + expect(mockRuntime.getRemoveSandboxCalls()).toHaveLength(0) + expect(mockRuntime.getRefreshSecretCalls()).toHaveLength(0) + expect(manager.isActive('test')).toBe(false) }) test('creates only when the sandbox is confirmed missing', async () => { @@ -623,7 +707,7 @@ describe('SandboxManager', () => { logger ) - // sbx accepts nested workspaces, so a writable ancestor mounts alongside the primary + // msb accepts nested workspaces, so a writable ancestor mounts alongside the primary // workspace instead of being dropped — this is what keeps git metadata available. expect(result).toHaveLength(3) expect(result.map((w) => w.hostDir)).toEqual(['/a/b', '/a', '/c']) @@ -673,7 +757,7 @@ describe('SandboxManager', () => { // Restricting a subtree read-only is safe: it only narrows writable access. expect(result).toHaveLength(2) - expect(result[1]).toEqual({ hostDir: '/a/sub', readOnly: true }) + expect(result[1]).toEqual({ hostDir: '/a/sub', containerDir: '/a/sub', readOnly: true }) expect(logger.log).not.toHaveBeenCalledWith(expect.stringMatching(/dropping workspace/)) }) @@ -689,7 +773,7 @@ describe('SandboxManager', () => { // The read-only ancestor shadows the descendant, so mounting it read-write is useless. expect(result).toHaveLength(1) - expect(result[0]).toEqual({ hostDir: '/a', readOnly: true }) + expect(result[0]).toEqual({ hostDir: '/a', containerDir: '/a', readOnly: true }) expect(logger.log).toHaveBeenCalledWith(expect.stringMatching(/dropping workspace/)) }) }) diff --git a/test/sandbox/config-warnings.test.ts b/test/sandbox/config-warnings.test.ts index c4dc0c769..576ac3557 100644 --- a/test/sandbox/config-warnings.test.ts +++ b/test/sandbox/config-warnings.test.ts @@ -21,8 +21,15 @@ describe('collectLegacySandboxConfigWarnings', () => { expect(joined).toContain('sandbox.mounts') }) - test('returns [] for a clean sbx config', () => { - expect(collectLegacySandboxConfigWarnings({ enabled: true, image: 'oc-forge-sandbox:latest' })).toEqual([]) + test('reports exactly one msb-replacement warning for the retired sbx mode', () => { + const warnings = collectLegacySandboxConfigWarnings({ mode: 'sbx' }) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('msb') + expect(warnings[0]).toContain('use mode') + }) + + test('returns [] for a clean msb config', () => { + expect(collectLegacySandboxConfigWarnings({ enabled: true, mode: 'msb', image: 'oc-forge-sandbox:latest' })).toEqual([]) }) test('returns [] for undefined, null and non-object input', () => { diff --git a/test/sandbox/detect-git-mount.test.ts b/test/sandbox/detect-git-mount.test.ts index 2e750fd09..c181c1ddf 100644 --- a/test/sandbox/detect-git-mount.test.ts +++ b/test/sandbox/detect-git-mount.test.ts @@ -40,6 +40,6 @@ describe('detectGitMount', () => { const calls = mockRuntime.getCreateSandboxCalls() expect(calls.length).toBe(1) // No git mount should be present — only the identical-path worktree workspace remains - expect(calls[0][1]).toEqual([{ hostDir: '/some/project', readOnly: undefined }]) + expect(calls[0][1]).toEqual([{ hostDir: '/some/project', containerDir: '/some/project', readOnly: undefined }]) }) }) diff --git a/test/sandbox/exec-fs.test.ts b/test/sandbox/exec-fs.test.ts index eda54cc2a..062a38626 100644 --- a/test/sandbox/exec-fs.test.ts +++ b/test/sandbox/exec-fs.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, vi } from 'vitest' import { executeSandboxGlob, executeSandboxGrep } from '../../src/sandbox/exec-fs' -import type { SandboxRuntime } from '../../src/sandbox/sbx' +import type { SandboxRuntime } from '../../src/sandbox/msb' function recordingRuntime() { const commands: string[] = [] diff --git a/test/sandbox/manager-caching.test.ts b/test/sandbox/manager-caching.test.ts index 6e43ed6e3..317842e0d 100644 --- a/test/sandbox/manager-caching.test.ts +++ b/test/sandbox/manager-caching.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createSandboxManager, type SandboxManagerConfig } from '../../src/sandbox/manager' -import type { SbxAvailability } from '../../src/sandbox/sbx' +import type { MsbAvailability } from '../../src/sandbox/msb' import { createMockSandboxRuntime, createMockLogger } from '../helpers/sandbox-mocks' -const available: SbxAvailability = { available: true } -const daemonDown: SbxAvailability = { available: false, reason: 'daemon-down', detail: 'mock daemon down' } -const indeterminate: SbxAvailability = { available: false, reason: 'unknown', detail: 'probe timed out' } +const available: MsbAvailability = { available: true } +const hostUnsupported: MsbAvailability = { available: false, reason: 'host-unsupported', detail: 'mock daemon down' } +const indeterminate: MsbAvailability = { available: false, reason: 'unknown', detail: 'probe timed out' } describe('SandboxManager caching', () => { let mockRuntime: ReturnType<typeof createMockSandboxRuntime> @@ -14,7 +14,7 @@ describe('SandboxManager caching', () => { beforeEach(() => { vi.useFakeTimers() mockRuntime = createMockSandboxRuntime() - mockRuntime.checkAvailable = vi.fn(async (): Promise<SbxAvailability> => available) + mockRuntime.checkAvailable = vi.fn(async (): Promise<MsbAvailability> => available) mockRuntime.templateExists = vi.fn(async () => true) mockRuntime.getSandboxState = vi.fn(async () => 'missing' as const) mockLogger = createMockLogger() @@ -50,19 +50,19 @@ describe('SandboxManager caching', () => { }) it('should reject both calls when runtime is unavailable and cache negative result within TTL', async () => { - mockRuntime.checkAvailable = vi.fn(async (): Promise<SbxAvailability> => daemonDown) + mockRuntime.checkAvailable = vi.fn(async (): Promise<MsbAvailability> => hostUnsupported) const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest' } const manager = createSandboxManager(mockRuntime, config, mockLogger) - await expect(manager.start('test-wt', '/tmp/project')).rejects.toThrow('daemon is not running') + await expect(manager.start('test-wt', '/tmp/project')).rejects.toThrow('This host cannot run microVMs') expect(mockRuntime.checkAvailable).toHaveBeenCalledTimes(1) - await expect(manager.start('test-wt', '/tmp/project')).rejects.toThrow('daemon is not running') + await expect(manager.start('test-wt', '/tmp/project')).rejects.toThrow('This host cannot run microVMs') expect(mockRuntime.checkAvailable).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(30_000) - await expect(manager.start('test-wt', '/tmp/project')).rejects.toThrow('daemon is not running') + await expect(manager.start('test-wt', '/tmp/project')).rejects.toThrow('This host cannot run microVMs') expect(mockRuntime.checkAvailable).toHaveBeenCalledTimes(2) }) @@ -85,7 +85,7 @@ describe('SandboxManager caching', () => { it('should start the sandbox when availability is indeterminate instead of failing the launch', async () => { // A busy daemon cannot answer the probe in time; that must not block a concurrent loop launch. - mockRuntime.checkAvailable = vi.fn(async (): Promise<SbxAvailability> => indeterminate) + mockRuntime.checkAvailable = vi.fn(async (): Promise<MsbAvailability> => indeterminate) const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest' } const manager = createSandboxManager(mockRuntime, config, mockLogger) @@ -95,7 +95,7 @@ describe('SandboxManager caching', () => { }) it('should not cache an indeterminate probe, so the next launch re-probes immediately', async () => { - mockRuntime.checkAvailable = vi.fn(async (): Promise<SbxAvailability> => indeterminate) + mockRuntime.checkAvailable = vi.fn(async (): Promise<MsbAvailability> => indeterminate) const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest' } const manager = createSandboxManager(mockRuntime, config, mockLogger) @@ -106,16 +106,18 @@ describe('SandboxManager caching', () => { expect(mockRuntime.checkAvailable).toHaveBeenCalledTimes(2) }) - it('should report an unreachable daemon rather than a missing template', async () => { - // `sbx template ls` failing looks identical to an absent template, so the daemon error wins. - mockRuntime.checkAvailable = vi.fn(async (): Promise<SbxAvailability> => available) + it('should report an unavailable host rather than a missing template', async () => { + // `msb images` failing looks identical to an absent template, so the availability error wins. + mockRuntime.checkAvailable = vi.fn() + .mockResolvedValueOnce(available) + .mockResolvedValueOnce(hostUnsupported) mockRuntime.templateExists = vi.fn(async () => false) const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest' } const manager = createSandboxManager(mockRuntime, config, mockLogger) - mockRuntime.checkAvailable = vi.fn(async (): Promise<SbxAvailability> => daemonDown) - await expect(manager.start('test-wt', '/tmp/project')).rejects.toThrow('daemon is not running') + await expect(manager.start('test-wt', '/tmp/project')).rejects.toThrow('This host cannot run microVMs') + expect(mockRuntime.templateExists).toHaveBeenCalledTimes(1) }) it('should defer an indeterminate template query to sandbox creation', async () => { diff --git a/test/sandbox/manager-custom-mounts.test.ts b/test/sandbox/manager-custom-mounts.test.ts index 5c51fec63..a90f35e39 100644 --- a/test/sandbox/manager-custom-mounts.test.ts +++ b/test/sandbox/manager-custom-mounts.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, afterEach } from 'vitest' -import { mkdtempSync, mkdirSync, rmSync } from 'fs' +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'fs' import { join, resolve } from 'path' import { tmpdir } from 'os' import { createSandboxManager, type SandboxManagerConfig } from '../../src/sandbox/manager' @@ -43,9 +43,9 @@ describe('SandboxManager custom mounts', () => { const workspaces = calls[0][1] // RW mount: readOnly false - expect(workspaces).toContainEqual({ hostDir: resolve(tmpRW), readOnly: false }) + expect(workspaces).toContainEqual({ hostDir: realpathSync(resolve(tmpRW)), containerDir: resolve(tmpRW), readOnly: false }) // RO mount: readOnly true - expect(workspaces).toContainEqual({ hostDir: resolve(tmpRO), readOnly: true }) + expect(workspaces).toContainEqual({ hostDir: realpathSync(resolve(tmpRO)), containerDir: resolve(tmpRO), readOnly: true }) }) test('custom mounts appear in active.mounts', async () => { @@ -93,7 +93,7 @@ describe('SandboxManager custom mounts', () => { // Custom mount should not be in the workspaces passed to createSandbox const workspaces = runtime.getCreateSandboxCalls()[0][1] - expect(workspaces).toEqual([{ hostDir: resolve(workspace) }]) + expect(workspaces).toEqual([{ hostDir: realpathSync(resolve(workspace)), containerDir: resolve(workspace) }]) }) test('custom mount whose host equals the project source dir is skipped', async () => { @@ -117,8 +117,8 @@ describe('SandboxManager custom mounts', () => { // Custom mount at the identical host is skipped (collision with project mount) const workspaces = runtime.getCreateSandboxCalls()[0][1] expect(workspaces).toEqual([ - { hostDir: '/home/user/worktrees/feature' }, - { hostDir: resolve(projectDir), readOnly: true }, + { hostDir: '/home/user/worktrees/feature', containerDir: '/home/user/worktrees/feature' }, + { hostDir: realpathSync(resolve(projectDir)), containerDir: resolve(projectDir), readOnly: true }, ]) }) @@ -145,8 +145,8 @@ describe('SandboxManager custom mounts', () => { // Only the first custom mount appears in workspaces const workspaces = runtime.getCreateSandboxCalls()[0][1] expect(workspaces).toEqual([ - { hostDir: '/home/user/worktrees/feature' }, - { hostDir: resolve(shared), readOnly: false }, + { hostDir: '/home/user/worktrees/feature', containerDir: '/home/user/worktrees/feature' }, + { hostDir: realpathSync(resolve(shared)), containerDir: resolve(shared), readOnly: false }, ]) }) @@ -171,7 +171,7 @@ describe('SandboxManager custom mounts', () => { expect(active?.mounts[2]).toEqual({ hostDir: resolve(tmpCustom), containerDir: resolve(tmpCustom), readOnly: false }) const workspaces = runtime.getCreateSandboxCalls()[0][1] - expect(workspaces).toContainEqual({ hostDir: '/tmp', readOnly: true }) - expect(workspaces).toContainEqual({ hostDir: resolve(tmpCustom), readOnly: false }) + expect(workspaces).toContainEqual({ hostDir: realpathSync('/tmp'), containerDir: '/tmp', readOnly: true }) + expect(workspaces).toContainEqual({ hostDir: realpathSync(resolve(tmpCustom)), containerDir: resolve(tmpCustom), readOnly: false }) }) }) diff --git a/test/sandbox/manager-env-passthrough.test.ts b/test/sandbox/manager-env-passthrough.test.ts index cef9709ca..fffcf1156 100644 --- a/test/sandbox/manager-env-passthrough.test.ts +++ b/test/sandbox/manager-env-passthrough.test.ts @@ -1,31 +1,17 @@ -import { describe, test, expect, afterEach } from 'vitest' -import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, readdirSync, mkdirSync, writeFileSync } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' +import { describe, test, expect, afterEach, vi } from 'vitest' import { createSandboxManager, type SandboxManagerConfig } from '../../src/sandbox/manager' import { createMockLogger, createMockSandboxRuntime } from '../helpers/sandbox-mocks' -describe('SandboxManager env passthrough file lifecycle', () => { - const tmpDirs: string[] = [] +describe('SandboxManager create-time env and secrets', () => { const savedEnv: Record<string, string | undefined> = {} afterEach(() => { - for (const d of tmpDirs) { - rmSync(d, { recursive: true, force: true }) - } - tmpDirs.length = 0 for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) delete process.env[k]; else process.env[k] = v } Object.keys(savedEnv).forEach((k) => delete savedEnv[k]) }) - function createTempDataDir(): string { - const dir = mkdtempSync(join(tmpdir(), 'forge-env-passthrough-')) - tmpDirs.push(dir) - return dir - } - function setEnv(name: string, value: string | undefined) { if (!(name in savedEnv)) { savedEnv[name] = process.env[name] @@ -33,126 +19,379 @@ describe('SandboxManager env passthrough file lifecycle', () => { if (value === undefined) delete process.env[name]; else process.env[name] = value } - test('writes a 0600 env file after start, exposes it on the active entry, and deletes it on stop', async () => { - setEnv('FORGE_TEST_TOKEN', 'abc123') - setEnv('FORGE_TEST_EMPTY', undefined) - const dataDir = createTempDataDir() + test('start forwards only env names that are set on the host and logs the omissions', async () => { + setEnv('FORGE_TEST_DEFINED', 'abc123') + setEnv('FORGE_TEST_UNDEFINED', undefined) const runtime = createMockSandboxRuntime() const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - dataDir, - network: { env: ['FORGE_TEST_TOKEN', 'FORGE_TEST_EMPTY', 'FORGE_TEST_UNSET'] }, + network: { env: ['FORGE_TEST_DEFINED', 'FORGE_TEST_UNDEFINED'] }, } const manager = createSandboxManager(runtime, config, logger) await manager.start('test', '/home/user/worktrees/feature') - const envFile = manager.getActive('test')?.envFile - expect(envFile).toBeDefined() - const expectedPath = join(dataDir, 'sandbox-env', 'forge-test.env') - expect(envFile).toBe(expectedPath) + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.env).toEqual(['FORGE_TEST_DEFINED']) + expect(createCall[2]?.env).not.toContain('FORGE_TEST_UNDEFINED') + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('FORGE_TEST_UNDEFINED')) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('not set')) + }) - expect(existsSync(expectedPath)).toBe(true) - expect(readFileSync(expectedPath, 'utf-8')).toBe('FORGE_TEST_TOKEN=abc123\n') - // Only the set variable is listed; unset/absent names are omitted. - expect(readFileSync(expectedPath, 'utf-8')).not.toMatch(/FORGE_TEST_EMPTY/) - expect(statSync(expectedPath).mode & 0o777).toBe(0o600) + test('start forwards configured secrets only when their host variable is set', async () => { + setEnv('FORGE_TEST_SECRET', 's3cr3t-value') + setEnv('FORGE_TEST_UNSET', undefined) - await manager.stop('test') + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { + secrets: [ + { env: 'FORGE_TEST_SECRET', hosts: ['api.github.com'] }, + { env: 'FORGE_TEST_UNSET', hosts: ['api.github.com'] }, + { env: '', hosts: ['api.github.com'] }, + { env: 'FORGE_TEST_NO_HOSTS', hosts: [] }, + ], + }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.secrets).toEqual([{ env: 'FORGE_TEST_SECRET', hosts: ['api.github.com'] }]) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('FORGE_TEST_UNSET')) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('not set')) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('no allowed hosts')) + }) + + test('omits secrets with whitespace-only names or hosts and logs the accurate reason', async () => { + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { + secrets: [ + { env: ' ', hosts: ['api.github.com'] }, + { env: 'FORGE_TEST_WS_HOSTS', hosts: [' '] }, + { env: 'FORGE_TEST_WS_HOSTS_2', hosts: ['api.example.com', ' '] }, + ], + }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.secrets).toEqual([]) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('missing env name')) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('FORGE_TEST_WS_HOSTS')) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('no allowed hosts')) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('FORGE_TEST_WS_HOSTS_2')) + }) + + test('trims and forwards secret env names and hosts that are otherwise valid', async () => { + setEnv('FORGE_TEST_PADDED', 'v') + setEnv('FORGE_TEST_PADDED_HOSTS', 'v') + + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { + secrets: [ + { env: ' FORGE_TEST_PADDED ', hosts: [' api.github.com '] }, + { env: 'FORGE_TEST_PADDED_HOSTS', hosts: [' *.githubusercontent.com '] }, + ], + }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') - expect(existsSync(expectedPath)).toBe(false) + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.secrets).toEqual([ + { env: 'FORGE_TEST_PADDED', hosts: ['api.github.com'] }, + { env: 'FORGE_TEST_PADDED_HOSTS', hosts: ['*.githubusercontent.com'] }, + ]) + expect(logger.log).not.toHaveBeenCalledWith(expect.stringContaining('FORGE_TEST_PADDED')) }) - test('with no network.env configured, no env file is created and envFile is undefined', async () => { - setEnv('FORGE_TEST_TOKEN', 'abc123') - const dataDir = createTempDataDir() + test('no credential value leaks into the create arguments', async () => { + setEnv('FORGE_TEST_TOKEN', 'super-secret-value') const runtime = createMockSandboxRuntime() const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - dataDir, + network: { + env: ['FORGE_TEST_TOKEN'], + secrets: [{ env: 'FORGE_TEST_TOKEN', hosts: ['api.example.com'] }], + }, } const manager = createSandboxManager(runtime, config, logger) await manager.start('test', '/home/user/worktrees/feature') - expect(manager.getActive('test')?.envFile).toBeUndefined() - expect(existsSync(join(dataDir, 'sandbox-env'))).toBe(false) + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.env).toEqual(['FORGE_TEST_TOKEN']) + expect(createCall[2]?.secrets).toEqual([{ env: 'FORGE_TEST_TOKEN', hosts: ['api.example.com'] }]) + // Only names (bare `-e NAME`) and references (`ENV@HOST`) are forwarded, never values. + expect(JSON.stringify(createCall)).not.toContain('super-secret-value') + expect(JSON.stringify(createCall)).not.toContain('=') + }) + + test('with no network config, start forwards empty env and secrets lists', async () => { + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest' } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.env).toEqual([]) + expect(createCall[2]?.secrets).toEqual([]) }) - test('no sandbox-env directory is created when no listed variable is set', async () => { - setEnv('FORGE_TEST_TOKEN', undefined) - const dataDir = createTempDataDir() + test('adopting an existing running sandbox refreshes secrets with the current filtered list', async () => { + setEnv('FORGE_TEST_SECRET', 's3cr3t-value') + setEnv('FORGE_TEST_ROTATED', 'rotated-value') + setEnv('FORGE_TEST_UNSET', undefined) const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'running') const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - dataDir, - network: { env: ['FORGE_TEST_TOKEN'] }, + network: { + secrets: [ + { env: 'FORGE_TEST_SECRET', hosts: ['api.github.com'] }, + { env: 'FORGE_TEST_ROTATED', hosts: ['api.github.com'] }, + { env: 'FORGE_TEST_UNSET', hosts: ['api.github.com'] }, + ], + }, } const manager = createSandboxManager(runtime, config, logger) await manager.start('test', '/home/user/worktrees/feature') - expect(manager.getActive('test')?.envFile).toBeUndefined() - expect(existsSync(join(dataDir, 'sandbox-env'))).toBe(false) + expect(runtime.getCreateSandboxCalls()).toHaveLength(0) + const calls = runtime.getRefreshSecretCalls() + expect(calls).toHaveLength(1) + expect(calls[0][0]).toBe('forge-test') + expect(calls[0][1]).toEqual([ + { env: 'FORGE_TEST_SECRET', hosts: ['api.github.com'] }, + { env: 'FORGE_TEST_ROTATED', hosts: ['api.github.com'] }, + ]) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('FORGE_TEST_UNSET')) + }) + + test('adopting a stopped sandbox through ensureRunning refreshes secrets exactly once across repeated calls past the TTL', async () => { + setEnv('FORGE_TEST_SECRET', 'v') + vi.useFakeTimers() + try { + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'stopped') + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { secrets: [{ env: 'FORGE_TEST_SECRET', hosts: ['api.example.com'] }] }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.ensureRunning('test', '/home/user/worktrees/feature') + + expect(runtime.getCreateSandboxCalls()).toHaveLength(0) + expect(runtime.getRefreshSecretCalls()).toHaveLength(1) + expect(runtime.getRefreshSecretCalls()[0][1]).toEqual([ + { env: 'FORGE_TEST_SECRET', hosts: ['api.example.com'] }, + ]) + + vi.advanceTimersByTime(3_000) + await manager.ensureRunning('test', '/home/user/worktrees/feature') + vi.advanceTimersByTime(3_000) + await manager.ensureRunning('test', '/home/user/worktrees/feature') + + expect(runtime.getRefreshSecretCalls()).toHaveLength(1) + } finally { + vi.useRealTimers() + } }) - test('stop deletes the env file even when it holds no sandbox-env dir entry', async () => { - setEnv('FORGE_TEST_TOKEN', 'abc123') - const dataDir = createTempDataDir() + test('creating a new sandbox issues no refreshSandboxSecrets call', async () => { + setEnv('FORGE_TEST_SECRET', 'v') const runtime = createMockSandboxRuntime() const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - dataDir, - network: { env: ['FORGE_TEST_TOKEN'] }, + network: { secrets: [{ env: 'FORGE_TEST_SECRET', hosts: ['api.example.com'] }] }, } const manager = createSandboxManager(runtime, config, logger) await manager.start('test', '/home/user/worktrees/feature') - const envFile = manager.getActive('test')?.envFile! - expect(existsSync(envFile)).toBe(true) - // Simulate a stale active entry without a sandbox-env directory listing. - await manager.stop('test') + expect(runtime.getCreateSandboxCalls()).toHaveLength(1) + expect(runtime.getRefreshSecretCalls()).toHaveLength(0) + }) + + test('a failed secret refresh rejects the adoption and is retried on the next attempt', async () => { + setEnv('FORGE_TEST_SECRET', 'v') + + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'running') + const refresh = vi.fn(async () => false) + runtime.refreshSandboxSecrets = refresh + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { secrets: [{ env: 'FORGE_TEST_SECRET', hosts: ['api.example.com'] }] }, + } + + const manager = createSandboxManager(runtime, config, logger) + await expect(manager.start('test', '/home/user/worktrees/feature')).rejects.toThrow(/Failed to refresh secrets/) + expect(refresh).toHaveBeenCalledTimes(1) + expect(logger.log).toHaveBeenCalledWith('Sandbox: failed to refresh secrets for forge-test') + expect(manager.isActive('test')).toBe(false) + + refresh.mockResolvedValue(true) + await expect(manager.start('test', '/home/user/worktrees/feature')).resolves.toEqual({ + containerName: 'forge-test', + }) + expect(refresh).toHaveBeenCalledTimes(2) + expect(manager.isActive('test')).toBe(true) + }) + + test('a failed secret refresh on the ensureRunning adopt path rejects and can be retried', async () => { + setEnv('FORGE_TEST_SECRET', 'v') + + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'stopped') + const refresh = vi.fn(async () => false) + runtime.refreshSandboxSecrets = refresh + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { secrets: [{ env: 'FORGE_TEST_SECRET', hosts: ['api.example.com'] }] }, + } + + const manager = createSandboxManager(runtime, config, logger) + await expect(manager.ensureRunning('test', '/home/user/worktrees/feature')).rejects.toThrow(/Failed to refresh secrets/) + expect(refresh).toHaveBeenCalledTimes(1) + expect(manager.isActive('test')).toBe(false) + + refresh.mockResolvedValue(true) + await expect(manager.ensureRunning('test', '/home/user/worktrees/feature')).resolves.toBe('forge-test') + expect(refresh).toHaveBeenCalledTimes(2) + }) + + test('adopting an existing sandbox with no secrets configured never refreshes', async () => { + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'stopped') + const logger = createMockLogger() + const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest' } + + const manager = createSandboxManager(runtime, config, logger) + await manager.ensureRunning('test', '/home/user/worktrees/feature') + + expect(runtime.getCreateSandboxCalls()).toHaveLength(0) + expect(runtime.getRefreshSecretCalls()).toHaveLength(0) + }) + + test('warns at most once per missing secret host variable across repeated calls', async () => { + setEnv('FORGE_TEST_UNSET', undefined) + vi.useFakeTimers() + try { + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'stopped') + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { secrets: [{ env: 'FORGE_TEST_UNSET', hosts: ['api.example.com'] }] }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.ensureRunning('test', '/home/user/worktrees/feature') + vi.advanceTimersByTime(3_000) + await manager.ensureRunning('test', '/home/user/worktrees/feature') + vi.advanceTimersByTime(3_000) + await manager.ensureRunning('test', '/home/user/worktrees/feature') - expect(existsSync(envFile)).toBe(false) - expect(readdirSync(join(dataDir, 'sandbox-env'))).toHaveLength(0) + const warnings = logger.log.mock.calls.filter((c) => String(c[0]).includes('FORGE_TEST_UNSET')) + expect(warnings).toHaveLength(1) + expect(String(warnings[0][0])).toContain('sandboxed shell commands will fail') + expect(String(warnings[0][0])).toContain('sandbox.network.secrets') + } finally { + vi.useRealTimers() + } + }) + + test('adopting an existing sandbox warns that new secret hosts may be unreachable until recreation', async () => { + setEnv('FORGE_TEST_SECRET', 'v') + + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'running') + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { secrets: [{ env: 'FORGE_TEST_SECRET', hosts: ['api.example.com'] }] }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + + expect(runtime.getRefreshSecretCalls()).toHaveLength(1) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('may be unreachable')) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('recreate the sandbox')) }) - test('stop clears the active map entry even when the env file cannot be removed', async () => { - setEnv('FORGE_TEST_TOKEN', 'abc123') - const dataDir = createTempDataDir() + test('stop clears the convergence so a recreated sandbox converges again', async () => { + setEnv('FORGE_TEST_SECRET', 'v') const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'running') const logger = createMockLogger() const config: SandboxManagerConfig = { image: 'oc-forge-sandbox:latest', - dataDir, - network: { env: ['FORGE_TEST_TOKEN'] }, + network: { secrets: [{ env: 'FORGE_TEST_SECRET', hosts: ['api.example.com'] }] }, } const manager = createSandboxManager(runtime, config, logger) await manager.start('test', '/home/user/worktrees/feature') - const envFile = manager.getActive('test')?.envFile! - expect(existsSync(envFile)).toBe(true) + expect(runtime.getRefreshSecretCalls()).toHaveLength(1) + + await manager.stop('test') + + runtime.setSandboxState('forge-test', 'stopped') + await manager.start('test', '/home/user/worktrees/feature') + expect(runtime.getRefreshSecretCalls()).toHaveLength(2) + }) + + test('cleanupOrphans clears the convergence so a recreated sandbox converges again', async () => { + setEnv('FORGE_TEST_SECRET', 'v') + + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'running') + runtime.setSandboxes(['forge-test']) + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { secrets: [{ env: 'FORGE_TEST_SECRET', hosts: ['api.example.com'] }] }, + } - // Replace the env file with a non-empty directory so its deletion throws (filesystem access), - // while the container removal itself succeeds. - rmSync(envFile) - mkdirSync(envFile) - writeFileSync(join(envFile, 'block'), 'x') + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + expect(runtime.getRefreshSecretCalls()).toHaveLength(1) - await expect(manager.stop('test')).resolves.toBeUndefined() + await manager.cleanupOrphans() + expect(manager.isActive('test')).toBe(false) - // The container was removed and the stale in-memory entry is gone despite the env-file failure, - // so no fail-closed retries are triggered for an already-removed container. - expect(manager.getActive('test')).toBeNull() + runtime.setSandboxState('forge-test', 'stopped') + await manager.start('test', '/home/user/worktrees/feature') + expect(runtime.getRefreshSecretCalls()).toHaveLength(2) }) }) diff --git a/test/sandbox/manager-mount-canonicalization.test.ts b/test/sandbox/manager-mount-canonicalization.test.ts new file mode 100644 index 000000000..35f4642df --- /dev/null +++ b/test/sandbox/manager-mount-canonicalization.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect, afterEach } from 'vitest' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { buildSandboxWorkspaces } from '../../src/sandbox/manager' +import { buildMsbCreateArgs } from '../../src/sandbox/msb' +import { createMockLogger } from '../helpers/sandbox-mocks' + +describe('buildSandboxWorkspaces host-path canonicalization', () => { + const tempDirs: string[] = [] + + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs.length = 0 + }) + + test('canonicalizes the host side of a symlinked mount while keeping the container side as the original path', () => { + const root = mkdtempSync(join(tmpdir(), 'forge-mount-canonical-')) + tempDirs.push(root) + const target = join(root, 'target') + mkdirSync(target) + const link = join(root, 'link') + symlinkSync(target, link) + + const workspaces = buildSandboxWorkspaces([{ hostDir: link, containerDir: link }], createMockLogger()) + + expect(workspaces).toHaveLength(1) + expect(workspaces[0].hostDir).toBe(realpathSync(target)) + expect(workspaces[0].hostDir).not.toBe(link) + expect(workspaces[0].containerDir).toBe(link) + }) + + test('emits the canonical host with the original container path in msb create args', () => { + const root = mkdtempSync(join(tmpdir(), 'forge-mount-canonical-')) + tempDirs.push(root) + const target = join(root, 'target') + mkdirSync(target) + const link = join(root, 'link') + symlinkSync(target, link) + + const [workspace] = buildSandboxWorkspaces([{ hostDir: link, containerDir: link }], createMockLogger()) + const args = buildMsbCreateArgs('forge-c', [workspace], { image: 'oc-forge-sandbox:latest' }) + const [roWorkspace] = buildSandboxWorkspaces( + [{ hostDir: link, containerDir: link, readOnly: true }], + createMockLogger(), + ) + const roArgs = buildMsbCreateArgs('forge-c', [roWorkspace], { image: 'oc-forge-sandbox:latest' }) + + expect(args).toContain('-v') + expect(args).toContain(`${realpathSync(target)}:${link}`) + expect(args).not.toContain(`${link}:${link}`) + expect(roArgs).toContain(`${realpathSync(target)}:${link}:ro`) + }) +}) diff --git a/test/sandbox/manager-network-allow.test.ts b/test/sandbox/manager-network-allow.test.ts index 95496dd6e..4b5f3f0b6 100644 --- a/test/sandbox/manager-network-allow.test.ts +++ b/test/sandbox/manager-network-allow.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect, vi } from 'vitest' +import { describe, test, expect, afterEach } from 'vitest' import { createSandboxManager, type SandboxManagerConfig } from '../../src/sandbox/manager' +import { egressAllowsAll } from '../../src/sandbox/msb' import { createMockLogger, createMockSandboxRuntime } from '../helpers/sandbox-mocks' function makeConfig(allow?: string[]): SandboxManagerConfig { @@ -9,11 +10,25 @@ function makeConfig(allow?: string[]): SandboxManagerConfig { } describe('SandboxManager network allowlist', () => { - test('each configured host is allowed exactly once across two start calls', async () => { + const savedEnv: Record<string, string | undefined> = {} + + afterEach(() => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; else process.env[k] = v + } + Object.keys(savedEnv).forEach((k) => delete savedEnv[k]) + }) + + function setEnv(name: string, value: string | undefined) { + if (!(name in savedEnv)) { + savedEnv[name] = process.env[name] + } + if (value === undefined) delete process.env[name]; else process.env[name] = value + } + + test('configured hosts are forwarded as create-time networkAllow once per createSandbox', async () => { const runtime = createMockSandboxRuntime() const logger = createMockLogger() - const allowNetworkHost = vi.fn(async () => true) - runtime.allowNetworkHost = allowNetworkHost const manager = createSandboxManager( runtime, @@ -24,69 +39,173 @@ describe('SandboxManager network allowlist', () => { await manager.start('test', '/home/user/worktrees/feature') await manager.start('test', '/home/user/worktrees/feature') - expect(allowNetworkHost).toHaveBeenCalledTimes(2) - expect(allowNetworkHost).toHaveBeenNthCalledWith(1, 'registry.npmjs.org') - expect(allowNetworkHost).toHaveBeenNthCalledWith(2, 'pypi.org') + const createCalls = runtime.getCreateSandboxCalls() + expect(createCalls).toHaveLength(1) + expect(createCalls[0][2]?.networkAllow).toEqual(['registry.npmjs.org', 'pypi.org']) }) - test('a false return is logged and does not fail start', async () => { + test('blank allow entries are trimmed out before being forwarded', async () => { const runtime = createMockSandboxRuntime() const logger = createMockLogger() - const allowNetworkHost = vi.fn(async () => false) - runtime.allowNetworkHost = allowNetworkHost - const manager = createSandboxManager(runtime, makeConfig(['registry.npmjs.org']), logger) + const manager = createSandboxManager(runtime, makeConfig([' ', 'pypi.org', '']), logger) - const result = await manager.start('test', '/home/user/worktrees/feature') + await manager.start('test', '/home/user/worktrees/feature') - expect(result.containerName).toBe('forge-test') - expect(allowNetworkHost).toHaveBeenCalledTimes(1) - expect(logger.log).toHaveBeenCalledWith( - expect.stringContaining('registry.npmjs.org'), - ) + const createCalls = runtime.getCreateSandboxCalls() + expect(createCalls).toHaveLength(1) + expect(createCalls[0][2]?.networkAllow).toEqual(['pypi.org']) + }) + + test('an empty union is forwarded when allow is absent', async () => { + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + + const manager = createSandboxManager(runtime, makeConfig(undefined), logger) + + await manager.start('test', '/home/user/worktrees/feature') + + const createCalls = runtime.getCreateSandboxCalls() + expect(createCalls).toHaveLength(1) + expect(createCalls[0][2]?.networkAllow).toEqual([]) + }) + + test('an empty allow array forwards no hosts but still creates', async () => { + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + + const manager = createSandboxManager(runtime, makeConfig([]), logger) + + await manager.start('test', '/home/user/worktrees/feature') + + const createCalls = runtime.getCreateSandboxCalls() + expect(createCalls).toHaveLength(1) + expect(createCalls[0][2]?.networkAllow).toEqual([]) + }) + + test('secret destination hosts are unioned into the create-time networkAllow', async () => { + setEnv('FORGE_TEST_TOKEN', 'v') + + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { + allow: ['api.github.com'], + secrets: [{ env: 'FORGE_TEST_TOKEN', hosts: ['api.github.com', '*.githubusercontent.com'] }], + }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + + const createCall = runtime.getCreateSandboxCalls()[0] + // api.github.com appears exactly once despite being in both allow and the secret hosts. + expect(createCall[2]?.networkAllow).toEqual(['api.github.com', '*.githubusercontent.com']) + expect(createCall[2]?.secrets).toEqual([ + { env: 'FORGE_TEST_TOKEN', hosts: ['api.github.com', '*.githubusercontent.com'] }, + ]) + }) + + test('secrets-only egress is reachable: secret hosts become net-rules without an allow list', async () => { + setEnv('FORGE_TEST_TOKEN', 'v') + + const runtime = createMockSandboxRuntime() + const logger = createMockLogger() + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { secrets: [{ env: 'FORGE_TEST_TOKEN', hosts: ['api.github.com'] }] }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.networkAllow).toEqual(['api.github.com']) }) - test('blank entries in the allowlist are skipped', async () => { + test('hosts of unset or misconfigured secrets are not unioned into the allow list', async () => { + setEnv('FORGE_TEST_UNSET', undefined) + const runtime = createMockSandboxRuntime() const logger = createMockLogger() - const allowNetworkHost = vi.fn(async () => true) - runtime.allowNetworkHost = allowNetworkHost + const config: SandboxManagerConfig = { + image: 'oc-forge-sandbox:latest', + network: { + allow: ['pypi.org'], + secrets: [ + { env: 'FORGE_TEST_UNSET', hosts: ['api.github.com'] }, + { env: '', hosts: ['api.example.com'] }, + ], + }, + } + + const manager = createSandboxManager(runtime, config, logger) + await manager.start('test', '/home/user/worktrees/feature') + + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.networkAllow).toEqual(['pypi.org']) + }) + + test('adopting an existing sandbox performs no create call, so egress rules are not re-applied', async () => { + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-test', 'running') + const logger = createMockLogger() const manager = createSandboxManager( runtime, - makeConfig([' ', 'pypi.org', '']), + makeConfig(['registry.npmjs.org']), logger, ) await manager.start('test', '/home/user/worktrees/feature') - expect(allowNetworkHost).toHaveBeenCalledTimes(1) - expect(allowNetworkHost).toHaveBeenCalledWith('pypi.org') + expect(runtime.getCreateSandboxCalls()).toHaveLength(0) }) - test('no allowNetworkHost call is made when allow is absent', async () => { + test('invalid egress host tokens are dropped and logged, and still flip the sandbox to deny-by-default', async () => { const runtime = createMockSandboxRuntime() const logger = createMockLogger() - const allowNetworkHost = vi.fn(async () => true) - runtime.allowNetworkHost = allowNetworkHost - const manager = createSandboxManager(runtime, makeConfig(undefined), logger) + const manager = createSandboxManager(runtime, makeConfig(['localhost']), logger) await manager.start('test', '/home/user/worktrees/feature') - expect(allowNetworkHost).not.toHaveBeenCalled() + const createCalls = runtime.getCreateSandboxCalls() + expect(createCalls).toHaveLength(1) + expect(createCalls[0][2]?.networkAllow).toEqual([]) + expect((createCalls[0][2] as { restrictEgress?: boolean }).restrictEgress).toBe(true) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('skipping egress host')) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('egress is fully denied')) }) - test('no allowNetworkHost call is made when allow is empty', async () => { + test('no configured hosts forwards restrictEgress false so msb allow-by-default applies', async () => { const runtime = createMockSandboxRuntime() const logger = createMockLogger() - const allowNetworkHost = vi.fn(async () => true) - runtime.allowNetworkHost = allowNetworkHost - const manager = createSandboxManager(runtime, makeConfig([]), logger) + const manager = createSandboxManager(runtime, makeConfig(undefined), logger) await manager.start('test', '/home/user/worktrees/feature') - expect(allowNetworkHost).not.toHaveBeenCalled() + const createCall = runtime.getCreateSandboxCalls()[0] + expect(createCall[2]?.networkAllow).toEqual([]) + expect((createCall[2] as { restrictEgress?: boolean }).restrictEgress).toBe(false) + expect(logger.log).not.toHaveBeenCalledWith(expect.stringContaining('egress is fully denied')) + }) +}) + +describe('egressAllowsAll', () => { + test('is true for an explicit allow-all wildcard in any position, trimmed', () => { + expect(egressAllowsAll(['*'])).toBe(true) + expect(egressAllowsAll(['**'])).toBe(true) + expect(egressAllowsAll([' * '])).toBe(true) + expect(egressAllowsAll(['api.github.com', '*'])).toBe(true) + }) + + test('is false when no allow-all wildcard is present', () => { + expect(egressAllowsAll([])).toBe(false) + expect(egressAllowsAll(undefined)).toBe(false) + expect(egressAllowsAll(['api.github.com'])).toBe(false) + expect(egressAllowsAll(['*.github.com'])).toBe(false) }) }) diff --git a/test/sandbox/manager-project-mount.test.ts b/test/sandbox/manager-project-mount.test.ts index 6774ee576..7eb0e0a77 100644 --- a/test/sandbox/manager-project-mount.test.ts +++ b/test/sandbox/manager-project-mount.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from 'vitest' +import { realpathSync } from 'fs' import { createSandboxManager, type SandboxManagerConfig } from '../../src/sandbox/manager' import { createMockLogger, createMockSandboxRuntime } from '../helpers/sandbox-mocks' @@ -18,7 +19,7 @@ describe('SandboxManager project mount', () => { const calls = runtime.getCreateSandboxCalls() expect(calls.length).toBe(1) const workspaces = calls[0][1] - expect(workspaces).toContainEqual({ hostDir: '/tmp', readOnly: true }) + expect(workspaces).toContainEqual({ hostDir: realpathSync('/tmp'), containerDir: '/tmp', readOnly: true }) }) test('does not add project mount when mountProjectReadonly is false', async () => { @@ -69,7 +70,7 @@ describe('SandboxManager project mount', () => { expect(workspaces).toHaveLength(1) }) - test('does not pass a stale source project directory to sbx', async () => { + test('does not pass a stale source project directory to msb', async () => { const runtime = createMockSandboxRuntime() const logger = createMockLogger() const manager = createSandboxManager(runtime, { @@ -81,7 +82,7 @@ describe('SandboxManager project mount', () => { await manager.start('test', '/home/user/worktrees/feature') expect(runtime.getCreateSandboxCalls()[0][1]).toEqual([ - { hostDir: '/home/user/worktrees/feature', readOnly: undefined }, + { hostDir: '/home/user/worktrees/feature', containerDir: '/home/user/worktrees/feature', readOnly: undefined }, ]) }) diff --git a/test/sandbox/manager-reliability.test.ts b/test/sandbox/manager-reliability.test.ts index d4e38b188..f8363009c 100644 --- a/test/sandbox/manager-reliability.test.ts +++ b/test/sandbox/manager-reliability.test.ts @@ -96,7 +96,7 @@ describe('SandboxManager.ensureRunning', () => { // Advance beyond TTL so the next call performs a real state check vi.advanceTimersByTime(3_000) - // sbx suspends idle microVMs to `stopped`; `sbx exec` resumes them in place + // msb suspends idle microVMs to `stopped`; `msb exec` resumes them in place mockRuntime.getSandboxState = vi.fn(async () => 'stopped' as const) const name = await manager.ensureRunning('test-wt', '/tmp/project') @@ -126,7 +126,7 @@ describe('SandboxManager.ensureRunning', () => { await manager.ensureRunning('test-wt', '/tmp/project') vi.advanceTimersByTime(3_000) - // A failed `sbx ls` says nothing about the sandbox and must not destroy it + // A failed `msb ls` says nothing about the sandbox and must not destroy it mockRuntime.getSandboxState = vi.fn(async () => 'unknown' as const) const name = await manager.ensureRunning('test-wt', '/tmp/project') @@ -219,6 +219,8 @@ describe('SandboxManager.ensureRunning', () => { // Runtime removal fails: the container may still be live, so stop() must surface the failure // (callers that own the lifecycle can record it) while still cleaning up the in-memory entry. + // stop() queries state before removal, so it must confirm the sandbox exists first. + mockRuntime.getSandboxState = vi.fn(async () => 'running' as const) mockRuntime.removeSandbox = vi.fn(async () => { throw new Error('container removal failed') }) diff --git a/test/sandbox/manager-temp-mount.test.ts b/test/sandbox/manager-temp-mount.test.ts index dbf5c53d9..c3b0164a5 100644 --- a/test/sandbox/manager-temp-mount.test.ts +++ b/test/sandbox/manager-temp-mount.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, afterEach } from 'vitest' -import { mkdtempSync, rmSync, existsSync } from 'fs' +import { mkdtempSync, realpathSync, rmSync, existsSync } from 'fs' import { join, resolve } from 'path' import { tmpdir } from 'os' import { createSandboxManager, type SandboxManagerConfig } from '../../src/sandbox/manager' @@ -41,7 +41,7 @@ describe('SandboxManager temp mount', () => { // Appears in workspaces as read-write const workspaces = runtime.getCreateSandboxCalls()[0][1] - expect(workspaces).toContainEqual({ hostDir: resolved, readOnly: false }) + expect(workspaces).toContainEqual({ hostDir: realpathSync(resolved), containerDir: resolved, readOnly: false }) const active = manager.getActive('test') expect(active?.mounts).toContainEqual({ hostDir: resolved, containerDir: resolved, readOnly: false }) @@ -96,8 +96,9 @@ describe('SandboxManager temp mount', () => { const active = manager.getActive('test') const tmpResolved = resolve(tmpDir) - // The tmp dir overlaps the read-only tool-output mount (its ancestor) and arrives after it - // in priority order, so it is dropped — `sbx` rejects overlapping workspace paths. + // The tmp dir is a read-write descendant of the earlier read-only tool-output mount, so it + // conflicts: the read-only flag applies to the whole subtree, and a read-write descendant + // of a read-only ancestor never takes effect. It is therefore dropped in priority order. expect(active?.mounts).toContainEqual({ hostDir: resolve(root), containerDir: resolve(root), readOnly: true }) expect(active?.mounts.some((m) => m.hostDir === tmpResolved)).toBe(false) expect(logger.log).toHaveBeenCalledWith(expect.stringMatching(/dropping workspace/)) diff --git a/test/sandbox/manager-tool-output-mount.test.ts b/test/sandbox/manager-tool-output-mount.test.ts index 48134102c..898bed1de 100644 --- a/test/sandbox/manager-tool-output-mount.test.ts +++ b/test/sandbox/manager-tool-output-mount.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, afterEach } from 'vitest' -import { mkdtempSync, mkdirSync, rmSync } from 'fs' +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'fs' import { join, resolve } from 'path' import { tmpdir } from 'os' import { createSandboxManager, type SandboxManagerConfig } from '../../src/sandbox/manager' @@ -38,7 +38,7 @@ describe('SandboxManager tool-output mount', () => { // Appears in workspaces as read-only const workspaces = runtime.getCreateSandboxCalls()[0][1] - expect(workspaces).toContainEqual({ hostDir: resolved, readOnly: true }) + expect(workspaces).toContainEqual({ hostDir: realpathSync(resolved), containerDir: resolved, readOnly: true }) const active = manager.getActive('test') expect(active?.mounts).toContainEqual({ hostDir: resolved, containerDir: resolved, readOnly: true }) diff --git a/test/sandbox/msb-runtime.test.ts b/test/sandbox/msb-runtime.test.ts new file mode 100644 index 000000000..7deec5cb8 --- /dev/null +++ b/test/sandbox/msb-runtime.test.ts @@ -0,0 +1,1451 @@ +import { describe, test, expect, vi } from 'vitest' +import { + sanitizeMsbName, + sandboxContainerName, + buildMsbExecArgs, + parseMsbCpus, + normalizeMsbSize, + buildMsbCreateArgs, + buildNetworkAllow, + egressRestrictionRequested, + dockerDataVolumeName, + parseMsbSandboxList, + parseMsbSandboxListOrNull, + mapMsbStatus, + parseMsbImageList, + msbImageMatches, + parseMsbInspectSecretNames, + checkMsbAvailability, + describeMsbUnavailable, + createMsbRuntime, + MSB_DEFAULT_TIMEOUT, +} from '../../src/sandbox/msb' +import type { CommandRunner, SandboxRuntime } from '../../src/sandbox/msb' +import { COMMAND_TIMEOUT_EXIT_CODE } from '../../src/sandbox/process' +import type { Logger } from '../../src/types' + +const logger: Logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } + +describe('sandbox name', () => { + test('replaces disallowed characters like the old Docker driver rejected slashes', () => { + expect(sandboxContainerName('feature/test-123')).toBe('forge-feature-test-123') + }) + + test('passes an already-sanitized loop name through unchanged', () => { + expect(sandboxContainerName('my-worktree')).toBe('forge-my-worktree') + }) + + test('empty input yields forge-sandbox', () => { + expect(sandboxContainerName('')).toBe('forge-sandbox') + }) + + test('truncates a long loop name to <= 66 chars with no trailing hyphen', () => { + const long = 'a'.repeat(120) + const name = sandboxContainerName(long) + expect(name.length).toBeLessThanOrEqual(66) + expect(name.endsWith('-')).toBe(false) + expect(name.startsWith('forge-')).toBe(true) + }) + + test('collapses runs of disallowed characters and strips edge separators', () => { + expect(sanitizeMsbName(' My_Work! ')).toBe('my-work') + }) +}) + +describe('exec args', () => { + test('emits the minimal exec vector with the -- separator', () => { + expect(buildMsbExecArgs('forge-c', 'ls')).toEqual([ + 'exec', + 'forge-c', + '--no-tty', + '--quiet', + '--', + 'sh', + '-c', + 'ls', + ]) + }) + + test('places -w and --timeout before the -- separator', () => { + expect(buildMsbExecArgs('forge-c', 'ls', { workdir: '/w', timeoutMs: 30000 })).toEqual([ + 'exec', + 'forge-c', + '--no-tty', + '--quiet', + '-w', + '/w', + '--timeout', + '30s', + '--', + 'sh', + '-c', + 'ls', + ]) + }) + + test('omits flags whose option is unset', () => { + expect(buildMsbExecArgs('forge-c', 'ls', {})).toEqual([ + 'exec', + 'forge-c', + '--no-tty', + '--quiet', + '--', + 'sh', + '-c', + 'ls', + ]) + }) + + test('rounds timeout milliseconds up to whole seconds', () => { + expect(buildMsbExecArgs('forge-c', 'ls', { timeoutMs: 1500 })).toContain('--timeout') + expect(buildMsbExecArgs('forge-c', 'ls', { timeoutMs: 1500 })).toContain('2s') + }) +}) + +describe('create args', () => { + test('emits the base vector with image positional after create', () => { + expect(buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { image: 'oc-forge-sandbox:latest' })).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-v', + '/a:/a', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + ]) + }) + + test('never emits the allow@dns rule that msb 0.6.8 rejects', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { image: 'oc-forge-sandbox:latest' }) + expect(args).not.toContain('allow@dns') + expect(args.join(' ')).not.toContain('allow@dns') + }) + + test('suffixes read-only workspaces with :ro and leaves read-write bare', () => { + expect( + buildMsbCreateArgs( + 'forge-c', + [{ hostDir: '/a', containerDir: '/a' }, { hostDir: '/b', containerDir: '/b', readOnly: true }], + { image: 'oc-forge-sandbox:latest' }, + ), + ).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-v', + '/a:/a', + '-v', + '/b:/b:ro', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + ]) + }) + + test('includes cpus and memory flags when present', () => { + expect( + buildMsbCreateArgs('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + memory: '8g', + cpus: 4, + }), + ).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-c', + '4', + '-m', + '8g', + '-v', + '/work:/work', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + ]) + }) + + test('emits deny-by-default network flags plus one allow rule per non-blank host', () => { + expect( + buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + networkAllow: ['github.com', ' '], + }), + ).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-v', + '/a:/a', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + '--net-default', + 'deny', + '--net-rule', + 'allow@github.com', + ]) + }) + + test('emits bare -e flags for env names and never inlines a value', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + env: ['GITHUB_TOKEN', 'CI'], + }) + expect(args).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-v', + '/a:/a', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + '-e', + 'GITHUB_TOKEN', + '-e', + 'CI', + ]) + expect(args.join(' ')).not.toContain('GITHUB_TOKEN=') + }) + + test('drops env entries that are blank or contain an equals sign so no value enters argv', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + env: ['KEEP', 'GITHUB_TOKEN=secret', ' ', 'LEAK=value'], + }) + expect(args).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-v', + '/a:/a', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + '-e', + 'KEEP', + ]) + expect(args.join(' ')).not.toContain('secret') + expect(args.join(' ')).not.toContain('value') + expect(args.join(' ')).not.toContain('GITHUB_TOKEN=') + expect(args.join(' ')).not.toContain('LEAK=') + }) + + test('trims whitespace around env names before emitting -e', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + env: [' GITHUB_TOKEN '], + }) + expect(args).toContain('-e') + expect(args).toContain('GITHUB_TOKEN') + expect(args).not.toContain(' GITHUB_TOKEN ') + }) + + test('emits one --secret env@hosts flag per secret with hosts joined by commas', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + secrets: [{ env: 'GITHUB_TOKEN', hosts: ['api.github.com', '*.githubusercontent.com'] }], + }) + expect(args).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-v', + '/a:/a', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + '--secret', + 'GITHUB_TOKEN@api.github.com,*.githubusercontent.com', + ]) + expect(args.join(' ')).not.toContain('GITHUB_TOKEN=') + }) + + test('drops secrets with a blank env name, a value-bearing env name, or an empty host list', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + secrets: [ + { env: 'KEEP', hosts: ['api.example.com'] }, + { env: 'NO_HOSTS', hosts: [] }, + { env: 'BLANK_HOSTS', hosts: [' '] }, + { env: ' ', hosts: ['api.example.com'] }, + { env: 'GITHUB_TOKEN=super-secret', hosts: ['api.github.com'] }, + ], + }) + expect(args).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-v', + '/a:/a', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + '--secret', + 'KEEP@api.example.com', + ]) + expect(args.join(' ')).not.toContain('super-secret') + }) + + test('normalizes padded secret env names and hosts into the reference form', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + secrets: [{ env: ' TOKEN ', hosts: [' api.example.com ', '*.github.com'] }], + }) + expect(args).toContain('--secret') + expect(args).toContain('TOKEN@api.example.com,*.github.com') + expect(args).not.toContain(' TOKEN ') + }) + + test('no credential value appears in any argument vector produced by buildMsbCreateArgs', () => { + const vector = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + env: ['GITHUB_TOKEN'], + secrets: [{ env: 'NPM_TOKEN', hosts: ['registry.npmjs.org'] }], + }) + // The bare `-e NAME` and `--secret ENV@HOST` reference forms never inline a value, so no + // `NAME=VALUE` fragment can appear in the vector. + expect(vector.join(' ')).not.toContain('GITHUB_TOKEN=') + expect(vector.join(' ')).not.toContain('NPM_TOKEN=') + }) + + test('throws on an empty workspace array', () => { + expect(() => buildMsbCreateArgs('forge-c', [], { image: 'x' })).toThrow( + 'requires at least one workspace', + ) + }) + + test('a host shared by allow and secrets emits exactly one matching --net-rule allow@host', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + networkAllow: buildNetworkAllow( + ['api.github.com', 'pypi.org'], + [{ env: 'GITHUB_TOKEN', hosts: ['api.github.com', '*.githubusercontent.com'] }], + ), + secrets: [{ env: 'GITHUB_TOKEN', hosts: ['api.github.com', '*.githubusercontent.com'] }], + }) + // The union (via buildNetworkAllow) deduplicates api.github.com, so it is allowed once. + expect(args.filter((a) => a === 'allow@api.github.com')).toHaveLength(1) + expect(args).toContain('allow@pypi.org') + expect(args).toContain('allow@*.githubusercontent.com') + expect(args).toContain('--secret') + expect(args).toContain('GITHUB_TOKEN@api.github.com,*.githubusercontent.com') + }) + + test('emits no net flags at all when no egress hosts are configured', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + networkAllow: [], + }) + expect(args).not.toContain('--net-default') + expect(args).not.toContain('--net-rule') + expect(args.join(' ')).not.toContain('deny') + }) + + test('an all-invalid allow list keeps deny with no allow rules instead of silently widening to allow-all', () => { + const log = vi.fn() + const effective = buildNetworkAllow(['localhost', '*.com'], undefined, { ...logger, log }) + expect(effective).toEqual([]) + expect(log).toHaveBeenCalledWith( + 'Sandbox: every configured egress host was rejected as invalid; sandbox egress is fully denied', + ) + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + networkAllow: effective, + restrictEgress: true, + }) + expect(args).toContain('--net-default') + expect(args).toContain('deny') + expect(args.filter((a) => a.startsWith('--net-rule'))).toHaveLength(0) + }) + + test('an allow-all wildcard emits no net flags at all', () => { + const effective = buildNetworkAllow(['**'], undefined, logger) + expect(effective).toEqual([]) + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + networkAllow: effective, + restrictEgress: egressRestrictionRequested(['**'], undefined), + }) + expect(args).not.toContain('--net-default') + expect(args.filter((a) => a.startsWith('--net-rule'))).toHaveLength(0) + expect(args.join(' ')).not.toContain('deny') + }) + + test('a concrete allow-list entry still flips the sandbox to deny-by-default', () => { + const effective = buildNetworkAllow(['api.github.com'], undefined, logger) + expect(effective).toEqual(['api.github.com']) + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + networkAllow: effective, + restrictEgress: egressRestrictionRequested(['api.github.com'], undefined), + }) + expect(args).toContain('--net-default') + expect(args).toContain('deny') + expect(args).toContain('allow@api.github.com') + }) + + test('emits the docker data volume mount with a deterministic per-sandbox name', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + }) + expect(args).toContain('--mount-named') + expect(args).toContain('forge-c-docker-data:/var/lib/docker:kind=disk,size=16g') + + const other = buildMsbCreateArgs('forge-other', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + }) + expect(other).toContain('forge-other-docker-data:/var/lib/docker:kind=disk,size=16g') + expect(other).not.toContain('forge-c-docker-data:') + }) + + test('uses the configured docker disk size when provided', () => { + const args = buildMsbCreateArgs('forge-c', [{ hostDir: '/a', containerDir: '/a' }], { + image: 'oc-forge-sandbox:latest', + dockerDisk: '32g', + }) + expect(args).toContain('forge-c-docker-data:/var/lib/docker:kind=disk,size=32g') + }) + + test('dockerDataVolumeName derives a stable per-container volume name', () => { + expect(dockerDataVolumeName('forge-my-worktree')).toBe('forge-my-worktree-docker-data') + expect(dockerDataVolumeName('Forge/C!')).toBe('forge-c-docker-data') + expect(dockerDataVolumeName('forge-a')).not.toBe(dockerDataVolumeName('forge-b')) + }) +}) + +describe('network allow union', () => { + test('unions allow hosts with secret hosts, trims, drops blanks, and deduplicates', () => { + expect( + buildNetworkAllow( + ['github.com', ' ', ''], + [{ env: 'GITHUB_TOKEN', hosts: ['api.github.com', ' github.com '] }], + ), + ).toEqual(['github.com', 'api.github.com']) + }) + + test('returns just the allow list when no secrets are configured', () => { + expect(buildNetworkAllow(['registry.npmjs.org'], undefined)).toEqual(['registry.npmjs.org']) + expect(buildNetworkAllow(undefined, undefined)).toEqual([]) + }) + + test('returns just the secret hosts when no allow list is configured', () => { + expect( + buildNetworkAllow(undefined, [{ env: 'NPM_TOKEN', hosts: ['registry.npmjs.org'] }]), + ).toEqual(['registry.npmjs.org']) + }) + + test('drops hosts of misconfigured secrets that msb would refuse to bind', () => { + expect( + buildNetworkAllow(undefined, [ + { env: 'KEEP', hosts: ['api.example.com'] }, + { env: 'NO_HOSTS', hosts: [] }, + { env: 'A=B', hosts: ['api.example.com'] }, + ]), + ).toEqual(['api.example.com']) + }) + + test('rejects a comma-laden host because a comma separates whole rule tokens', () => { + const log = vi.fn() + expect(buildNetworkAllow(['a.com,b.com'], undefined, { ...logger, log })).toEqual([]) + expect(log).toHaveBeenCalledWith( + 'Sandbox: skipping egress host "a.com,b.com": commas separate rule tokens, not hosts', + ) + }) + + test('rejects a port-qualified host that lacks the tcp/udp rule form', () => { + const log = vi.fn() + expect(buildNetworkAllow(['example.com:443'], undefined, { ...logger, log })).toEqual([]) + expect(log).toHaveBeenCalledWith( + 'Sandbox: skipping egress host "example.com:443": port-qualified hosts need the tcp/udp rule form', + ) + }) + + test('rejects a host containing the @ target separator', () => { + const log = vi.fn() + expect(buildNetworkAllow(['user@example.com'], undefined, { ...logger, log })).toEqual([]) + expect(log).toHaveBeenCalledWith( + 'Sandbox: skipping egress host "user@example.com": the @ character is reserved for rule targets', + ) + }) + + test('a bare * wildcard leaves egress unrestricted instead of being rejected as an invalid host', () => { + const log = vi.fn() + expect(buildNetworkAllow(['*'], undefined, { ...logger, log })).toEqual([]) + expect(log).toHaveBeenCalledWith('Sandbox: wildcard allow-list leaves sandbox egress unrestricted') + expect(log).not.toHaveBeenCalledWith( + 'Sandbox: skipping egress host "*": the bare wildcard is not a valid egress host', + ) + }) + + test('rejects a wildcard suffix with fewer than two labels', () => { + const log = vi.fn() + expect(buildNetworkAllow(['*.com'], undefined, { ...logger, log })).toEqual([]) + expect(log).toHaveBeenCalledWith( + 'Sandbox: skipping egress host "*.com": wildcard suffixes need at least two labels', + ) + }) + + test('rejects a bare single-label host that msb requires to be domain=-prefixed', () => { + const log = vi.fn() + expect(buildNetworkAllow(['barehost'], undefined, { ...logger, log })).toEqual([]) + expect(log).toHaveBeenCalledWith( + 'Sandbox: skipping egress host "barehost": bare single-label hosts are ambiguous; use domain=name', + ) + }) + + test('rejects a suffix= domain with fewer than two labels', () => { + const log = vi.fn() + expect(buildNetworkAllow(['suffix=com'], undefined, { ...logger, log })).toEqual([]) + expect(log).toHaveBeenCalledWith( + 'Sandbox: skipping egress host "suffix=com": suffix= domains need at least two labels', + ) + }) + + test('applies the same rejection rules to secret destination hosts', () => { + const log = vi.fn() + expect( + buildNetworkAllow( + undefined, + [{ env: 'KEEP', hosts: ['api.example.com', '*.com', 'bad,host'] }], + { ...logger, log }, + ), + ).toEqual(['api.example.com']) + expect(log).toHaveBeenCalledTimes(2) + }) + + test('accepts a multi-label wildcard and the domain=/suffix= forms unchanged', () => { + expect( + buildNetworkAllow(['*.example.com', 'domain=myhost', 'suffix=example.com'], undefined, logger), + ).toEqual(['*.example.com', 'domain=myhost', 'suffix=example.com']) + }) + + test('still unions, trims, and deduplicates when no logger is supplied', () => { + expect( + buildNetworkAllow( + ['*.github.com', ' github.com ', '*.com'], + [{ env: 'T', hosts: ['github.com', '*.com'] }], + ), + ).toEqual(['*.github.com', 'github.com']) + }) + + test('an allow-all wildcard short-circuits validation and leaves egress unrestricted', () => { + const log = vi.fn() + expect(buildNetworkAllow(['**'], undefined, { ...logger, log })).toEqual([]) + expect(log).toHaveBeenCalledWith('Sandbox: wildcard allow-list leaves sandbox egress unrestricted') + expect(log).not.toHaveBeenCalledWith( + 'Sandbox: every configured egress host was rejected as invalid; sandbox egress is fully denied', + ) + }) + + test('an allow-all wildcard beats narrower entries in the same allow list', () => { + expect(buildNetworkAllow(['*', 'api.github.com'], undefined, logger)).toEqual([]) + }) + + test('a wildcard suffix stays a normal restricted allow-list entry', () => { + expect(buildNetworkAllow(['*.github.com'], undefined, logger)).toEqual(['*.github.com']) + }) + + test('egressRestrictionRequested is false for an allow-all wildcard even with secret hosts', () => { + expect(egressRestrictionRequested(['**'], undefined)).toBe(false) + expect(egressRestrictionRequested(['**'], [{ env: 'TOKEN', hosts: ['api.github.com'] }])).toBe(false) + }) + + test('egressRestrictionRequested is false only when no host token is configured at all', () => { + expect(egressRestrictionRequested(undefined, undefined)).toBe(false) + expect(egressRestrictionRequested([], [])).toBe(false) + expect(egressRestrictionRequested([' ', ''], undefined)).toBe(false) + expect(egressRestrictionRequested(undefined, [{ env: 'NO_HOSTS', hosts: [] }])).toBe(false) + }) + + test('egressRestrictionRequested is true for any configured token, valid or not', () => { + expect(egressRestrictionRequested(['github.com'], undefined)).toBe(true) + expect(egressRestrictionRequested(['localhost'], undefined)).toBe(true) + expect(egressRestrictionRequested(undefined, [{ env: 'T', hosts: ['bad,host'] }])).toBe(true) + }) +}) + +describe('resource coercion', () => { + test('parseMsbCpus returns the floored integer for whole and fractional input', () => { + expect(parseMsbCpus('4', logger)).toBe(4) + expect(parseMsbCpus('2.5', logger)).toBe(2) + }) + + test('parseMsbCpus logs when rounding a fractional value', () => { + const log = vi.fn() + parseMsbCpus('2.5', { ...logger, log }) + expect(log).toHaveBeenCalledWith( + 'Sandbox: msb --cpus is integer-only; rounding cpus="2.5" down to 2', + ) + }) + + test('parseMsbCpus returns undefined for non-numeric input', () => { + expect(parseMsbCpus('abc', logger)).toBeUndefined() + expect(parseMsbCpus(undefined, logger)).toBeUndefined() + }) + + test('normalizeMsbSize passes lowercased value without trailing b', () => { + expect(normalizeMsbSize('8g', logger)).toBe('8g') + expect(normalizeMsbSize('8GB', logger)).toBe('8g') + expect(normalizeMsbSize('1024m', logger)).toBe('1024m') + }) + + test('normalizeMsbSize returns undefined for unrecognized input', () => { + expect(normalizeMsbSize('lots', logger)).toBeUndefined() + expect(normalizeMsbSize(undefined, logger)).toBeUndefined() + }) + + test('normalizeMsbSize applies to the docker disk size string as well', () => { + expect(normalizeMsbSize('16g', logger)).toBe('16g') + expect(normalizeMsbSize('32GB', logger)).toBe('32g') + }) +}) + +describe('sandbox list', () => { + test('maps the canonical array form into running and stopped states', () => { + const entries = parseMsbSandboxList( + '[{"name":"forge-a","status":"Running"},{"name":"forge-b","status":"Stopped"}]', + ) + expect(entries.map((e) => e.name)).toEqual(['forge-a', 'forge-b']) + expect(entries.map((e) => e.state)).toEqual(['running', 'stopped']) + expect(entries.map((e) => e.status)).toEqual(['Running', 'Stopped']) + }) + + test('empty output is a legitimately empty list', () => { + expect(parseMsbSandboxListOrNull('')).toEqual([]) + expect(parseMsbSandboxListOrNull(' ')).toEqual([]) + }) + + test('unparseable output returns null so the caller reports unknown', () => { + expect(parseMsbSandboxListOrNull('not json')).toBeNull() + }) + + test('valid JSON in a non-array shape returns null', () => { + expect(parseMsbSandboxListOrNull('{"error":"x"}')).toBeNull() + expect(parseMsbSandboxListOrNull('{"sandboxes":[]}')).toBeNull() + }) + + test('parseMsbSandboxList falls back to an empty list when parsing fails', () => { + expect(parseMsbSandboxList('{"error":"x"}')).toEqual([]) + }) + + test('drops entries without a non-empty string name', () => { + const entries = parseMsbSandboxList( + '[{"status":"Running"},{"name":""},{"name":"forge-c","status":"Paused"}]', + ) + expect(entries.map((e) => e.name)).toEqual(['forge-c']) + }) + + test('mapMsbStatus classifies all seven upstream msb variants', () => { + expect(mapMsbStatus('Created')).toBe('transient') + expect(mapMsbStatus('Starting')).toBe('transient') + expect(mapMsbStatus('Running')).toBe('running') + expect(mapMsbStatus('Draining')).toBe('transient') + expect(mapMsbStatus('Paused')).toBe('transient') + expect(mapMsbStatus('Stopped')).toBe('stopped') + expect(mapMsbStatus('Crashed')).toBe('stopped') + }) + + test('mapMsbStatus keeps unrecognized statuses on the fail-closed unknown path', () => { + expect(mapMsbStatus('Suspended')).toBe('unknown') + expect(mapMsbStatus('')).toBe('unknown') + expect(mapMsbStatus('Quarantined')).toBe('unknown') + }) +}) + +describe('image list', () => { + test('returns the reference strings from the canonical array form', () => { + expect( + parseMsbImageList( + '[{"reference":"docker.io/library/oc-forge-sandbox:latest","digest":"sha256:abc"}]', + ), + ).toEqual(['docker.io/library/oc-forge-sandbox:latest']) + }) + + test('returns an empty list on parse failure', () => { + expect(parseMsbImageList('not json')).toEqual([]) + }) + + test('returns an empty list for a non-array payload', () => { + expect(parseMsbImageList('{"error":"x"}')).toEqual([]) + }) + + test('drops entries without a non-empty string reference', () => { + expect(parseMsbImageList('[{"digest":"x"},{"reference":""},{"reference":"a:latest"}]')).toEqual([ + 'a:latest', + ]) + }) + + test('msbImageMatches matches a bare name against a registry-qualified reference', () => { + expect( + msbImageMatches(['docker.io/library/oc-forge-sandbox:latest'], 'oc-forge-sandbox:latest'), + ).toBe(true) + }) + + test('msbImageMatches rejects a tag mismatch', () => { + expect(msbImageMatches(['docker.io/library/oc-forge-sandbox:latest'], 'oc-forge-sandbox:v2')).toBe( + false, + ) + }) + + test('msbImageMatches treats a tagless ref as latest and an exact repository as a match', () => { + expect(msbImageMatches(['oc-forge-sandbox:latest'], 'oc-forge-sandbox')).toBe(true) + expect(msbImageMatches(['oc-forge-sandbox:latest'], 'oc-forge-sandbox:v1')).toBe(false) + expect(msbImageMatches([], 'oc-forge-sandbox:latest')).toBe(false) + }) + + test('msbImageMatches does not mistake a registry port for a tag separator', () => { + expect(msbImageMatches(['localhost:5000/oc-forge-sandbox:latest'], 'localhost:5000/oc-forge-sandbox')).toBe( + true, + ) + }) + + test('msbImageMatches honors explicit tags on port-qualified registries', () => { + expect(msbImageMatches(['localhost:5000/oc-forge-sandbox:latest'], 'localhost:5000/oc-forge-sandbox:latest')).toBe( + true, + ) + expect(msbImageMatches(['localhost:5000/oc-forge-sandbox:latest'], 'localhost:5000/oc-forge-sandbox:v2')).toBe( + false, + ) + }) + + test('msbImageMatches treats a port-qualified registry as part of the repository', () => { + // The registry authority is not interchangeable: only the exact repository (or a bare + // trailing-repository match) passes, so the port never leaks into tag comparison. + expect(msbImageMatches(['localhost:5000/oc-forge-sandbox:latest'], 'localhost:5000/other:latest')).toBe( + false, + ) + expect(msbImageMatches(['localhost:5000/oc-forge-sandbox:latest'], 'other-registry.io/oc-forge-sandbox:latest')).toBe( + false, + ) + }) +}) + +describe('inspect secret parsing', () => { + test('extracts the bound secret env names from inspect output', () => { + const stdout = JSON.stringify({ + name: 'forge-c', + status: 'Running', + config: { network: { secrets: { secrets: [{ env_var: 'A_TOKEN' }, { env_var: 'B_TOKEN' }] } } }, + }) + expect(parseMsbInspectSecretNames(stdout)).toEqual(['A_TOKEN', 'B_TOKEN']) + }) + + test('returns an empty list when no secrets are bound', () => { + const stdout = JSON.stringify({ name: 'forge-c', status: 'Running', config: { network: {} } }) + expect(parseMsbInspectSecretNames(stdout)).toEqual([]) + }) + + test('returns null for unparseable or malformed output so callers fail closed', () => { + expect(parseMsbInspectSecretNames('not json')).toBeNull() + expect(parseMsbInspectSecretNames('[]')).toBeNull() + expect(parseMsbInspectSecretNames('{"config":{}}')).toBeNull() + expect( + parseMsbInspectSecretNames( + JSON.stringify({ config: { network: { secrets: { secrets: 'x' } } } }), + ), + ).toBeNull() + }) +}) + +describe('availability', () => { + test('a passing doctor check yields available', async () => { + const fake: CommandRunner = async () => ({ stdout: 'ok\n', stderr: '', exitCode: 0 }) + await expect(checkMsbAvailability(fake)).resolves.toEqual({ available: true }) + }) + + test('a missing CLI yields not-installed from an ENOENT spawn', async () => { + const fake: CommandRunner = async () => ({ stdout: '', stderr: 'spawn msb ENOENT', exitCode: 1 }) + await expect(checkMsbAvailability(fake)).resolves.toEqual({ + available: false, + reason: 'not-installed', + }) + }) + + test('a host that cannot run microVMs yields host-unsupported with trimmed detail', async () => { + const fake: CommandRunner = async () => ({ + stdout: '', + stderr: '/dev/kvm not found\n', + exitCode: 1, + }) + const result = await checkMsbAvailability(fake) + expect(result).toMatchObject({ available: false, reason: 'host-unsupported' }) + if (!result.available) expect(result.detail).toBe('/dev/kvm not found') + }) + + test('a rejecting runner yields unknown', async () => { + const fake: CommandRunner = async () => { + throw new Error('boom') + } + await expect(checkMsbAvailability(fake)).resolves.toEqual({ available: false, reason: 'unknown' }) + }) + + test('a timed-out probe yields unknown with a detail naming the bound', async () => { + const fake: CommandRunner = async () => ({ + stdout: '', + stderr: '', + exitCode: COMMAND_TIMEOUT_EXIT_CODE, + }) + const result = await checkMsbAvailability(fake) + expect(result).toMatchObject({ available: false, reason: 'unknown' }) + if (!result.available) expect(result.detail).toMatch(/did not answer within 30000ms/) + }) + + test('passes a 30000ms timeout to the runner', async () => { + const optsSeen: Array<{ timeout?: number }> = [] + const fake: CommandRunner = async (_args, opts) => { + optsSeen.push(opts ?? {}) + return { stdout: 'ok\n', stderr: '', exitCode: 0 } + } + await checkMsbAvailability(fake) + expect(optsSeen[0]?.timeout).toBe(30000) + }) + + test('describeMsbUnavailable carries the remediation strings without a login step', () => { + expect(describeMsbUnavailable({ available: false, reason: 'not-installed' })).toMatch( + /install\.microsandbox\.dev/, + ) + expect(describeMsbUnavailable({ available: false, reason: 'host-unsupported' })).toMatch( + /msb doctor/, + ) + expect(describeMsbUnavailable({ available: false, reason: 'unknown', detail: 'x' })).toMatch(/x/) + }) +}) + +describe('runtime', () => { + interface Rec { + args: string[] + opts?: { timeout?: number; stdin?: string; abort?: AbortSignal } + } + 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, abort: opts?.abort } } + calls.push(rec) + const res = handler ? handler(rec) : { stdout: '', stderr: '', exitCode: 0 } + return res + } + return { calls, runner } + } + + const alreadyExistsStderr = + "error: sandbox already exists: sandbox 'forge-x' already exists; remove it, start the stopped sandbox, or recreate with .replace()" + + test('exec maps cwd and default timeout into native flags with no cd prefix', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.exec('forge-c', 'ls', { cwd: '/w' }) + expect(calls[0].args).toEqual([ + 'exec', + 'forge-c', + '--no-tty', + '--quiet', + '-w', + '/w', + '--timeout', + '120s', + '--', + 'sh', + '-c', + 'ls', + ]) + expect(calls[0].args.join(' ')).not.toContain("cd '/w' &&") + expect(calls[0].opts?.timeout).toBe(120000) + }) + + test('exec passes an explicit timeout and abort through to the runner', async () => { + const abort = new AbortController().signal + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.exec('forge-c', 'ls', { timeout: 3000, cwd: '/w', abort }) + expect(calls[0].args).toContain('--timeout') + expect(calls[0].args).toContain('3s') + expect(calls[0].opts?.timeout).toBe(3000) + expect(calls[0].opts?.abort).toBe(abort) + }) + + test('exec omits the -w flag when no cwd is given', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.exec('forge-c', 'ls') + expect(calls[0].args).toEqual([ + 'exec', + 'forge-c', + '--no-tty', + '--quiet', + '--timeout', + '120s', + '--', + 'sh', + '-c', + 'ls', + ]) + }) + + test('createSandbox builds create args with the image and coerced resources', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + resources: { memory: '8GB', cpus: '2.5' }, + }) + expect(calls[0].args).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-c', + '2', + '-m', + '8g', + '-v', + '/work:/work', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + ]) + expect(calls[0].opts?.timeout).toBe(120000) + }) + + test('createSandbox forwards coerced boot ceilings as --max-cpus and --max-memory', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + resources: { memory: '2g', maxMemory: '16GB', cpus: '2', maxCpus: '8.5' }, + }) + expect(calls[0].args).toEqual([ + 'create', + 'oc-forge-sandbox:latest', + '--name', + 'forge-c', + '--quiet', + '-c', + '2', + '--max-cpus', + '8', + '-m', + '2g', + '--max-memory', + '16g', + '-v', + '/work:/work', + '--mount-named', + 'forge-c-docker-data:/var/lib/docker:kind=disk,size=16g', + ]) + }) + + test('createSandbox omits the boot ceilings when only one of them is configured', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + resources: { memory: '2g', maxMemory: '16g', cpus: '2' }, + }) + expect(calls[0].args).toContain('--max-memory') + expect(calls[0].args).not.toContain('--max-cpus') + }) + + test('createSandbox drops unparsable boot ceilings instead of passing them to msb', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + resources: { memory: '2g', maxMemory: 'lots', cpus: '2', maxCpus: 'many' }, + }) + expect(calls[0].args).not.toContain('--max-memory') + expect(calls[0].args).not.toContain('--max-cpus') + }) + + test('createSandbox forwards networkAllow into the create args', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + networkAllow: ['github.com', ' '], + }) + expect(calls[0].args).toContain('--net-default') + expect(calls[0].args).toContain('allow@github.com') + expect(calls[0].args).not.toContain('allow@ ') + }) + + test('createSandbox forwards restrictEgress and the docker disk size into the create args', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + restrictEgress: true, + resources: { dockerDisk: '32g' }, + }) + expect(calls[0].args).toContain('--net-default') + expect(calls[0].args).toContain('deny') + expect(calls[0].args).toContain('--mount-named') + expect(calls[0].args).toContain('forge-c-docker-data:/var/lib/docker:kind=disk,size=32g') + }) + + test('createSandbox forwards env and secrets into the create args', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + env: ['GITHUB_TOKEN'], + secrets: [{ env: 'API_KEY', hosts: ['api.example.com'] }], + }) + expect(calls[0].args).toContain('-e') + expect(calls[0].args).toContain('GITHUB_TOKEN') + expect(calls[0].args).toContain('--secret') + expect(calls[0].args).toContain('API_KEY@api.example.com') + expect(calls[0].args.join(' ')).not.toContain('GITHUB_TOKEN=') + expect(calls[0].args.join(' ')).not.toContain('API_KEY=') + }) + + test('createSandbox throws on non-zero exit', async () => { + const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'boom', exitCode: 1 })) + const rt = createMsbRuntime(logger, { run: runner }) + await expect( + rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { image: 'oc-forge-sandbox:latest' }), + ).rejects.toThrow('Failed to create sandbox: boom') + }) + + test('createSandbox on success performs exactly one invocation and never appends --replace', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.createSandbox('forge-c', [{ hostDir: '/work', containerDir: '/work' }], { + image: 'oc-forge-sandbox:latest', + }) + expect(calls).toHaveLength(1) + expect(calls[0].args).not.toContain('--replace') + }) + + test('createSandbox retries once with a trailing --replace when an already-exists failure hides an orphaned directory', async () => { + const { calls, runner } = recordingRunner((rec) => + rec.args.includes('--replace') + ? { stdout: '', stderr: '', exitCode: 0 } + : { stdout: '', stderr: alreadyExistsStderr, exitCode: 1 }, + ) + const rt = createMsbRuntime(logger, { run: runner }) + await expect( + rt.createSandbox('forge-x', [{ hostDir: '/work', containerDir: '/work' }], { image: 'oc-forge-sandbox:latest' }), + ).resolves.toBeUndefined() + expect(calls).toHaveLength(2) + expect(calls[1].args).toEqual([...calls[0].args, '--replace']) + }) + + test('createSandbox surfaces the second --replace failure rather than the first already-exists error', async () => { + const { calls, runner } = recordingRunner((rec) => + rec.args.includes('--replace') + ? { stdout: '', stderr: 'disk corrupt', exitCode: 2 } + : { stdout: '', stderr: alreadyExistsStderr, exitCode: 1 }, + ) + const rt = createMsbRuntime(logger, { run: runner }) + const err = await rt + .createSandbox('forge-x', [{ hostDir: '/work', containerDir: '/work' }], { image: 'oc-forge-sandbox:latest' }) + .catch((e: unknown) => e) + expect(err).toBeInstanceOf(Error) + if (err instanceof Error) { + expect(err.message).toContain('Failed to create sandbox: disk corrupt') + expect(err.message).not.toContain('remove it, start the stopped sandbox') + } + expect(calls).toHaveLength(2) + expect(calls[1].args).toEqual([...calls[0].args, '--replace']) + }) + + test('createSandbox does not retry with --replace on an unrelated failure', async () => { + const { calls, runner } = recordingRunner(() => ({ stdout: '', stderr: 'no space left on device', exitCode: 1 })) + const rt = createMsbRuntime(logger, { run: runner }) + await expect( + rt.createSandbox('forge-x', [{ hostDir: '/work', containerDir: '/work' }], { image: 'oc-forge-sandbox:latest' }), + ).rejects.toThrow('Failed to create sandbox: no space left on device') + expect(calls).toHaveLength(1) + expect(calls[0].args).not.toContain('--replace') + }) + + test('createSandbox retries when the already-exists wording differs in capitalization', async () => { + const { calls, runner } = recordingRunner((rec) => + rec.args.includes('--replace') + ? { stdout: '', stderr: '', exitCode: 0 } + : { stdout: '', stderr: 'Sandbox Already Exists: duplicate', exitCode: 1 }, + ) + const rt = createMsbRuntime(logger, { run: runner }) + await expect( + rt.createSandbox('forge-x', [{ hostDir: '/work', containerDir: '/work' }], { image: 'oc-forge-sandbox:latest' }), + ).resolves.toBeUndefined() + expect(calls).toHaveLength(2) + expect(calls[1].args).toEqual([...calls[0].args, '--replace']) + }) + + test('createSandbox logs the orphaned-state recovery naming the sandbox and --replace', async () => { + const log = vi.fn() + const { runner } = recordingRunner((rec) => + rec.args.includes('--replace') + ? { stdout: '', stderr: '', exitCode: 0 } + : { stdout: '', stderr: alreadyExistsStderr, exitCode: 1 }, + ) + const rt = createMsbRuntime({ ...logger, log }, { run: runner }) + await expect( + rt.createSandbox('forge-x', [{ hostDir: '/work', containerDir: '/work' }], { image: 'oc-forge-sandbox:latest' }), + ).resolves.toBeUndefined() + expect(log).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith(expect.stringContaining('forge-x')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('--replace')) + }) + + test('removeSandbox removes the sandbox and then its derived docker data volume', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.removeSandbox('forge-a') + expect(calls[0].args).toEqual(['rm', '--force', 'forge-a', '--quiet']) + expect(calls[1].args).toEqual(['volume', 'rm', 'forge-a-docker-data']) + }) + + test('removeSandbox tolerates a not-found failure', async () => { + const { runner } = recordingRunner((rec) => + rec.args[0] === 'rm' + ? { stdout: '', stderr: 'no such sandbox forge-a', exitCode: 1 } + : { stdout: '', stderr: '', exitCode: 0 }, + ) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.removeSandbox('forge-a')).resolves.toBeUndefined() + }) + + test('removeSandbox treats an already-removed docker data volume as success', async () => { + const { runner } = recordingRunner((rec) => + rec.args[0] === 'rm' + ? { stdout: '', stderr: '', exitCode: 0 } + : { stdout: '', stderr: 'volume forge-a-docker-data not found', exitCode: 1 }, + ) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.removeSandbox('forge-a')).resolves.toBeUndefined() + }) + + test('a docker data volume removal failure is logged but does not fail sandbox removal', async () => { + const log = vi.fn() + const rt = createMsbRuntime({ ...logger, log }, { + run: async (args) => + args[0] === 'rm' + ? { stdout: '', stderr: '', exitCode: 0 } + : { stdout: '', stderr: 'permission denied', exitCode: 1 }, + }) + await expect(rt.removeSandbox('forge-a')).resolves.toBeUndefined() + expect(log).toHaveBeenCalledWith(expect.stringContaining('failed to remove docker data volume')) + }) + + test('removeSandbox throws on an unexpected failure', async () => { + const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'permission denied', exitCode: 1 })) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.removeSandbox('forge-a')).rejects.toThrow('Failed to remove sandbox') + }) + + test('a failed sandbox removal leaves the docker data volume untouched', async () => { + const { calls, runner } = recordingRunner(() => ({ stdout: '', stderr: 'boom', exitCode: 1 })) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.removeSandbox('forge-a')).rejects.toThrow('Failed to remove sandbox') + expect(calls).toHaveLength(1) + expect(calls[0].args).toEqual(['rm', '--force', 'forge-a', '--quiet']) + }) + + test('getSandboxState reports running, reusable stopped, transient for known non-executable states, and missing for an absent name', async () => { + const stdout = JSON.stringify([ + { name: 'forge-a', status: 'Running' }, + { name: 'forge-b', status: 'Crashed' }, + { name: 'forge-c', status: 'Created' }, + { name: 'forge-d', status: 'Starting' }, + { name: 'forge-e', status: 'Draining' }, + { name: 'forge-f', status: 'Paused' }, + ]) + const { runner } = recordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.getSandboxState('forge-a')).resolves.toBe('running') + await expect(rt.getSandboxState('forge-b')).resolves.toBe('stopped') + // `Created`/`Starting`/`Draining`/`Paused` are real msb states, so they report transient + // rather than a query failure: the sandbox exists even though it is not directly executable. + await expect(rt.getSandboxState('forge-c')).resolves.toBe('transient') + await expect(rt.getSandboxState('forge-d')).resolves.toBe('transient') + await expect(rt.getSandboxState('forge-e')).resolves.toBe('transient') + await expect(rt.getSandboxState('forge-f')).resolves.toBe('transient') + await expect(rt.getSandboxState('forge-x')).resolves.toBe('missing') + }) + + test('getSandboxState reports unknown on a failing ls rather than claiming missing', async () => { + const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) + const rt = createMsbRuntime(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('msb exploded') }) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') + }) + + 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: '[{"name":"forge-', stderr: '', exitCode: 0 })) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') + }) + + test('getSandboxState reports unknown for valid JSON in a non-array shape', async () => { + const { runner } = recordingRunner(() => ({ stdout: '{"error":"x"}', stderr: '', exitCode: 0 })) + const rt = createMsbRuntime(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: '[]', stderr: '', exitCode: 0 })) + const rt = createMsbRuntime(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 rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.getSandboxState('forge-a')).resolves.toBe('missing') + }) + + test('listSandboxesByPrefix filters parsed names by prefix', async () => { + const stdout = JSON.stringify([ + { name: 'forge-a', status: 'Running' }, + { name: 'forge-b', status: 'Stopped' }, + { name: 'other', status: 'Running' }, + ]) + const { runner } = recordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const rt = createMsbRuntime(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 rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.listSandboxesByPrefix('forge-')).resolves.toEqual([]) + }) + + function inspectStdout(bound: string[]): string { + return JSON.stringify({ + name: 'forge-c', + status: 'Running', + config: { + network: { + secrets: bound.length === 0 + ? undefined + : { secrets: bound.map((envVar) => ({ env_var: envVar })) }, + }, + }, + }) + } + + function refreshRunner(bound: string[]) { + return recordingRunner((rec) => + rec.args[0] === 'inspect' + ? { stdout: inspectStdout(bound), stderr: '', exitCode: 0 } + : { stdout: '', stderr: '', exitCode: 0 }, + ) + } + + test('refreshSandboxSecrets reads the bound set and converges msb modify to the desired set', async () => { + const { calls, runner } = refreshRunner(['STALE_TOKEN', 'KEEP']) + const rt = createMsbRuntime(logger, { run: runner }) + const ok = await rt.refreshSandboxSecrets('forge-c', [ + { env: 'KEEP', hosts: ['api.example.com'] }, + { env: 'NPM_TOKEN', hosts: ['registry.npmjs.org'] }, + ]) + expect(ok).toBe(true) + expect(calls[0].args).toEqual(['inspect', 'forge-c', '--format', 'json']) + expect(calls[0].opts?.timeout).toBe(30000) + // NPM_TOKEN is a new placeholder, so the modification is restart-backed. + expect(calls[1].args).toEqual([ + 'modify', + 'forge-c', + '--restart', + '--secret-rm', + 'STALE_TOKEN', + '--secret', + 'KEEP@api.example.com', + '--secret', + 'NPM_TOKEN@registry.npmjs.org', + ]) + // A restart-backed modify (--restart restarts the VM) gets the long default timeout. + expect(calls[1].opts?.timeout).toBe(MSB_DEFAULT_TIMEOUT) + }) + + test('refreshSandboxSecrets adds --restart only when introducing a new secret name', async () => { + // Rotation of an existing name stays restart-free (msb applies it live). + const rotation = refreshRunner(['KEEP']) + const rt = createMsbRuntime(logger, { run: rotation.runner }) + await expect( + rt.refreshSandboxSecrets('forge-c', [{ env: 'KEEP', hosts: ['api.example.com'] }]), + ).resolves.toBe(true) + expect(rotation.calls[1].args).toEqual(['modify', 'forge-c', '--secret', 'KEEP@api.example.com']) + // A live (restart-free) modify keeps the short query timeout. + expect(rotation.calls[1].opts?.timeout).toBe(30000) + + // A new name is a placeholder addition, which msb classifies as restart-required. + const added = refreshRunner(['KEEP']) + const rt2 = createMsbRuntime(logger, { run: added.runner }) + await expect( + rt2.refreshSandboxSecrets('forge-c', [ + { env: 'KEEP', hosts: ['api.example.com'] }, + { env: 'NEW_TOKEN', hosts: ['api.example.com'] }, + ]), + ).resolves.toBe(true) + expect(added.calls[1].args).toEqual([ + 'modify', + 'forge-c', + '--restart', + '--secret', + 'KEEP@api.example.com', + '--secret', + 'NEW_TOKEN@api.example.com', + ]) + }) + + test('refreshSandboxSecrets removes every bound secret when the desired list is empty', async () => { + const { calls, runner } = refreshRunner(['A_TOKEN', 'B_TOKEN']) + const rt = createMsbRuntime(logger, { run: runner }) + const ok = await rt.refreshSandboxSecrets('forge-c', []) + expect(ok).toBe(true) + // Removals apply live, so no --restart is needed. + expect(calls[1].args).toEqual([ + 'modify', + 'forge-c', + '--secret-rm', + 'A_TOKEN', + '--secret-rm', + 'B_TOKEN', + ]) + expect(calls[1].args).not.toContain('--restart') + }) + + test('refreshSandboxSecrets re-issues desired secrets so a rotated host value is re-read', async () => { + const { calls, runner } = refreshRunner(['KEEP']) + const rt = createMsbRuntime(logger, { run: runner }) + const ok = await rt.refreshSandboxSecrets('forge-c', [ + { env: 'KEEP', hosts: ['api.example.com'] }, + ]) + expect(ok).toBe(true) + // A matching name is not skipped: `--secret` re-binds it, picking up any rotated host value. + expect(calls).toHaveLength(2) + expect(calls[0].args[0]).toBe('inspect') + expect(calls[1].args).toEqual(['modify', 'forge-c', '--secret', 'KEEP@api.example.com']) + }) + + test('refreshSandboxSecrets with an empty list and nothing bound never invokes msb modify', async () => { + const { calls, runner } = refreshRunner([]) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.refreshSandboxSecrets('forge-c', [])).resolves.toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0].args[0]).toBe('inspect') + expect(calls.filter((c) => c.args[0] === 'modify')).toHaveLength(0) + }) + + test('refreshSandboxSecrets never inlines a secret value into any argument vector', async () => { + const { calls, runner } = refreshRunner([]) + const rt = createMsbRuntime(logger, { run: runner }) + await rt.refreshSandboxSecrets('forge-c', [{ env: 'GITHUB_TOKEN', hosts: ['api.github.com'] }]) + for (const call of calls) { + expect(call.args.join(' ')).not.toContain('GITHUB_TOKEN=') + expect(call.args.join(' ')).not.toContain('=') + } + }) + + test('refreshSandboxSecrets returns false when the inspect query fails or is unparseable', async () => { + const failing = recordingRunner(() => ({ stdout: '', stderr: 'boom', exitCode: 1 })) + const rt = createMsbRuntime(logger, { run: failing.runner }) + await expect( + rt.refreshSandboxSecrets('forge-c', [{ env: 'T', hosts: ['h'] }]), + ).resolves.toBe(false) + + const malformed = recordingRunner(() => ({ stdout: 'not json', stderr: '', exitCode: 0 })) + const rt2 = createMsbRuntime(logger, { run: malformed.runner }) + await expect( + rt2.refreshSandboxSecrets('forge-c', [{ env: 'T', hosts: ['h'] }]), + ).resolves.toBe(false) + }) + + test('refreshSandboxSecrets returns false when the runner throws', async () => { + const { runner } = recordingRunner(() => { throw new Error('msb exploded') }) + const rt = createMsbRuntime(logger, { run: runner }) + await expect( + rt.refreshSandboxSecrets('forge-c', [{ env: 'T', hosts: ['h'] }]), + ).resolves.toBe(false) + }) + + test('refreshSandboxSecrets skips misconfigured secrets like the create builder', async () => { + const { calls, runner } = refreshRunner([]) + const rt = createMsbRuntime(logger, { run: runner }) + const ok = await rt.refreshSandboxSecrets('forge-c', [ + { env: 'KEEP', hosts: ['api.example.com'] }, + { env: ' ', hosts: ['api.example.com'] }, + { env: 'GITHUB_TOKEN=super-secret', hosts: ['api.github.com'] }, + { env: 'NO_HOSTS', hosts: [] }, + ]) + expect(ok).toBe(true) + // KEEP is a new name (nothing was bound), so the introduction is restart-backed. + expect(calls[1].args).toEqual(['modify', 'forge-c', '--restart', '--secret', 'KEEP@api.example.com']) + expect(calls[1].args.join(' ')).not.toContain('super-secret') + }) + + test('templateExists matches parsed image references', async () => { + const stdout = JSON.stringify([{ reference: 'docker.io/library/oc-forge-sandbox:latest' }]) + const { runner } = recordingRunner(() => ({ stdout, stderr: '', exitCode: 0 })) + const rt = createMsbRuntime(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('templateExists returns false on a non-zero exit and on a throw', async () => { + const failing = recordingRunner(() => ({ stdout: '', stderr: 'err', exitCode: 1 })) + const rt = createMsbRuntime(logger, { run: failing.runner }) + await expect(rt.templateExists('oc-forge-sandbox:latest')).resolves.toBe(false) + + const throwing = recordingRunner(() => { throw new Error('boom') }) + const rt2 = createMsbRuntime(logger, { run: throwing.runner }) + await expect(rt2.templateExists('oc-forge-sandbox:latest')).resolves.toBe(false) + }) + + test('loadTemplate records the input and tag flags with the load timeout', async () => { + const { calls, runner } = recordingRunner() + const rt = createMsbRuntime(logger, { run: runner }) + await rt.loadTemplate('/tmp/x.tar', 'oc-forge-sandbox:latest') + expect(calls[0].args).toEqual(['load', '--input', '/tmp/x.tar', '--tag', 'oc-forge-sandbox:latest', '--quiet']) + expect(calls[0].opts?.timeout).toBe(600000) + }) + + test('loadTemplate throws on non-zero exit with stderr', async () => { + const { runner } = recordingRunner(() => ({ stdout: '', stderr: 'bad tar', exitCode: 1 })) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.loadTemplate('/tmp/x.tar', 'oc-forge-sandbox:latest')).rejects.toThrow( + 'Failed to load sandbox template: bad tar', + ) + }) + + test('checkAvailable proxies to checkMsbAvailability', async () => { + const { runner } = recordingRunner(() => ({ stdout: 'ok\n', stderr: '', exitCode: 0 })) + const rt = createMsbRuntime(logger, { run: runner }) + await expect(rt.checkAvailable()).resolves.toEqual({ available: true }) + }) + + test('sandboxContainerName is exposed on the runtime', () => { + const rt = createMsbRuntime(logger, { run: async () => ({ stdout: '', stderr: '', exitCode: 0 }) }) + expect(rt.sandboxContainerName('feature/test')).toBe('forge-feature-test') + }) + + test('the exported runtime interface has no execPipe or allowNetworkHost member', () => { + const rt = createMsbRuntime(logger, { run: async () => ({ stdout: '', stderr: '', exitCode: 0 }) }) + expect('execPipe' in rt).toBe(false) + expect('allowNetworkHost' in rt).toBe(false) + const contract: SandboxRuntime = rt + expect(contract.exec).toBeInstanceOf(Function) + }) +}) diff --git a/test/sandbox/sbx-runtime.test.ts b/test/sandbox/sbx-runtime.test.ts deleted file mode 100644 index e5385942f..000000000 --- a/test/sandbox/sbx-runtime.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { describe, test, expect, vi } from 'vitest' -import { - sanitizeSbxName, - sandboxContainerName, - buildSbxExecArgs, - parseSbxCpus, - normalizeSbxMemory, - buildSbxCreateArgs, - parseSbxSandboxList, - parseSbxTemplateList, - sbxTemplateMatches, - checkSbxAvailability, - describeSbxUnavailable, - createSbxRuntime, -} from '../../src/sandbox/sbx' -import type { CommandRunner } from '../../src/sandbox/sbx' -import { COMMAND_TIMEOUT_EXIT_CODE } from '../../src/sandbox/process' -import type { Logger } from '../../src/types' - -const logger: Logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } - -describe('sandbox name', () => { - test('replaces disallowed characters like the old Docker driver rejected slashes', () => { - expect(sandboxContainerName('feature/test-123')).toBe('forge-feature-test-123') - }) - - test('passes an already-sanitized loop name through unchanged', () => { - expect(sandboxContainerName('my-worktree')).toBe('forge-my-worktree') - }) - - test('empty input yields forge-sandbox', () => { - expect(sandboxContainerName('')).toBe('forge-sandbox') - }) - - test('truncates a long loop name to <= 66 chars with no trailing hyphen', () => { - const long = 'a'.repeat(120) - const name = sandboxContainerName(long) - expect(name.length).toBeLessThanOrEqual(66) - expect(name.endsWith('-')).toBe(false) - expect(name.startsWith('forge-')).toBe(true) - }) - - test('collapses runs of disallowed characters and strips edge separators', () => { - expect(sanitizeSbxName(' My_Work! ')).toBe('my-work') - }) -}) - -describe('exec args', () => { - test('emits the minimal exec vector', () => { - expect(buildSbxExecArgs('forge-c', 'ls')).toEqual(['exec', 'forge-c', 'sh', '-c', 'ls']) - }) - - test('emits each flag only when its option is set', () => { - expect(buildSbxExecArgs('forge-c', 'ls', { interactive: true, envFile: '/e.env', workdir: '/w' })).toEqual([ - 'exec', - '-i', - '--env-file', - '/e.env', - '-w', - '/w', - 'forge-c', - 'sh', - '-c', - 'ls', - ]) - }) - - test('omits flags whose option is undefined or empty', () => { - expect(buildSbxExecArgs('forge-c', 'ls', { user: '', envFile: '', workdir: '' })).toEqual([ - 'exec', - 'forge-c', - 'sh', - '-c', - 'ls', - ]) - }) - - test('includes -u when a user is given', () => { - expect(buildSbxExecArgs('forge-c', 'ls', { user: '1000:1000' })).toEqual([ - 'exec', - '-u', - '1000:1000', - 'forge-c', - 'sh', - '-c', - 'ls', - ]) - }) -}) - -describe('create args', () => { - test('emits the base vector for one read-write workspace', () => { - expect(buildSbxCreateArgs('forge-c', [{ hostDir: '/work' }])).toEqual([ - 'create', - 'shell', - '--quiet', - '--name', - 'forge-c', - '/work', - ]) - }) - - test('suffixes read-only workspaces with :ro and leaves read-write bare', () => { - expect( - buildSbxCreateArgs('forge-c', [{ hostDir: '/work' }, { hostDir: '/proj', readOnly: true }]), - ).toEqual([ - 'create', - 'shell', - '--quiet', - '--name', - 'forge-c', - '/work', - '/proj:ro', - ]) - }) - - test('includes template, memory and cpus flags when present', () => { - expect( - buildSbxCreateArgs('forge-c', [{ hostDir: '/work' }], { template: 't1', memory: '8g', cpus: 4 }), - ).toEqual([ - 'create', - 'shell', - '--quiet', - '--name', - 'forge-c', - '--template', - 't1', - '--memory', - '8g', - '--cpus', - '4', - '/work', - ]) - }) - - test('throws on an empty workspace array', () => { - expect(() => buildSbxCreateArgs('forge-c', [])).toThrow('requires at least one workspace') - }) -}) - -describe('resource coercion', () => { - test('parseSbxCpus returns the floored integer for whole and fractional input', () => { - expect(parseSbxCpus('4', logger)).toBe(4) - expect(parseSbxCpus('2.5', logger)).toBe(2) - }) - - test('parseSbxCpus logs when rounding a fractional value', () => { - const log = vi.fn() - parseSbxCpus('2.5', { ...logger, log }) - expect(log).toHaveBeenCalledWith( - 'Sandbox: sbx --cpus is integer-only; rounding cpus="2.5" down to 2', - ) - }) - - test('parseSbxCpus returns undefined for non-numeric input', () => { - expect(parseSbxCpus('abc', logger)).toBeUndefined() - expect(parseSbxCpus(undefined, logger)).toBeUndefined() - }) - - test('normalizeSbxMemory passes lowercased value without trailing b', () => { - expect(normalizeSbxMemory('8g', logger)).toBe('8g') - expect(normalizeSbxMemory('8GB', logger)).toBe('8g') - expect(normalizeSbxMemory('1024m', logger)).toBe('1024m') - }) - - test('normalizeSbxMemory returns undefined for unrecognized input', () => { - expect(normalizeSbxMemory('lots', logger)).toBeUndefined() - expect(normalizeSbxMemory(undefined, logger)).toBeUndefined() - }) -}) - -describe('sandbox list', () => { - test('empty output yields an empty array without throwing', () => { - expect(parseSbxSandboxList('')).toEqual([]) - }) - - test('malformed JSON yields an empty array without throwing', () => { - expect(parseSbxSandboxList('not json {')).toEqual([]) - }) - - test('the observed {"sandboxes":[]} shape yields an empty array', () => { - expect(parseSbxSandboxList('{"sandboxes":[]}')).toEqual([]) - }) - - test('maps running status to true and stopped to false', () => { - const stdout = JSON.stringify({ - sandboxes: [ - { name: 'forge-a', status: 'running' }, - { name: 'forge-b', status: 'stopped' }, - ], - }) - expect(parseSbxSandboxList(stdout)).toEqual([ - { name: 'forge-a', status: 'running', running: true }, - { name: 'forge-b', status: 'stopped', running: false }, - ]) - }) - - test('accepts a bare array fallback', () => { - const stdout = JSON.stringify([ - { name: 'forge-a', status: 'running' }, - { name: 'forge-b', status: 'stopped' }, - ]) - expect(parseSbxSandboxList(stdout)).toEqual([ - { name: 'forge-a', status: 'running', running: true }, - { name: 'forge-b', status: 'stopped', running: false }, - ]) - }) - - test('treats exited, creating, empty and missing status as not running', () => { - expect(parseSbxSandboxList(JSON.stringify([{ name: 'a', status: 'exited' }]))[0].running).toBe(false) - expect(parseSbxSandboxList(JSON.stringify([{ name: 'a', status: 'creating' }]))[0].running).toBe(false) - expect(parseSbxSandboxList(JSON.stringify([{ name: 'a', status: '' }]))[0].running).toBe(false) - expect(parseSbxSandboxList(JSON.stringify([{ name: 'a' }]))[0].running).toBe(false) - }) - - test('drops entries without a non-empty name', () => { - const stdout = JSON.stringify([{ name: '', status: 'running' }, { status: 'running' }]) - expect(parseSbxSandboxList(stdout)).toEqual([]) - }) -}) - -describe('template list', () => { - const fixture = [ - 'REPOSITORY TAG IMAGE ID FLAVOR CREATED', - 'docker.io/docker/sandbox-templates shell-docker e0c0544e2109 shell-docker 3 months ago', - 'oc-forge-sandbox latest a1b2c3d4e5f6 shell-docker 3 weeks ago', - ].join('\n') - - const registryFixture = [ - 'REPOSITORY TAG IMAGE ID FLAVOR CREATED', - 'docker.io/library/oc-forge-sandbox latest a1b2c3d4e5f6 shell-docker 3 weeks ago', - ].join('\n') - - test('never emits the header row as an entry', () => { - const entries = parseSbxTemplateList(fixture) - expect(entries).not.toEqual(expect.arrayContaining([{ repository: 'REPOSITORY', tag: 'TAG' }])) - expect(entries).toHaveLength(2) - }) - - test('matches a bare repository with an explicit matching tag', () => { - expect(sbxTemplateMatches(parseSbxTemplateList(fixture), 'oc-forge-sandbox:latest')).toBe(true) - }) - - test('matches a registry-qualified repository ending in /name', () => { - expect(sbxTemplateMatches(parseSbxTemplateList(registryFixture), 'oc-forge-sandbox:latest')).toBe( - true, - ) - }) - - test('does not match a different tag', () => { - expect(sbxTemplateMatches(parseSbxTemplateList(fixture), 'oc-forge-sandbox:other')).toBe(false) - }) - - test('treats a ref with no explicit tag as :latest', () => { - expect(sbxTemplateMatches(parseSbxTemplateList(fixture), 'oc-forge-sandbox')).toBe(true) - }) - - test('skips blank lines and lines with fewer than two fields', () => { - const entries = parseSbxTemplateList('oc-forge-sandbox latest\n\nREPOSITORY TAG\n') - expect(entries).toEqual([{ repository: 'oc-forge-sandbox', tag: 'latest' }]) - }) -}) - -describe('availability', () => { - test('a running daemon yields available', async () => { - const fake: CommandRunner = async () => ({ stdout: 'Status: running\n', stderr: '', exitCode: 0 }) - await expect(checkSbxAvailability(fake)).resolves.toEqual({ available: true }) - }) - - test('a missing CLI yields not-installed from an ENOENT spawn', async () => { - const fake: CommandRunner = async () => ({ stdout: '', stderr: 'spawn sbx ENOENT', exitCode: 1 }) - await expect(checkSbxAvailability(fake)).resolves.toEqual({ - available: false, - reason: 'not-installed', - }) - }) - - test('a stopped daemon yields daemon-down with trimmed detail', async () => { - const fake: CommandRunner = async () => ({ - stdout: '', - stderr: 'daemon not running\n', - exitCode: 1, - }) - const result = await checkSbxAvailability(fake) - expect(result).toMatchObject({ available: false, reason: 'daemon-down' }) - if (!result.available) expect(result.detail).toBe('daemon not running') - }) - - test('a rejecting runner yields unknown', async () => { - const fake: CommandRunner = async () => { - throw new Error('boom') - } - await expect(checkSbxAvailability(fake)).resolves.toEqual({ available: false, reason: 'unknown' }) - }) - - test('a timed-out probe yields unknown, not daemon-down', async () => { - // A busy daemon (other loops mid-exec) can blow the query bound while running perfectly well; - // reporting daemon-down there tells the user to start a daemon that is already up. - const fake: CommandRunner = async () => ({ - stdout: '', - stderr: '', - exitCode: COMMAND_TIMEOUT_EXIT_CODE, - }) - const result = await checkSbxAvailability(fake) - expect(result).toMatchObject({ available: false, reason: 'unknown' }) - if (!result.available) expect(result.detail).toMatch(/did not answer/) - }) - - test('passes a 30000ms timeout to the runner', async () => { - const optsSeen: Array<{ timeout?: number }> = [] - const fake: CommandRunner = async (_args, opts) => { - optsSeen.push(opts ?? {}) - return { stdout: 'Status: running\n', stderr: '', exitCode: 0 } - } - await checkSbxAvailability(fake) - expect(optsSeen[0]?.timeout).toBe(30000) - }) - - test('describeSbxUnavailable carries the remediation strings', () => { - expect(describeSbxUnavailable({ available: false, reason: 'not-installed' })).toMatch(/sbx login/) - expect(describeSbxUnavailable({ available: false, reason: 'daemon-down' })).toMatch(/sbx daemon start/) - expect(describeSbxUnavailable({ available: false, reason: 'unknown', detail: 'x' })).toMatch(/x/) - }) -}) - -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 } - } - - test('exec with cwd prefixes the command with cd and records args', async () => { - const { calls, runner } = recordingRunner() - const rt = createSbxRuntime(logger, { run: runner }) - await rt.exec('forge-c', 'ls', { cwd: "/w/it's" }) - expect(calls[0].args).toEqual([ - 'exec', - 'forge-c', - 'sh', - '-c', - "cd '/w/it'\\''s' && ls", - ]) - }) - - test('exec passes envFile and timeout to the runner', async () => { - const { calls, runner } = recordingRunner() - const rt = createSbxRuntime(logger, { run: runner }) - await rt.exec('forge-c', 'ls', { envFile: '/e.env', timeout: 3000 }) - expect(calls[0].args).toContain('--env-file') - expect(calls[0].opts?.timeout).toBe(3000) - }) - - test('execPipe sets interactive and passes stdin through', async () => { - const { calls, runner } = recordingRunner() - 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']) - expect(calls[0].opts?.stdin).toBe('hello') - }) - - test('createSandbox builds create args with coerced resources', async () => { - const { calls, runner } = recordingRunner() - const rt = createSbxRuntime(logger, { run: runner }) - await rt.createSandbox('forge-c', [{ hostDir: '/work' }], { - template: 't1', - resources: { memory: '8GB', cpus: '2.5' }, - }) - expect(calls[0].args).toEqual([ - 'create', 'shell', '--quiet', '--name', 'forge-c', - '--template', 't1', '--memory', '8g', '--cpus', '2', '/work', - ]) - expect(calls[0].opts?.timeout).toBe(120000) - }) - - test('createSandbox throws on non-zero exit', async () => { - const { runner } = recordingRunner(() => ({ 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', - ) - }) - - test('removeSandbox records rm --force', async () => { - const { calls, runner } = recordingRunner() - 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 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 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 rt = createSbxRuntime(logger, { run: runner }) - await expect(rt.getSandboxState('forge-a')).resolves.toBe('running') - await expect(rt.getSandboxState('forge-b')).resolves.toBe('missing') - }) - - 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 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 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 rt = createSbxRuntime(logger, { run: runner }) - await expect(rt.getSandboxState('forge-a')).resolves.toBe('unknown') - }) - - 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 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 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 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 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 rt = createSbxRuntime(logger, { run: runner }) - await expect(rt.getSandboxState('forge-a')).resolves.toBe('missing') - }) - - test('listSandboxesByPrefix filters parsed names by prefix', async () => { - const stdout = JSON.stringify({ - sandboxes: [ - { name: 'forge-a', status: 'running' }, - { name: 'forge-b', status: 'stopped' }, - { name: 'other', status: 'running' }, - ], - }) - const { runner } = recordingRunner(() => ({ 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 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 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 rt = createSbxRuntime(logger, { run: runner }) - await expect(rt.loadTemplate('/tmp/t.tar')).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 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 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 rt = createSbxRuntime(logger, { run: runner }) - await expect(rt.allowNetworkHost('db.internal')).resolves.toBe(true) - }) - - test('sandboxContainerName is exposed on the runtime', () => { - const rt = createSbxRuntime(logger, { run: async () => ({ stdout: '', stderr: '', exitCode: 0 }) }) - expect(rt.sandboxContainerName('feature/test')).toBe('forge-feature-test') - }) -}) diff --git a/test/sandbox/session-controller.test.ts b/test/sandbox/session-controller.test.ts index b4e89b11d..030954f2a 100644 --- a/test/sandbox/session-controller.test.ts +++ b/test/sandbox/session-controller.test.ts @@ -167,7 +167,7 @@ describe('SessionSandboxController', () => { test('failed start is acknowledged as OFF with an error and exposes no host fallback', async () => { repo.setDesired(PROJECT, makeDesired({ revision: 'r-fail' })) manager.setEnsureRunningImpl(async () => { - throw new Error('sbx daemon is not running') + throw new Error('msb create failed') }) const controller = createController() await controller.start() @@ -175,11 +175,11 @@ describe('SessionSandboxController', () => { const applied = repo.getApplied(PROJECT) expect(applied?.revision).toBe('r-fail') expect(applied?.enabled).toBe(false) - expect(applied?.error).toMatch(/sbx daemon is not running/) + expect(applied?.error).toMatch(/msb create failed/) // A failed start must never expose a host fallback for the selected session: resolution // fails closed (throws) rather than returning null (which hooks treat as host permission). - await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/sbx daemon is not running/) + await expect(controller.resolveSandboxForSession(ROOT_SESSION)).rejects.toThrow(/msb create failed/) await controller.dispose() }) @@ -300,7 +300,7 @@ describe('SessionSandboxController', () => { test('persisted-ON restore that partially creates the container cleans up before acknowledging OFF', async () => { // A prior run left applied ON at the same revision as desired. On restart the restore // validation must run deterministic-key cleanup if ensureRunning creates the container and - // then fails (e.g. env-file generation), so the partially-created container is not leaked + // then fails (e.g. create-time secret binding), so the partially-created container is not leaked // while OFF-with-error is acknowledged. repo.setDesired(PROJECT, makeDesired({ revision: 'r-restore-partial' })) repo.setApplied(PROJECT, { @@ -1434,6 +1434,116 @@ describe('SessionSandboxController', () => { } }) + test('a repeatedly failing OFF-with-error removal backs off exponentially and resets on success', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-removal-backoff', enabled: false, sessionId: ROOT_SESSION })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-removal-backoff', + enabled: false, + sessionId: ROOT_SESSION, + error: 'transient removal failure', + appliedAt: Date.now(), + }) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + // Initial reconcile attempts the removal once and fails, retaining ownership. + expect(manager.stopCalls).toHaveLength(1) + + // The second attempt at +20 (base interval) fails; the retry delay doubles to 40. + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + + // Consecutive failures must NOT re-attempt at the base interval: 20ms after the second + // failure (t=40) nothing runs, and the third attempt only fires at +40 (t=60). + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(3) + + // Removal succeeds; the backoff resets so the next poll returns to the base interval. + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(3) + await vi.advanceTimersByTimeAsync(60) + expect(manager.stopCalls).toHaveLength(4) + const applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + + // A fresh failing removal after the success retries at the base interval (20), not at the + // previously-backed-off delay: the retry fires 20ms after the new state appears. + repo.setDesired(PROJECT, makeDesired({ revision: 'r-removal-backoff-2', enabled: false, sessionId: ROOT_SESSION })) + repo.setApplied(PROJECT, { + version: 1, + revision: 'r-removal-backoff-2', + enabled: false, + sessionId: ROOT_SESSION, + error: 'transient removal failure again', + appliedAt: Date.now(), + }) + stopFails = true + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(5) + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + test('the first ON-to-OFF stop failure initializes the retry schedule and the second failure doubles it', async () => { + vi.useFakeTimers() + try { + repo.setDesired(PROJECT, makeDesired({ revision: 'r-on-to-off-up', sessionId: ROOT_SESSION })) + const controller = createController({ pollIntervalMs: 20 }) + await controller.start() + expect(repo.getApplied(PROJECT)?.enabled).toBe(true) + + repo.setDesired(PROJECT, makeDesired({ revision: 'r-on-to-off', enabled: false, sessionId: ROOT_SESSION })) + let stopFails = true + manager.stop = async (key) => { + manager.stopCalls.push(key) + if (stopFails) throw new Error('transient removal failure') + manager.active = null + } + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(1) + let applied = repo.getApplied(PROJECT) + expect(applied?.revision).toBe('r-on-to-off') + expect(applied?.enabled).toBe(false) + expect(applied?.error).toMatch(/transient removal failure/) + + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(2) + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(3) + + stopFails = false + await vi.advanceTimersByTimeAsync(20) + expect(manager.stopCalls).toHaveLength(3) + await vi.advanceTimersByTimeAsync(60) + expect(manager.stopCalls).toHaveLength(4) + applied = repo.getApplied(PROJECT) + expect(applied?.enabled).toBe(false) + expect(applied?.error).toBeNull() + + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + test('a failed stop during absent-desired teardown is retried until removal succeeds', async () => { vi.useFakeTimers() try { @@ -1695,7 +1805,7 @@ describe('SessionSandboxController', () => { } }) - test('an ON request without a session is acknowledged OFF-with-error and never starts SBX', async () => { + test('an ON request without a session is acknowledged OFF-with-error and never starts MSB', async () => { repo.setDesired(PROJECT, makeDesired({ revision: 'r-null-session', enabled: true, sessionId: null })) const controller = createController() await controller.start() @@ -1789,9 +1899,9 @@ describe('SessionSandboxController', () => { vi.useFakeTimers() try { repo.setDesired(PROJECT, makeDesired({ revision: 'r-adopt', sessionId: ROOT_SESSION })) - // The first start creates the container and then fails (env-file setup). A subsequent - // ensureRunning would now SUCCEED: without a pending-cleanup guard the next reconcile tick - // would adopt the partially-initialized container and wrongly acknowledge ON. + // The first start creates the container and then fails (create-time secret binding). A + // subsequent ensureRunning would now SUCCEED: without a pending-cleanup guard the next + // reconcile tick would adopt the partially-initialized container and wrongly acknowledge ON. let startCount = 0 manager.setEnsureRunningImpl(async (key, dir) => { startCount++ @@ -1844,8 +1954,8 @@ describe('SessionSandboxController', () => { expect(repo.getApplied(PROJECT)?.enabled).toBe(true) expect(await controller.resolveSandboxForSession(ROOT_SESSION)).not.toBeNull() - // The acknowledged container dies; recovery recreates it but fails during env-file setup, - // leaving a partially-created container and a stale applied-ON row. + // The acknowledged container dies; recovery recreates it but fails during create-time secret + // binding, leaving a partially-created container and a stale applied-ON row. manager.setEnsureRunningImpl(async (key, dir) => { manager.active = { containerName: `forge-${key}`, projectDir: dir, startedAt: new Date().toISOString(), mounts: [] } throw new Error('env setup failed during restore') diff --git a/test/sandbox/shell-shim.test.ts b/test/sandbox/shell-shim.test.ts index e5830884b..43632b659 100644 --- a/test/sandbox/shell-shim.test.ts +++ b/test/sandbox/shell-shim.test.ts @@ -9,7 +9,6 @@ import { resolveHostShell, SHELL_SHIM_FILENAME, SHIM_ENV_CONTAINER, - SHIM_ENV_ENV_FILE, SHIM_ENV_HOST_SHELL, } from '../../src/sandbox/shell-shim' import type { Logger } from '../../src/types' @@ -19,7 +18,6 @@ const logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Lo function cleanEnv(): NodeJS.ProcessEnv { const env = { ...process.env } delete env[SHIM_ENV_CONTAINER] - delete env[SHIM_ENV_ENV_FILE] delete env[SHIM_ENV_HOST_SHELL] return env } @@ -81,12 +79,12 @@ describe('shim behavior (executed via sh)', () => { expect(result.stdout.trim()).toBe('ok') }) - test('fail-closed: when a container is set but sbx is unavailable, the command never runs on the host', () => { + test('fail-closed: when a container is set but msb 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'), { mode: 0o755 }) const marker = join(dir, 'escaped') - // Empty PATH: `sbx` cannot be found, so exec fails. The shim must exit + // Empty PATH: `msb` cannot be found, so exec fails. The shim must exit // non-zero without falling through to the host shell. const result = spawnSync(shim, ['-c', `touch ${marker}`], { env: { ...cleanEnv(), PATH: dir, [SHIM_ENV_CONTAINER]: 'forge-some-loop' }, @@ -97,70 +95,50 @@ describe('shim behavior (executed via sh)', () => { expect(existsSync(marker)).toBe(false) }) - test('routes into sbx exec with cwd, container, and command when container env is set', () => { + test('routes into msb exec with container, flags, 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'), { mode: 0o755 }) - // Fake sbx on PATH that records its argv. const binDir = join(dir, 'bin') mkdirSync(binDir) - const argsFile = join(dir, 'sbx-args') - writeFileSync(join(binDir, 'sbx'), `#!/bin/sh\nprintf '%s\\n' "$@" > ${argsFile}\n`, { mode: 0o755 }) + const argsFile = join(dir, 'msb-args') + writeFileSync(join(binDir, 'msb'), `#!/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', - }, + env: { ...cleanEnv(), PATH: [binDir, process.env.PATH ?? ''].join(':'), [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([ - 'exec', - '--env-file', - '/data/forge/sandbox-env/forge-loop-x.env', - '-w', - realpathSync(cwd), - 'forge-loop-x', - 'bash', - '-c', - 'echo in-container', - ]) + expect(argv).toEqual(['exec', '--quiet', 'forge-loop-x', '--no-tty', '-w', realpathSync(cwd), '--', 'bash', '-c', 'echo in-container']) }) - test('routes into sbx exec without --env-file when no env file is set', () => { + test('propagates a non-zero msb exit verbatim', () => { const dir = mkdtempSync(join(tmpdir(), 'forge-shim-')) const shim = join(dir, SHELL_SHIM_FILENAME) writeFileSync(shim, buildShimScript('/bin/sh'), { mode: 0o755 }) const binDir = join(dir, 'bin') mkdirSync(binDir) - const argsFile = join(dir, 'sbx-args') - writeFileSync(join(binDir, 'sbx'), `#!/bin/sh\nprintf '%s\\n' "$@" > ${argsFile}\n`, { mode: 0o755 }) + writeFileSync(join(binDir, 'msb'), `#!/bin/sh\nprintf '%s\\n' 'some unrelated failure' >&2\nexit 7\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' }, + const result = spawnSync(shim, ['-c', 'echo should-not-run'], { + env: { ...cleanEnv(), PATH: [binDir, process.env.PATH ?? ''].join(':'), [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(['exec', '-w', realpathSync(cwd), 'forge-loop-x', 'bash', '-c', 'echo in-container']) + expect(result.status).toBe(7) + expect(result.stderr).toContain('some unrelated failure') }) }) describe('shim content', () => { - test('routes through sbx exec with no docker and no --user', () => { + test('routes through msb exec with no docker and no --user', () => { const script = buildShimScript('/bin/sh') - expect(script).toContain('sbx exec -w "$PWD"') - expect(script).toContain('--env-file "$FORGE_SANDBOX_ENV_FILE"') + expect(script).toContain('msb exec --quiet "$FORGE_SANDBOX_CONTAINER" --no-tty -w "$PWD" -- bash "$@"') + expect(script).not.toContain('sbx') expect(script).not.toContain('docker') expect(script).not.toContain('--user') expect(script).toContain('exec "${FORGE_HOST_SHELL:-/bin/sh}" "$@"') diff --git a/test/sandbox/template.test.ts b/test/sandbox/template.test.ts index 403cf2dae..eb2638767 100644 --- a/test/sandbox/template.test.ts +++ b/test/sandbox/template.test.ts @@ -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 }) @@ -116,7 +117,7 @@ describe('buildAndLoadSandboxTemplate', () => { } await expect(buildAndLoadSandboxTemplate('/ctx', 't', deps)).rejects.toThrow( - /Docker CLI not found\. Building the sandbox template requires Docker; the sbx runtime itself does not\./, + /Docker CLI not found\. Building the sandbox template requires Docker; the msb runtime itself does not\./, ) } finally { rmSync(tmp, { recursive: true, force: true }) @@ -174,13 +175,13 @@ describe('template build args and command formatter', () => { test('formatTemplateBuildCommands reflects default args', () => { expect(formatTemplateBuildCommands('/ctx', 'oc-forge-sandbox:latest')).toBe( - 'docker build -t oc-forge-sandbox:latest "/ctx" && docker save oc-forge-sandbox:latest -o <tar> && sbx template load <tar>', + 'docker build -t oc-forge-sandbox:latest "/ctx" && docker save oc-forge-sandbox:latest -o <tar> && msb load --input <tar> --tag oc-forge-sandbox:latest', ) }) test('formatTemplateBuildCommands reflects the browser-control build arg', () => { expect(formatTemplateBuildCommands('/ctx', 'oc-forge-sandbox:latest', { browserControl: true })).toBe( - 'docker build --build-arg INSTALL_BROWSER_CONTROL=true -t oc-forge-sandbox:latest "/ctx" && docker save oc-forge-sandbox:latest -o <tar> && sbx template load <tar>', + 'docker build --build-arg INSTALL_BROWSER_CONTROL=true -t oc-forge-sandbox:latest "/ctx" && docker save oc-forge-sandbox:latest -o <tar> && msb load --input <tar> --tag oc-forge-sandbox:latest', ) }) }) diff --git a/test/scripts/cleanup-loop.test.ts b/test/scripts/cleanup-loop.test.ts new file mode 100644 index 000000000..be9a0f1b0 --- /dev/null +++ b/test/scripts/cleanup-loop.test.ts @@ -0,0 +1,294 @@ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { spawnSync } from 'child_process' +import { Database } from 'bun:sqlite' +import { sandboxContainerName } from '../../src/sandbox/msb' + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') + +// Fake `msb` that logs its argv and serves a configurable `ls` inventory, so the +// script runs end-to-end without a real CLI. +const FAKE_MSB = `#!/bin/sh +printf '%s\\n' "$@" >> "\${FAKE_MSB_LOG}" +case "$1" in + ls) + exit_code="\${FAKE_MSB_LS_EXIT:-0}" + if [ "$exit_code" != "0" ]; then exit "$exit_code"; fi + printf '%s' "\${FAKE_MSB_LS_OUT:-[]}" + exit 0 + ;; + rm) + exit_code="\${FAKE_MSB_RM_EXIT:-0}" + if [ "$exit_code" != "0" ]; then + printf '%s' "\${FAKE_MSB_RM_ERR:-rm failed}" >&2 + exit "$exit_code" + fi + exit 0 + ;; +esac +exit 0 +` + +interface CleanupRun { + status: number | null + stdout: string + stderr: string + msbArgs: string[] +} + +let binDir: string +let homeDir: string +const projectDirs: string[] = [] + +function runCleanup( + loopName: string, + opts: { lsOut?: string; lsExit?: number; rmExit?: number; rmErr?: string; args?: string[]; xdgDataHome?: string; xdgConfigHome?: string } = {}, +): CleanupRun { + const logPath = join(homeDir, 'msb.log') + rmSync(logPath, { force: true }) + const env = { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + HOME: homeDir, + XDG_DATA_HOME: opts.xdgDataHome ?? join(homeDir, 'xdg-data'), + XDG_CONFIG_HOME: opts.xdgConfigHome ?? join(homeDir, 'xdg-config'), + FAKE_MSB_LOG: logPath, + FAKE_MSB_LS_OUT: opts.lsOut ?? '', + FAKE_MSB_LS_EXIT: String(opts.lsExit ?? 0), + FAKE_MSB_RM_EXIT: String(opts.rmExit ?? 0), + FAKE_MSB_RM_ERR: opts.rmErr ?? '', + } + const result = spawnSync('bun', ['scripts/cleanup-loop.ts', loopName, ...(opts.args ?? [])], { + cwd: REPO_ROOT, + env, + encoding: 'utf-8', + timeout: 30_000, + }) + let msbArgs: string[] = [] + try { + msbArgs = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean) + } catch { + // no msb invocation happened + } + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + msbArgs, + } +} + +function rmTarget(run: CleanupRun): string | undefined { + const rmIdx = run.msbArgs.indexOf('rm') + if (rmIdx < 0) return undefined + return run.msbArgs[rmIdx + 2] +} + +beforeAll(() => { + binDir = mkdtempSync(join(tmpdir(), 'msb-bin-')) + homeDir = mkdtempSync(join(tmpdir(), 'msb-home-')) + const fakeMsb = join(binDir, 'msb') + writeFileSync(fakeMsb, FAKE_MSB, { mode: 0o755 }) + chmodSync(fakeMsb, 0o755) +}) + +afterAll(() => { + rmSync(binDir, { recursive: true, force: true }) + rmSync(homeDir, { recursive: true, force: true }) + for (const dir of projectDirs) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('cleanup-loop sandbox naming', () => { + test('derives the sandbox name through the runtime sanitizer, not string concatenation', () => { + const run = runCleanup('foo_bar', { + lsOut: '[{"name":"forge-foo-bar","status":"Running"}]', + }) + expect(run.status).toBe(0) + expect(run.stdout).toContain('Cleanup complete.') + expect(rmTarget(run)).toBe('forge-foo-bar') + expect(rmTarget(run)).toBe(sandboxContainerName('foo_bar')) + }) + + test('truncates long loop names to the same canonical name runtime provisioning uses', () => { + const loopName = 'x'.repeat(100) + const canonical = sandboxContainerName(loopName) + const run = runCleanup(loopName, { + lsOut: JSON.stringify([{ name: canonical, status: 'Stopped' }]), + }) + expect(run.status).toBe(0) + expect(run.stdout).toContain(`present (state=stopped)`) + expect(rmTarget(run)).toBe(canonical) + }) +}) + +describe('cleanup-loop inventory handling', () => { + test('never reports absence or completion when msb ls exits non-zero', () => { + const run = runCleanup('foo_bar', { lsExit: 1 }) + expect(run.status).not.toBe(0) + expect(run.stdout).not.toContain('not present') + expect(run.stdout).not.toContain('Cleanup complete.') + expect(`${run.stdout}\n${run.stderr}`).toMatch(/could not be established/) + }) + + test('never reports absence or completion when msb ls emits malformed JSON', () => { + const run = runCleanup('foo_bar', { lsOut: 'not json' }) + expect(run.status).not.toBe(0) + expect(run.stdout).not.toContain('not present') + expect(run.stdout).not.toContain('Cleanup complete.') + expect(`${run.stdout}\n${run.stderr}`).toMatch(/could not be established/) + }) + + test('reports absence only after a valid empty inventory', () => { + const run = runCleanup('foo_bar', { lsOut: '[]' }) + expect(run.status).toBe(0) + expect(run.stdout).toContain('not present') + expect(run.stdout).toContain('Cleanup complete.') + }) +}) + +describe('cleanup-loop sandbox removal', () => { + test('exits non-zero and never reports completion when msb rm fails', () => { + const run = runCleanup('foo_bar', { + lsOut: '[{"name":"forge-foo-bar","status":"Running"}]', + rmExit: 1, + rmErr: 'permission denied', + }) + expect(run.status).not.toBe(0) + expect(run.stdout).not.toContain('Cleanup complete.') + expect(`${run.stdout}\n${run.stderr}`).toMatch(/could not be established as removed/) + expect(`${run.stdout}\n${run.stderr}`).toMatch(/permission denied/) + expect(rmTarget(run)).toBe('forge-foo-bar') + }) + + test('dry run reports the removal action without invoking msb rm', () => { + const run = runCleanup('foo_bar', { + lsOut: '[{"name":"forge-foo-bar","status":"Stopped"}]', + args: ['--dry-run'], + }) + expect(run.status).toBe(0) + expect(run.stdout).toContain('Dry run complete.') + expect(run.stdout).toContain('would: msb rm --force forge-foo-bar --quiet') + expect(run.msbArgs).not.toContain('rm') + }) + + test('reports a transient sandbox as present and proceeds with removal', () => { + const run = runCleanup('foo_bar', { + lsOut: '[{"name":"forge-foo-bar","status":"Draining"}]', + }) + expect(run.status).toBe(0) + expect(run.stdout).toContain('present (state=transient)') + expect(run.stdout).not.toContain('inventory query failed') + expect(run.stdout).toContain('Cleanup complete.') + expect(rmTarget(run)).toBe('forge-foo-bar') + }) + + test('removal failure still ran the inventory query and targets the canonical name', () => { + const run = runCleanup('foo_bar', { + lsOut: '[{"name":"forge-foo-bar","status":"Running"}]', + rmExit: 2, + }) + expect(run.status).not.toBe(0) + expect(rmTarget(run)).toBe(sandboxContainerName('foo_bar')) + }) +}) + +describe('cleanup-loop forge db cleanup', () => { + test('resolves forge.db under XDG_DATA_HOME and deletes loop state in one pass', () => { + const xdg = join(homeDir, 'xdg') + const dbPath = join(xdg, 'opencode', 'forge', 'forge.db') + mkdirSync(join(xdg, 'opencode', 'forge'), { recursive: true }) + const db = new Database(dbPath) + db.run('CREATE TABLE loops (project_id TEXT, loop_name TEXT, status TEXT)') + db.run('CREATE TABLE loop_large_fields (loop_name TEXT)') + db.run('CREATE TABLE section_plans (loop_name TEXT)') + db.run('CREATE TABLE review_findings (loop_name TEXT)') + db.run('INSERT INTO loops (project_id, loop_name, status) VALUES (?, ?, ?)', ['p1', 'foo_bar', 'completed']) + db.run('INSERT INTO loop_large_fields (loop_name) VALUES (?)', ['foo_bar']) + db.close() + + const run = runCleanup('foo_bar', { + lsOut: '[]', + xdgDataHome: xdg, + }) + expect(run.status).toBe(0) + expect(run.stdout).toContain(`forge.db (${dbPath}):`) + expect(run.stdout).toContain('✓ delete loops row project=p1 status=completed') + expect(run.stdout).toContain('✓ delete loop_large_fields entries for loop=foo_bar') + + const verify = new Database(dbPath) + expect(verify.prepare('SELECT loop_name FROM loops').all()).toHaveLength(0) + expect(verify.prepare('SELECT loop_name FROM loop_large_fields').all()).toHaveLength(0) + verify.close() + }) + + test('rolls back every write when a deletion fails mid-transaction', () => { + const xdg = join(homeDir, 'xdg-rollback') + const dbPath = join(xdg, 'opencode', 'opencode.db') + mkdirSync(join(xdg, 'opencode'), { recursive: true }) + const db = new Database(dbPath) + db.run('CREATE TABLE workspace (id TEXT PRIMARY KEY, project_id TEXT, name TEXT, type TEXT)') + db.run('CREATE TABLE session (id TEXT PRIMARY KEY, workspace_id TEXT, title TEXT)') + db.run('CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)') + db.run("CREATE TRIGGER block_workspace_delete BEFORE DELETE ON workspace BEGIN SELECT RAISE(ABORT, 'blocked'); END") + db.run("INSERT INTO workspace (id, project_id, name, type) VALUES ('w1', 'p1', 'foo_bar', 'forge')") + db.run("INSERT INTO session (id, workspace_id, title) VALUES ('s1', 'w1', 'first')") + db.run("INSERT INTO session_message (id, session_id) VALUES ('m1', 's1')") + db.close() + + const run = runCleanup('foo_bar', { + lsOut: '[]', + xdgDataHome: xdg, + }) + expect(run.status).not.toBe(0) + expect(`${run.stdout}\n${run.stderr}`).toMatch(/blocked/) + + const verify = new Database(dbPath) + expect(verify.prepare("SELECT id FROM session_message WHERE session_id = 's1'").all()).toHaveLength(1) + expect(verify.prepare("SELECT id FROM session WHERE id = 's1'").all()).toHaveLength(1) + expect(verify.prepare("SELECT id FROM workspace WHERE id = 'w1'").all()).toHaveLength(1) + verify.close() + }) +}) + +describe('cleanup-loop git cleanup', () => { + test('prunes the worktree and deletes the forge branch in --project-dir', () => { + const xdg = join(homeDir, 'xdg-git') + const projectDir = mkdtempSync(join(tmpdir(), 'cleanup-proj-')) + projectDirs.push(projectDir) + expect(spawnSync('git', ['init', '-b', 'main', projectDir], { encoding: 'utf-8' }).status).toBe(0) + expect(spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: projectDir, encoding: 'utf-8' }).status).toBe(0) + expect(spawnSync('git', ['config', 'user.name', 'Test'], { cwd: projectDir, encoding: 'utf-8' }).status).toBe(0) + writeFileSync(join(projectDir, 'README.md'), 'hi\n') + expect(spawnSync('git', ['add', '-A'], { cwd: projectDir, encoding: 'utf-8' }).status).toBe(0) + expect(spawnSync('git', ['commit', '-m', 'init'], { cwd: projectDir, encoding: 'utf-8' }).status).toBe(0) + + const worktreeDir = join(xdg, 'opencode', 'forge', 'worktrees', 'foo_bar') + mkdirSync(worktreeDir, { recursive: true }) + expect( + spawnSync('git', ['worktree', 'add', '-b', 'forge/foo_bar', worktreeDir], { cwd: projectDir, encoding: 'utf-8' }) + .status, + ).toBe(0) + + const run = runCleanup('foo_bar', { + lsOut: '[]', + xdgDataHome: xdg, + args: [`--project-dir=${projectDir}`], + }) + expect(run.status).toBe(0) + expect(run.stdout).toContain('✓ git worktree prune') + expect(run.stdout).toContain('✓ git branch -D forge/foo_bar') + + expect( + spawnSync('git', ['show-ref', '--verify', '--quiet', 'refs/heads/forge/foo_bar'], { cwd: projectDir, encoding: 'utf-8' }) + .status, + ).not.toBe(0) + const wtList = spawnSync('git', ['worktree', 'list'], { cwd: projectDir, encoding: 'utf-8' }) + expect(wtList.stdout).not.toContain(worktreeDir) + expect(existsSync(worktreeDir)).toBe(false) + }) +}) diff --git a/test/services/execution-sandbox-cleanup.test.ts b/test/services/execution-sandbox-cleanup.test.ts new file mode 100644 index 000000000..7aec51310 --- /dev/null +++ b/test/services/execution-sandbox-cleanup.test.ts @@ -0,0 +1,177 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' +import Database from 'better-sqlite3' +import { mkdtempSync, writeFileSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createLoopsRepo } from '../../src/storage/repos/loops-repo' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' +import { createReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createLoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' + +const noopFn = () => {} + +const PROJECT_ID = 'test-project' + +vi.mock('../../src/utils/sandbox-ready', () => ({ + waitForSandboxReady: vi.fn(), +})) + +describe('attachLoopToSession sandbox-not-ready cleanup', () => { + let db: Database + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'exec-sandbox-cleanup-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + // attachLoopToSession only runs the sandbox wait when forge.db exists at dataDir. + writeFileSync(join(tempDir, 'forge.db'), '') + }) + + afterEach(() => { + try { + db.close() + } catch {} + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch {} + }) + + function buildDeps() { + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const sectionPlansRepo = createSectionPlansRepo(db) + const loopService = createLoopService( + loopsRepo, + plansRepo, + reviewFindingsRepo, + PROJECT_ID, + { log: () => {}, error: () => {}, debug: () => {} } as Logger, + undefined, + undefined, + undefined, + sectionPlansRepo, + ) + + const { client } = createFakeForgeClient() + + const stop = vi.fn(async () => {}) + const sandboxManager = { stop } + const unregisterSessionReverseIndex = vi.fn() + const logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() } + + const deps = { + projectId: PROJECT_ID, + directory: '/tmp/test', + config: { + loop: { enabled: true }, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + }, + logger, + dataDir: tempDir, + client, + plansRepo, + loopsRepo, + reviewFindingsRepo, + sectionPlansRepo, + loop: { + service: loopService, + listActive: (...a: any[]) => loopService.listActive(...a as any), + generateUniqueLoopName: (...a: any[]) => loopService.generateUniqueLoopName(...a as any), + findMatchByName: (...a: any[]) => loopService.findMatchByName(...a as any), + registerSessionReverseIndex: () => {}, + unregisterSessionReverseIndex, + handleAuditorProviderLimit: async () => false, + } as any, + loopHandler: { + runExclusive: async <T>(name: string, fn: () => Promise<T>) => fn(), + startWatchdog: vi.fn(), + clearLoopTimers: noopFn, + }, + sandboxManager, + workspaceStatusRegistry: { + recordEvent: vi.fn(), + getStatus: vi.fn().mockReturnValue('connected' as const), + awaitConnected: vi.fn().mockResolvedValue({ connected: true, elapsedMs: 0, source: 'cached' as const }), + primeFromSnapshot: vi.fn(), + }, + } + + return { deps, loopService, sandboxManager, unregisterSessionReverseIndex, logger } + } + + async function attach(deps: ReturnType<typeof buildDeps>['deps'], sendInitialPrompt = true) { + const { attachLoopToSession } = await import('../../src/services/execution') + return attachLoopToSession( + deps as any, + { surface: 'tui', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + sessionId: 'sess_msb', + workspaceId: 'ws_msb', + worktreeDir: '/tmp/wt/msb', + loopName: 'msb-loop', + displayName: 'MSB Loop', + executionName: 'msb-loop', + maxIterations: 50, + sandboxEnabled: true, + planText: 'NEW_PLAN', + selectSession: false, + selectSessionTiming: 'after-prompt', + startWatchdog: false, + sendInitialPrompt, + }, + ) + } + + test('rollback calls sandboxManager.stop with the loop name and still unregisters state', async () => { + const { waitForSandboxReady } = await import('../../src/utils/sandbox-ready') + vi.mocked(waitForSandboxReady).mockResolvedValue({ ready: false, reason: 'timeout' }) + + const { deps, loopService, sandboxManager, unregisterSessionReverseIndex } = buildDeps() + const deleteState = vi.spyOn(loopService, 'deleteState') + + const result = await attach(deps) + + expect(sandboxManager.stop).toHaveBeenCalledWith('msb-loop') + expect(unregisterSessionReverseIndex).toHaveBeenCalledWith('sess_msb') + expect(deleteState).toHaveBeenCalledWith('msb-loop') + expect(result).toEqual({ ok: false, code: 'internal_error', message: 'Sandbox not ready: timeout' }) + }) + + test('a rejected stop (unknown-state throw) is logged and does not mask the not-ready failure', async () => { + const { waitForSandboxReady } = await import('../../src/utils/sandbox-ready') + vi.mocked(waitForSandboxReady).mockResolvedValue({ ready: false, reason: 'timeout' }) + + const { deps, loopService, sandboxManager, unregisterSessionReverseIndex, logger } = buildDeps() + const deleteState = vi.spyOn(loopService, 'deleteState') + + const stopError = new Error('Could not determine whether sandbox forge-msb-loop exists (state query failed); refusing to remove') + vi.mocked(sandboxManager.stop).mockRejectedValue(stopError) + + const result = await attach(deps) + + expect(sandboxManager.stop).toHaveBeenCalledWith('msb-loop') + expect(logger.error).toHaveBeenCalledWith('attachLoopToSession: failed to remove sandbox container after timeout', stopError) + expect(unregisterSessionReverseIndex).toHaveBeenCalledWith('sess_msb') + expect(deleteState).toHaveBeenCalledWith('msb-loop') + expect(result).toEqual({ ok: false, code: 'internal_error', message: 'Sandbox not ready: timeout' }) + }) + + test('does not stop the sandbox when it is ready', async () => { + const { waitForSandboxReady } = await import('../../src/utils/sandbox-ready') + vi.mocked(waitForSandboxReady).mockResolvedValue({ ready: true, containerName: 'forge-msb-loop' }) + + const { deps, sandboxManager } = buildDeps() + + const result = await attach(deps, false) + + expect(sandboxManager.stop).not.toHaveBeenCalled() + expect(result).toEqual({ ok: true, loopName: 'msb-loop' }) + }) +}) diff --git a/test/setup.test.ts b/test/setup.test.ts index 745cbf7d0..22056ab65 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -57,7 +57,7 @@ describe('loadPluginConfig', () => { const sandboxConfig = { sandbox: { - mode: 'sbx', + mode: 'msb', image: 'custom-image:latest', imageFeatures: { browserControl: true }, }, @@ -66,7 +66,7 @@ describe('loadPluginConfig', () => { writeFileSync(configPath, JSON.stringify(sandboxConfig)) const config = loadPluginConfig() - expect(config.sandbox?.mode).toBe('sbx') + expect(config.sandbox?.mode).toBe('msb') expect(config.sandbox?.image).toBe('custom-image:latest') expect(config.sandbox?.imageFeatures?.browserControl).toBe(true) }) @@ -266,7 +266,7 @@ describe('bundled sample config', () => { expect(parsed.sandbox).toBeDefined() expect(parsed.sandbox?.enabled).toBe(true) - expect(parsed.sandbox?.mode).toBe('sbx') + expect(parsed.sandbox?.mode).toBe('msb') expect(parsed.sandbox?.imageFeatures?.browserControl).toBe(false) }) diff --git a/test/tui/session-sandbox-store.test.ts b/test/tui/session-sandbox-store.test.ts index be6308153..a5d20a91b 100644 --- a/test/tui/session-sandbox-store.test.ts +++ b/test/tui/session-sandbox-store.test.ts @@ -320,7 +320,7 @@ describe('session-sandbox-store (TUI bridge)', () => { }) test('derives OFF for a matching revision carrying an error or disabled desired', () => { - const errored = writeApplied({ revision: 'r1', enabled: true, error: 'sbx failed' }) + const errored = writeApplied({ revision: 'r1', enabled: true, error: 'msb failed' }) expect(deriveSessionSandboxAcknowledged({ desired: desired(), applied: errored })).toBeNull() const disabledDesired = desired({ enabled: false }) expect(deriveSessionSandboxAcknowledged({ desired: disabledDesired, applied: errored })).toBeNull() @@ -365,7 +365,7 @@ describe('session-sandbox-store (TUI bridge)', () => { test('is settled once the applied revision matches, including OFF and error', () => { const off = writeApplied({ revision: 'r1', enabled: false, error: null }) expect(isSessionSandboxPreferenceSettled({ desired: desired(), applied: off })).toBe(true) - const errored = writeApplied({ revision: 'r1', enabled: false, error: 'sbx failed to start' }) + const errored = writeApplied({ revision: 'r1', enabled: false, error: 'msb failed to start' }) expect(isSessionSandboxPreferenceSettled({ desired: desired(), applied: errored })).toBe(true) }) }) diff --git a/test/utils/section-summary.test.ts b/test/utils/section-summary.test.ts index ba8ae0fa0..88a41e545 100644 --- a/test/utils/section-summary.test.ts +++ b/test/utils/section-summary.test.ts @@ -1,66 +1,37 @@ -import { describe, test, expect, vi } from 'vitest' -import { mkdirSync, writeFileSync, rmSync } from 'fs' -import { join } from 'path' -import { hasSectionSummaryMarkers, SECTION_SUMMARY_START_MARKER, SECTION_SUMMARY_END_MARKER } from '../../src/utils/section-summary' -import { buildAuditorLoopAgent } from '../../src/agents/auditor' - -describe('hasSectionSummaryMarkers', () => { - test('returns true when text contains both markers', () => { - const text = `some content\n${SECTION_SUMMARY_START_MARKER}\nmid\n${SECTION_SUMMARY_END_MARKER}\nmore` - expect(hasSectionSummaryMarkers(text)).toBe(true) - }) - - test('returns false when missing start marker', () => { - const text = `some content\n${SECTION_SUMMARY_END_MARKER}` - expect(hasSectionSummaryMarkers(text)).toBe(false) - }) - - test('returns false when missing end marker', () => { - const text = `some content\n${SECTION_SUMMARY_START_MARKER}` - expect(hasSectionSummaryMarkers(text)).toBe(false) - }) - - test('returns false for empty string', () => { - expect(hasSectionSummaryMarkers('')).toBe(false) - }) -}) - -describe('buildLoopPrompt marker warning', () => { - test('warns when auditor-loop-addendum.md lacks markers', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const tmpDir = join(import.meta.dirname, '..', '..', '.forge', 'tmp', 'section-marker-test-' + Date.now()) - mkdirSync(join(tmpDir, 'agents'), { recursive: true }) - writeFileSync(join(tmpDir, 'agents', 'auditor-loop-addendum.md'), 'NO MARKERS HERE', 'utf-8') - writeFileSync(join(tmpDir, 'agents', 'auditor.md'), 'BASE', 'utf-8') - writeFileSync(join(tmpDir, 'agents', 'auditor-final-audit-addendum.md'), 'FINAL', 'utf-8') - - buildAuditorLoopAgent(tmpDir) - - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith( - '[forge] auditor-loop-addendum.md is missing section-summary markers; loop section parsing may fail' - ) - - warnSpy.mockRestore() - rmSync(tmpDir, { recursive: true, force: true }) +import { describe, test, expect } from 'vitest' +import { SECTION_SUMMARY_START_MARKER, SECTION_SUMMARY_END_MARKER } from '../../src/utils/section-summary' +import { buildSectionAuditPrompt } from '../../src/loop/prompts' +import type { PromptContext } from '../../src/loop/prompts' +import type { LoopState } from '../../src/loop/state' + +describe('section-summary markers', () => { + test('constants are HTML comment markers', () => { + expect(SECTION_SUMMARY_START_MARKER).toBe('<!-- section-summary:start -->') + expect(SECTION_SUMMARY_END_MARKER).toBe('<!-- section-summary:end -->') }) - test('does not warn when markers are present', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const tmpDir = join(import.meta.dirname, '..', '..', '.forge', 'tmp', 'section-marker-ok-' + Date.now()) - mkdirSync(join(tmpDir, 'agents'), { recursive: true }) - writeFileSync(join(tmpDir, 'agents', 'auditor-loop-addendum.md'), - `${SECTION_SUMMARY_START_MARKER}\ncontent\n${SECTION_SUMMARY_END_MARKER}`, 'utf-8') - writeFileSync(join(tmpDir, 'agents', 'auditor.md'), 'BASE', 'utf-8') - writeFileSync(join(tmpDir, 'agents', 'auditor-final-audit-addendum.md'), 'FINAL', 'utf-8') - - buildAuditorLoopAgent(tmpDir) - - expect(warnSpy).not.toHaveBeenCalled() - - warnSpy.mockRestore() - rmSync(tmpDir, { recursive: true, force: true }) + test('buildSectionAuditPrompt is the single owner of the summary template', () => { + const ctx: PromptContext = { + getPlanTextForState: () => null, + getOutstandingFindings: () => [], + formatReviewFindings: () => 'No review findings found.', + getSectionPlan: () => ({ + projectId: 'p', loopName: 'l', sectionIndex: 0, title: 'S1', content: 'Section plan', + status: 'in_progress', attempts: 0, summaryDone: null, summaryDeviations: null, + summaryFollowUps: null, startedAt: null, completedAt: null, createdAt: 0, + }), + getCompletedSectionDigest: () => [], + getCoderDecisions: () => null, + getFindingRecurrence: () => new Map(), + } + const state = { + loopName: 'l', sessionId: 's', active: true, phase: 'auditing', + iteration: 1, maxIterations: 5, errorCount: 0, + currentSectionIndex: 0, totalSections: 2, + } as unknown as LoopState + + const prompt = buildSectionAuditPrompt(ctx, state) + expect(prompt).toContain(SECTION_SUMMARY_START_MARKER) + expect(prompt).toContain(SECTION_SUMMARY_END_MARKER) }) }) diff --git a/test/utils/shipped-paths.test.ts b/test/utils/shipped-paths.test.ts new file mode 100644 index 000000000..a4d1ab468 --- /dev/null +++ b/test/utils/shipped-paths.test.ts @@ -0,0 +1,41 @@ +import { describe, test, expect } from 'vitest' +import { pathToFileURL } from 'url' +import { resolveShippedRoot } from '../../src/utils/shipped-paths' + +function url(absPath: string): string { + return pathToFileURL(absPath).href +} + +describe('resolveShippedRoot', () => { + test('resolves the dist root for each unbundled tsc layout module', () => { + expect(resolveShippedRoot(url('/repo/dist/install/paths.js'))).toBe('/repo/dist') + expect(resolveShippedRoot(url('/repo/dist/storage/migrations/index.js'))).toBe('/repo/dist') + expect(resolveShippedRoot(url('/repo/dist/prompts/loader.js'))).toBe('/repo/dist') + }) + + test('resolves the dist root for a future bundled server entry', () => { + expect(resolveShippedRoot(url('/repo/dist/index.js'))).toBe('/repo/dist') + }) + + test('resolves the dist root for the bundled installer cli', () => { + expect(resolveShippedRoot(url('/repo/dist/install/cli.js'))).toBe('/repo/dist') + }) + + test('resolves the src root when running from source', () => { + expect(resolveShippedRoot(url('/repo/src/install/paths.ts'))).toBe('/repo/src') + }) + + test('nearest match wins over a higher src or dist ancestor', () => { + expect(resolveShippedRoot(url('/Users/x/src/project/dist/storage/migrations/index.js'))).toBe( + '/Users/x/src/project/dist' + ) + }) + + test('returns the starting directory when no dist or src ancestor exists', () => { + expect(resolveShippedRoot(url('/Users/x/project/lib/foo.js'))).toBe('/Users/x/project/lib') + }) + + test('returns dist when the module is directly inside dist', () => { + expect(resolveShippedRoot(url('/repo/dist/index.js'))).toBe('/repo/dist') + }) +}) diff --git a/test/workspace/forge-adapter.test.ts b/test/workspace/forge-adapter.test.ts index d377339c2..099b88923 100644 --- a/test/workspace/forge-adapter.test.ts +++ b/test/workspace/forge-adapter.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createForgeWorkspaceAdapter, type ForgeAdapterDeps } from '../../src/workspace/forge-adapter' +import { createSandboxManager } from '../../src/sandbox/manager' +import { createMockSandboxRuntime } from '../helpers/sandbox-mocks' import { join, isAbsolute } from 'path' import { mkdtempSync, existsSync, rmSync, readFileSync, writeFileSync } from 'fs' import { execSync } from 'child_process' @@ -108,7 +110,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create invokes git worktree add and creates worktree directory', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-repo-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const adapter = createForgeWorkspaceAdapter({ dataDir: tmpDataDir, logger, @@ -130,7 +132,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create adds .forge/ to git exclude in the worktree', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-exclude-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const adapter = createForgeWorkspaceAdapter({ dataDir: tmpDataDir, logger, @@ -157,7 +159,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create uses info.extra.projectDirectory as the git cwd, ignoring deps', async () => { const realRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-projdir-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: realRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: realRepo, encoding: 'utf-8' }) const adapter = createForgeWorkspaceAdapter({ dataDir: tmpDataDir, logger }) const configured = adapter.configure({ id: 'ws-1', type: 'forge', name: '', branch: null, directory: null, @@ -209,7 +211,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create ensures parent worktree directory exists before calling git', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-repo2-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const nestedDataDir = join(tmpDataDir, 'nested', 'deep') const adapter = createForgeWorkspaceAdapter({ dataDir: nestedDataDir, @@ -231,7 +233,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create starts sandbox after creating the worktree', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-repo-sandbox-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const sandboxManager = { start: vi.fn().mockResolvedValue({ containerName: 'forge-sandbox-loop' }), stop: vi.fn().mockResolvedValue(undefined), @@ -255,7 +257,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create skips sandbox provisioning when forgeLoop.sandboxEnabled is false', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-repo-sandbox-optout-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const sandboxManager = { start: vi.fn().mockRejectedValue(new Error('Docker is not available. Please ensure Docker is running.')), stop: vi.fn().mockResolvedValue(undefined), @@ -281,7 +283,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create cleans up worktree and sandbox when sandbox start fails', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-repo-sandbox-fail-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const sandboxManager = { start: vi.fn().mockRejectedValue(new Error('docker unavailable')), stop: vi.fn().mockResolvedValue(undefined), @@ -302,10 +304,34 @@ describe('createForgeWorkspaceAdapter', () => { } }) + it('create never removes a sandbox when provisioning fails on an unknown state query', async () => { + const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-repo-unknown-')) + try { + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + const runtime = createMockSandboxRuntime() + runtime.setSandboxState('forge-sandbox-unknown-loop', 'unknown') + const sandboxManager = createSandboxManager(runtime, { image: 'oc-forge-sandbox:latest' }, logger) + const adapter = createForgeWorkspaceAdapter({ + dataDir: tmpDataDir, + logger, + sandboxManager, + }) + const configured = adapter.configure(makeInfo('sandbox-unknown-loop', tmpRepo)) + + await expect(adapter.create(configured, {})).rejects.toThrow(/state query failed/) + + // The rollback path calls stop(), which must also fail closed: `unknown` says nothing about + // the sandbox, so `msb rm` is never issued and a possibly-live sandbox is never destroyed. + expect(runtime.getRemoveSandboxCalls()).toEqual([]) + } finally { + if (existsSync(tmpRepo)) rmSync(tmpRepo, { recursive: true, force: true }) + } + }) + it('remove runs git worktree remove and prune', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-repo3-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const adapter = createForgeWorkspaceAdapter({ dataDir: tmpDataDir, logger, @@ -329,7 +355,7 @@ describe('createForgeWorkspaceAdapter', () => { it('remove is idempotent: skips remove when directory does not exist', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-repo4-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const adapter = createForgeWorkspaceAdapter({ dataDir: tmpDataDir, logger, @@ -466,7 +492,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create writes opencode.jsonc and adds it to git exclude', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-opencode-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const config = { mcp: { demo: { type: 'local', command: ['x'], enabled: true } } } const adapter = createForgeWorkspaceAdapter({ dataDir: tmpDataDir, @@ -497,7 +523,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create substitutes the sandbox container placeholder when a sandbox is provisioned', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-placeholder-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const sandboxManager = { start: vi.fn().mockResolvedValue({ containerName: 'forge-placeholder-loop' }), stop: vi.fn().mockResolvedValue(undefined), @@ -532,7 +558,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create drops placeholder mcp entries when the loop opts out of the sandbox', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-placeholder-optout-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const sandboxManager = { start: vi.fn().mockResolvedValue({ containerName: 'unused' }), stop: vi.fn().mockResolvedValue(undefined), @@ -601,7 +627,7 @@ describe('createForgeWorkspaceAdapter', () => { it('create does not write opencode.jsonc when no config provided', async () => { const tmpRepo = mkdtempSync(join(tmpdir(), 'forge-adapter-no-opencode-')) try { - execSync('git init && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) + execSync('git init && git config user.email t@t && git config user.name t && git commit --allow-empty -m init', { cwd: tmpRepo, encoding: 'utf-8' }) const adapter = createForgeWorkspaceAdapter({ dataDir: tmpDataDir, logger,