diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5c09d03 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +engines/opencode-sandboxed-linux.sh text eol=lf diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29a577c..2979af5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,6 +21,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y bubblewrap - name: Run test suite env: RINGER_NO_SELF_UPDATE: "1" diff --git a/README.md b/README.md index 529e36c..6beb8c0 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ For CI and evals, `config.sample.toml` includes `[engines.mock]` so the enforcem ![Identical workers, each under its own light](docs/engines.png) -Ringer ships with three worker lanes: **Codex CLI** is the built-in default, and `config.sample.toml` carries verified engine blocks for **Grok Build CLI** (works as-is once you `grok login`) and **OpenCode + OpenRouter** (one edit: point `bin` at the sandbox wrapper in your clone). Anything else with a headless CLI is a config block away: +Ringer ships with built-in **Codex CLI** and **OpenCode + OpenRouter** lanes. OpenCode automatically selects the macOS Seatbelt wrapper or Linux/WSL2 bubblewrap wrapper; `config.sample.toml` also carries a verified **Grok Build CLI** engine block. Anything else with a headless CLI is a config block away: ```toml [engines.mymodel] @@ -166,9 +166,9 @@ Per-task `"engine": "mymodel"` routes work to it — the invariants (stdin close ### The universal harness: OpenCode + OpenRouter -Unless a model ships its own first-class harness (Codex does), OpenCode is the harness that runs it — one engine block covers every OpenRouter-served model. `config.sample.toml` includes a ready-to-uncomment engine whose `{model}` placeholder is filled per task from the manifest's `"model"` field, with `model_default` as the fallback. The shipped default is OpenRouter's `z-ai/glm-5.2` — roughly $0.74/M input and $2.33/M output (2026-07), about 20-30x cheaper output than frontier coding models; a complete write-code-and-pass-the-check task lands around a penny. +Unless a model ships its own first-class harness (Codex does), OpenCode is the harness that runs it — one built-in engine covers every OpenRouter-served model. Set the manifest task's `"model"` to the full OpenRouter slug so routing cannot drift. -OpenCode ships no OS sandbox, so the engine's `bin` points at an absolute path to `engines/opencode-sandboxed.sh` (ringer does not resolve engine bins relative to the repo): a macOS Seatbelt wrapper that leaves network and reads open but confines writes to the task dir, a per-run scratch dir (wired as the agent's `TMPDIR`/`XDG_CACHE_HOME`), and OpenCode's own state/config dirs. Its `--dangerously-skip-permissions` flag only silences OpenCode's interactive prompts; Seatbelt is the actual containment. Task paths reach the profile as `sandbox-exec -D` parameters rather than string interpolation, so a task dir with quotes or parens can't inject sandbox rules. `--no-sandbox` is wired as the engine's `full_access_args`, so ringer's `allow_full_access` gate still governs escapes. Non-macOS installs need their own sandbox (or full-access mode). +OpenCode ships no OS sandbox, so Ringer selects one. The macOS Seatbelt and Linux/WSL2 bubblewrap wrappers leave network and host reads open, confine writes to the task directory and ephemeral per-run state, and clear inherited environment variables. `--no-sandbox` remains wired to Ringer's existing two-part `full_access` gate. Setting it up takes about five minutes: @@ -181,13 +181,10 @@ curl -fsSL https://opencode.ai/install | bash # 2) Connect OpenRouter — create a key at https://openrouter.ai/settings/keys opencode auth login # select OpenRouter, paste the key -# 3) In ~/.config/ringer/config.toml, uncomment [engines.opencode] and set -# bin to the ABSOLUTE path of engines/opencode-sandboxed.sh in this clone. -# (Linux/WSL: the wrapper is macOS-only — set bin to the opencode binary -# itself; there is no OS write-confinement then, so keep manifests scoped.) +# 3) No engine config is required. Ringer selects the platform wrapper. ``` -Route with per-task `"engine": "opencode"`, pick the model with per-task `"model": "openrouter/"`, and set reasoning effort via `engine_args`: `["--variant", "low|high|max"]`. A sensible split: mechanical or tightly-specced tasks on the cheap lane, gnarly ones on your frontier engine — the executed check catches shortfalls either way, and `swarm_runs` rows tell you whether the cheap lane's pass rate holds. +Route with per-task `"engine": "opencode"`, pick the model with per-task `"model": "openrouter/"`, and set reasoning effort via `engine_args`, for example `["--variant", "high"]`. ### The plan lane: Grok Build CLI diff --git a/config.sample.toml b/config.sample.toml index 1527dea..07e4d60 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -136,31 +136,27 @@ model_report_regex = "(?m)^model:[ \\t]*([^ \\t\\r\\n]+)[ \\t]*\\r?$" # sandbox_args = [] # full_access_args = [] -# OpenCode + OpenRouter — the universal model harness. One engine block runs -# ANY OpenRouter-served model: the {model} placeholder is filled per task from -# the manifest's "model" field, falling back to model_default below. Example -# default: GLM-5.2 (z-ai/glm-5.2, roughly $0.74/M input, $2.33/M output as of -# 2026-07) — the cheap-intelligence lane. -# OpenCode has no OS sandbox, so `bin` points at engines/opencode-sandboxed.sh -# (macOS Seatbelt: network + reads open, writes confined to the task dir, a -# per-run scratch dir, and OpenCode's state/config dirs). Auth: put your +# OpenCode + OpenRouter is built in. Ringer selects the macOS Seatbelt wrapper +# or Linux/WSL2 bubblewrap wrapper, keeps host reads available, confines writes +# to the task and ephemeral state, and requires an explicit per-task model. +# Auth: put your # OpenRouter key where your OpenCode version expects it — commonly # ~/.local/share/opencode/auth.json ({"openrouter": {"type": "api", "key": "..."}}); # confirm the path with your installed CLI. # Per-task engine_args can set reasoning effort ("--variant", "low|high|max"). -# Uncomment and set an absolute path for `bin` to enable. +# Uncomment only to override the built-in engine. # [engines.opencode] -# bin = "/absolute/path/to/ringer/engines/opencode-sandboxed.sh" -# model_default = "openrouter/z-ai/glm-5.2" +# model_default = "" # args_template = [ # "{taskdir}", # "{access_args}", # "run", -# "-m", -# "{model}", -# "--dangerously-skip-permissions", +# "--pure", +# "--auto", # "--format", # "json", +# "--model", +# "{model}", # "{engine_args}", # "--dir", # "{taskdir}", diff --git a/engines/opencode-sandboxed-linux.sh b/engines/opencode-sandboxed-linux.sh new file mode 100755 index 0000000..7c59bca --- /dev/null +++ b/engines/opencode-sandboxed-linux.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Run OpenCode with host reads available, writes confined to the task and +# ephemeral per-run state, and the inherited environment removed. +set -euo pipefail + +TASKDIR="${1:?usage: opencode-sandboxed-linux.sh [--no-sandbox] }" +shift +SANDBOX=1 +if [ "${1:-}" = "--no-sandbox" ]; then + SANDBOX=0 + shift +fi + +if [ -n "${OPENCODE_BIN:-}" ]; then + : +elif [ -x "$HOME/.opencode/bin/opencode" ]; then + OPENCODE_BIN="$HOME/.opencode/bin/opencode" +elif ! OPENCODE_BIN="$(command -v opencode)" || [ -z "$OPENCODE_BIN" ]; then + echo "opencode-sandboxed-linux.sh: native opencode not found" >&2 + echo "Install it with: curl -fsSL https://opencode.ai/install | bash" >&2 + exit 127 +fi + +if [ ! -x "$OPENCODE_BIN" ]; then + echo "opencode-sandboxed-linux.sh: not executable: $OPENCODE_BIN" >&2 + exit 127 +fi + +TASKDIR_REAL="$(cd "$TASKDIR" && pwd -P)" +if [ "$SANDBOX" = "0" ]; then + cd "$TASKDIR_REAL" + exec "$OPENCODE_BIN" "$@" < /dev/null +fi + +if ! BWRAP_BIN="$(command -v bwrap)" || [ -z "$BWRAP_BIN" ]; then + echo "opencode-sandboxed-linux.sh: bubblewrap is required for sandboxed runs" >&2 + echo "Install it with: sudo apt-get install bubblewrap" >&2 + exit 1 +fi + +SCRATCH="$(mktemp -d -t ringer-opencode-linux.XXXXXX)" +cleanup() { + rm -rf -- "$SCRATCH" +} +trap cleanup EXIT + +SANDBOX_HOME="$SCRATCH/home" +SANDBOX_TMP="$SCRATCH/tmp" +mkdir -p \ + "$SANDBOX_HOME/.cache" \ + "$SANDBOX_HOME/.config/opencode" \ + "$SANDBOX_HOME/.local/share/opencode" \ + "$SANDBOX_HOME/.local/state/opencode" \ + "$SANDBOX_TMP" + +AUTH_FILE="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/auth.json" +if [ -f "$AUTH_FILE" ]; then + install -m 600 "$AUTH_FILE" "$SANDBOX_HOME/.local/share/opencode/auth.json" +fi + +OPENCODE_DIR="$(cd "$(dirname "$OPENCODE_BIN")" && pwd -P)" + +set +e +"$BWRAP_BIN" \ + --die-with-parent \ + --new-session \ + --unshare-user \ + --unshare-pid \ + --unshare-uts \ + --unshare-ipc \ + --share-net \ + --clearenv \ + --ro-bind / / \ + --proc /proc \ + --dev /dev \ + --bind "$SCRATCH" "$SCRATCH" \ + --bind "$TASKDIR_REAL" "$TASKDIR_REAL" \ + --setenv HOME "$SANDBOX_HOME" \ + --setenv PATH "$OPENCODE_DIR:/usr/local/bin:/usr/bin:/bin" \ + --setenv TMPDIR "$SANDBOX_TMP" \ + --setenv XDG_CACHE_HOME "$SANDBOX_HOME/.cache" \ + --setenv XDG_CONFIG_HOME "$SANDBOX_HOME/.config" \ + --setenv XDG_DATA_HOME "$SANDBOX_HOME/.local/share" \ + --setenv XDG_STATE_HOME "$SANDBOX_HOME/.local/state" \ + --setenv LANG C.UTF-8 \ + --chdir "$TASKDIR_REAL" \ + "$OPENCODE_BIN" "$@" < /dev/null +status=$? +set -e +exit "$status" diff --git a/ringer.py b/ringer.py index 0dca41b..803830a 100755 --- a/ringer.py +++ b/ringer.py @@ -891,6 +891,39 @@ def built_in_codex_engine() -> EngineConfig: ) +def built_in_opencode_engine() -> EngineConfig: + wrapper_name = ( + "opencode-sandboxed.sh" + if sys.platform == "darwin" + else "opencode-sandboxed-linux.sh" + ) + wrapper = (Path(__file__).resolve().parent / "engines" / wrapper_name).resolve() + return EngineConfig( + name="opencode", + bin=str(wrapper), + args_template=( + "{taskdir}", + "{access_args}", + "run", + "--pure", + "--auto", + "--format", + "json", + "--model", + "{model}", + "{engine_args}", + "--dir", + "{taskdir}", + "{spec}", + ), + full_access_args=("--no-sandbox",), + sandbox_args=(), + token_regex=r'"tokens"\s*:\s*\{\s*"total"\s*:\s*([0-9]+)', + model_report_regex=None, + model_default="", + ) + + def load_eval_config(raw: Any, state_dir: Path) -> EvalConfig: if raw is None: raw = {} @@ -928,6 +961,8 @@ def load_hud_port(raw: Any) -> int: def load_engines(raw: Any) -> dict[str, EngineConfig]: engines: dict[str, EngineConfig] = {DEFAULT_ENGINE_NAME: built_in_codex_engine()} + if sys.platform == "darwin" or sys.platform.startswith("linux"): + engines["opencode"] = built_in_opencode_engine() if raw is None: return engines if not isinstance(raw, dict): diff --git a/tests/test_linux_sandbox_wrapper.py b/tests/test_linux_sandbox_wrapper.py new file mode 100644 index 0000000..17e73f7 --- /dev/null +++ b/tests/test_linux_sandbox_wrapper.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +@unittest.skipUnless(sys.platform.startswith("linux"), "bubblewrap contract is Linux/WSL-only") +class LinuxSandboxWrapperTests(unittest.TestCase): + def test_wrapper_allows_host_reads_and_workspace_writes_but_blocks_host_writes(self) -> None: + if shutil.which("bwrap") is None: + self.skipTest("bubblewrap is not installed") + + repo_root = Path(__file__).resolve().parents[1] + wrapper = repo_root / "engines" / "opencode-sandboxed-linux.sh" + with tempfile.TemporaryDirectory() as raw_root: + root = Path(raw_root) + taskdir = root / "task" + taskdir.mkdir() + forbidden = root / "forbidden.txt" + host_readable = root / "host-readable.txt" + host_readable.write_text("host reads remain available\n", encoding="utf-8") + fake_worker = root / "opencode" + fake_worker.write_text( + "#!/bin/sh\n" + "set -eu\n" + "if [ -n \"${RINGER_TEST_SECRET:-}\" ]; then exit 43; fi\n" + f"cat {host_readable} > {taskdir / 'read-copy.txt'}\n" + f"printf 'allowed\\n' > {taskdir / 'allowed.txt'}\n" + f"if printf 'blocked\\n' > {forbidden}; then exit 42; fi\n" + f"printf '%s\\n' \"$HOME\" > {taskdir / 'sandbox-home.txt'}\n", + encoding="utf-8", + ) + fake_worker.chmod(0o755) + + env = os.environ.copy() + env["OPENCODE_BIN"] = str(fake_worker) + env["RINGER_TEST_SECRET"] = "must-not-cross-boundary" + completed = subprocess.run( + [str(wrapper), str(taskdir), "run", "ignored"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=30, + env=env, + check=False, + ) + + self.assertEqual(0, completed.returncode, completed.stdout) + self.assertEqual("allowed\n", (taskdir / "allowed.txt").read_text(encoding="utf-8")) + self.assertEqual( + "host reads remain available\n", + (taskdir / "read-copy.txt").read_text(encoding="utf-8"), + ) + self.assertFalse(forbidden.exists()) + sandbox_home = (taskdir / "sandbox-home.txt").read_text(encoding="utf-8").strip() + self.assertIn("ringer-opencode-linux.", sandbox_home) + self.assertFalse(Path(sandbox_home).exists(), "ephemeral home survived the run") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_opencode_engine.py b/tests/test_opencode_engine.py new file mode 100644 index 0000000..650fbee --- /dev/null +++ b/tests/test_opencode_engine.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from unittest import mock + +from ringer import build_worker_command, built_in_opencode_engine, load_engines + + +class OpenCodeEngineContractTests(unittest.TestCase): + def test_linux_and_macos_select_their_native_wrappers(self) -> None: + with mock.patch.object(sys, "platform", "linux"): + self.assertEqual( + "opencode-sandboxed-linux.sh", + Path(built_in_opencode_engine().bin).name, + ) + with mock.patch.object(sys, "platform", "darwin"): + self.assertEqual( + "opencode-sandboxed.sh", + Path(built_in_opencode_engine().bin).name, + ) + + @unittest.skipUnless( + sys.platform == "darwin" or sys.platform.startswith("linux"), + "built-in OpenCode wrappers are POSIX-only", + ) + def test_opencode_is_built_in_and_requires_an_explicit_model(self) -> None: + engine = load_engines(None)["opencode"] + self.assertEqual("", engine.model_default) + taskdir = Path("/tmp/opencode-task") + command = build_worker_command( + engine, + taskdir=taskdir, + spec="write result.txt", + full_access=False, + model="openrouter/z-ai/glm-5.2", + ) + self.assertEqual(str(taskdir), command[1]) + self.assertNotIn("--no-sandbox", command) + self.assertIn("--pure", command) + self.assertIn("--auto", command) + self.assertEqual( + "openrouter/z-ai/glm-5.2", + command[command.index("--model") + 1], + ) + self.assertEqual(str(taskdir), command[command.index("--dir") + 1]) + self.assertEqual("write result.txt", command[-1]) + + @unittest.skipUnless( + sys.platform == "darwin" or sys.platform.startswith("linux"), + "built-in OpenCode wrappers are POSIX-only", + ) + def test_full_access_uses_the_existing_two_part_gate_escape(self) -> None: + command = build_worker_command( + load_engines(None)["opencode"], + taskdir=Path("/tmp/opencode-task"), + spec="write result.txt", + full_access=True, + model="openrouter/z-ai/glm-5.2", + ) + self.assertEqual("--no-sandbox", command[2]) + + +if __name__ == "__main__": + unittest.main(verbosity=2)