Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engines/opencode-sandboxed-linux.sh text eol=lf
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 5 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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:

Expand All @@ -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/<any-model>"`, 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/<any-model>"`, and set reasoning effort via `engine_args`, for example `["--variant", "high"]`.

### The plan lane: Grok Build CLI

Expand Down
24 changes: 10 additions & 14 deletions config.sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
90 changes: 90 additions & 0 deletions engines/opencode-sandboxed-linux.sh
Original file line number Diff line number Diff line change
@@ -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 <taskdir> [--no-sandbox] <args...>}"
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"
35 changes: 35 additions & 0 deletions ringer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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):
Expand Down
68 changes: 68 additions & 0 deletions tests/test_linux_sandbox_wrapper.py
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 67 additions & 0 deletions tests/test_opencode_engine.py
Original file line number Diff line number Diff line change
@@ -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)