From c049d0ca28d40dffb54383225733395e7372c4fd Mon Sep 17 00:00:00 2001 From: David <289305201+victurbo37-debug@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:48:11 -0400 Subject: [PATCH 1/3] feat: add Codex Sol orchestrator integration Install the Ringer skill and hooks for Codex, keep Windows PID probes safe, document the WSL runtime boundary, and verify the real Codex demo contract. --- .claude/skills/ringer/SKILL.md | 13 +++ .claude/skills/ringer/agents/openai.yaml | 4 + .gitattributes | 2 + .gitignore | 4 + README.md | 34 +++++- hooks/ringer_nudge.py | 56 ++++++++- ringer.py | 138 +++++++++++++++++------ tests/test_agent_install.py | 96 +++++++++++++++- tests/test_demo.py | 37 ++++++ tests/test_nudge_hook.py | 52 ++++++++- 10 files changed, 387 insertions(+), 49 deletions(-) create mode 100644 .claude/skills/ringer/agents/openai.yaml create mode 100644 .gitattributes create mode 100644 tests/test_demo.py diff --git a/.claude/skills/ringer/SKILL.md b/.claude/skills/ringer/SKILL.md index 9e8770e..fb0c64d 100644 --- a/.claude/skills/ringer/SKILL.md +++ b/.claude/skills/ringer/SKILL.md @@ -21,6 +21,19 @@ description: >- # Ringer orchestrator playbook +## Codex Sol orchestrator seat + +When the user chooses ChatGPT Codex Sol as the orchestrator, keep Sol in the +planning and review lane. Use Ringer's external CLI workers for implementation; +do not substitute invisible in-process subagents for the Ringer run. Stamp runs +with `--identity codex-sol` unless the user supplies a different name. + +On Windows, run the Ringer runtime and its POSIX checks inside WSL. The native +Codex app can still orchestrate by invoking the WSL command line, while Ringside +remains reachable on `127.0.0.1`. Do not claim a verified swarm until the +manifest check commands have executed successfully inside that supported +runtime. + ## Read this first — the four rules that actually get broken 1. **You review; workers type.** Your lane: specs, checks, pattern choice, diff --git a/.claude/skills/ringer/agents/openai.yaml b/.claude/skills/ringer/agents/openai.yaml new file mode 100644 index 0000000..ac5ab71 --- /dev/null +++ b/.claude/skills/ringer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Ringer Orchestrator" + short_description: "Plan and review verified multi-model swarms" + default_prompt: "Use $ringer to route this work through a verified Ringer swarm." diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..61d299b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.py text eol=lf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index e6b24bd..2adc167 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ hud/dist/ hud/gen/ hud/icons/icon.iconset/ *.log +.skill_validate_deps/ +.tmp/ +.test-tmp/ +.sandbox-test-root/ diff --git a/README.md b/README.md index fbc3c50..2f49554 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,26 @@ git clone https://github.com/NateBJones-Projects/ringer && cd ringer mkdir -p ~/.config/ringer && cp config.sample.toml ~/.config/ringer/config.toml # optional — sane defaults without it ``` -3. Teach your agent to route work through Ringer: +3. Teach your orchestrating agent to route work through Ringer: ```bash -# optional but recommended: teach your agent to route work through ringer +# Claude Code (backward-compatible default) ./ringer.py install-agent + +# ChatGPT Codex / Codex CLI / IDE +./ringer.py install-agent --agent codex +``` + +On Windows, install the Codex integration from PowerShell against the Windows +checkout so it reaches the native Codex profile: + +```powershell +py -3 .\ringer.py install-agent --agent codex ``` +Run Ringer itself inside WSL. Running `install-agent` from WSL would configure +the Linux user's profile, not the native Windows Codex app. + 4. Run the demo: ```bash @@ -129,12 +142,23 @@ Between swarms, agents drift back to invisible inline work. Reminders decay, so Run one command: ```bash -./ringer.py install-agent +./ringer.py install-agent # Claude Code +./ringer.py install-agent --agent codex # ChatGPT Codex / Codex CLI / IDE ``` -It installs the ringer skill — the orchestrator playbook — user-level for Claude Code, and registers two gentle hooks: a Bash hook that notices model-calling or harness commands running outside a live Ringer run, and an edit-loop hook that notices batch editing without a run. Each hook nudges ONCE per session, pointing the agent at the skill. +It installs the ringer skill — the orchestrator playbook — user-level for Claude Code by default, or for ChatGPT Codex with `--agent codex`. It also registers two gentle hooks: a Bash hook that notices model-calling or harness commands running outside a live Ringer run, and an edit-loop hook that notices batch editing without a run. Each hook nudges ONCE per session, pointing the agent at the skill. + +Codex installs the skill under `~/.agents/skills/ringer` and its hooks under +`~/.codex/hooks.json`, matching Codex's documented user-level discovery paths. +On Windows, hook entries include a native `commandWindows` override; Ringer +itself still runs in WSL because manifests and process control are POSIX-based. +Codex treats these as non-managed hooks: review and trust them in the Hooks +settings (or with `/hooks` in the CLI) before they can run. A changed hook +definition is skipped until it is trusted again. -The hooks never block anything. A user who says "just do it inline" is obeyed; uninstall with `./ringer.py uninstall-agent`. +The hooks never block anything. A user who says "just do it inline" is obeyed; +uninstall Claude with `./ringer.py uninstall-agent`, or Codex with +`./ringer.py uninstall-agent --agent codex`. For CI and evals, `config.sample.toml` includes `[engines.mock]` so the enforcement stack can be tested without an API bill. diff --git a/hooks/ringer_nudge.py b/hooks/ringer_nudge.py index 0d39e2f..2480da0 100644 --- a/hooks/ringer_nudge.py +++ b/hooks/ringer_nudge.py @@ -29,6 +29,8 @@ r"\.(?:mjs|js|ts|py)\b", re.IGNORECASE, ) +PATCH_PATH_RE = re.compile(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", re.MULTILINE) +PATCH_MOVE_RE = re.compile(r"^\*\*\* Move to: (.+)$", re.MULTILINE) def ringer_home() -> Path: @@ -38,6 +40,34 @@ def ringer_home() -> Path: return Path.home() / ".ringer" +def pid_is_alive_windows(pid: int) -> bool: + import ctypes + from ctypes import wintypes + + synchronize = 0x00100000 + wait_timeout = 0x00000102 + error_access_denied = 5 + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + open_process = kernel32.OpenProcess + open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + open_process.restype = wintypes.HANDLE + wait_for_single_object = kernel32.WaitForSingleObject + wait_for_single_object.argtypes = [wintypes.HANDLE, wintypes.DWORD] + wait_for_single_object.restype = wintypes.DWORD + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + + handle = open_process(synchronize, False, pid) + if not handle: + return ctypes.get_last_error() == error_access_denied + try: + return wait_for_single_object(handle, 0) == wait_timeout + finally: + close_handle(handle) + + def pid_is_alive(pid: Any) -> bool: try: parsed = int(pid) @@ -45,6 +75,9 @@ def pid_is_alive(pid: Any) -> bool: return False if parsed <= 0: return False + if os.name == "nt": + # Signal 0 is CTRL_C_EVENT on Windows, not a harmless existence probe. + return pid_is_alive_windows(parsed) try: os.kill(parsed, 0) except ProcessLookupError: @@ -178,17 +211,30 @@ def load_post_edit_state(path: Path) -> dict[str, Any]: return {"count": count, "file_paths": [str(path) for path in file_paths]} +def edited_file_paths(payload: dict[str, Any]) -> set[str]: + tool_input = payload.get("tool_input") + if not isinstance(tool_input, dict): + return set() + + file_path = tool_input.get("file_path") + if isinstance(file_path, str) and file_path.strip(): + return {file_path.strip()} + + command = tool_input.get("command") + if payload.get("tool_name") != "apply_patch" or not isinstance(command, str): + return set() + paths = PATCH_PATH_RE.findall(command) + paths.extend(PATCH_MOVE_RE.findall(command)) + return {path.strip() for path in paths if path.strip()} + + def record_post_edit(payload: dict[str, Any], home: Path) -> tuple[int, int]: path = post_edit_state_path(home, payload.get("session_id")) state = load_post_edit_state(path) count = int(state["count"]) + 1 files = set(str(item) for item in state["file_paths"]) - tool_input = payload.get("tool_input") - if isinstance(tool_input, dict): - file_path = tool_input.get("file_path") - if isinstance(file_path, str) and file_path.strip(): - files.add(file_path) + files.update(edited_file_paths(payload)) next_state = {"count": count, "file_paths": sorted(files)} write_json_atomic(path, next_state) diff --git a/ringer.py b/ringer.py index aa75f84..93f092d 100755 --- a/ringer.py +++ b/ringer.py @@ -2054,9 +2054,40 @@ def active_runs_path() -> Path: return ringer_home() / "active-runs.json" +def pid_is_alive_windows(pid: int) -> bool: + import ctypes + from ctypes import wintypes + + synchronize = 0x00100000 + wait_timeout = 0x00000102 + error_access_denied = 5 + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + open_process = kernel32.OpenProcess + open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + open_process.restype = wintypes.HANDLE + wait_for_single_object = kernel32.WaitForSingleObject + wait_for_single_object.argtypes = [wintypes.HANDLE, wintypes.DWORD] + wait_for_single_object.restype = wintypes.DWORD + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + + handle = open_process(synchronize, False, pid) + if not handle: + return ctypes.get_last_error() == error_access_denied + try: + return wait_for_single_object(handle, 0) == wait_timeout + finally: + close_handle(handle) + + def pid_is_alive(pid: int) -> bool: if pid <= 0: return False + if os.name == "nt": + # Signal 0 is CTRL_C_EVENT on Windows, not a harmless existence probe. + return pid_is_alive_windows(pid) try: os.kill(pid, 0) except ProcessLookupError: @@ -8629,21 +8660,21 @@ def create_demo_manifest() -> Path: "tasks": [ { "key": "alpha", - "spec": "Create alpha.txt in the current working directory containing exactly: alpha ready. Do not write any other files.", + "spec": "Create alpha.txt in the current working directory. Its complete contents must be exactly \"alpha ready\" without the quotation marks. There is no period in the file content. Do not write any other files.", "check": "test \"$(cat alpha.txt 2>/dev/null)\" = \"alpha ready\" || { echo 'FAIL: alpha.txt missing or content is not alpha ready'; exit 1; }", "verified": "alpha.txt exists and contains exactly the expected text", "expect_files": ["alpha.txt"], }, { "key": "bravo", - "spec": "Create bravo.txt in the current working directory containing exactly: bravo ready. Do not write any other files.", + "spec": "Create bravo.txt in the current working directory. Its complete contents must be exactly \"bravo ready\" without the quotation marks. There is no period in the file content. Do not write any other files.", "check": "test \"$(cat bravo.txt 2>/dev/null)\" = \"bravo ready\" || { echo 'FAIL: bravo.txt missing or content is not bravo ready'; exit 1; }", "verified": "bravo.txt exists and contains exactly the expected text", "expect_files": ["bravo.txt"], }, { "key": "charlie", - "spec": "Create charlie.txt in the current working directory containing exactly: charlie ready. Do not write any other files.", + "spec": "Create charlie.txt in the current working directory. Its complete contents must be exactly \"charlie ready\" without the quotation marks. There is no period in the file content. Do not write any other files.", "check": "test \"$(cat charlie.txt 2>/dev/null)\" = \"charlie ready\" || { echo 'FAIL: charlie.txt missing or content is not charlie ready'; exit 1; }", "verified": "charlie.txt exists and contains exactly the expected text", "expect_files": ["charlie.txt"], @@ -8659,17 +8690,34 @@ def repo_root() -> Path: return Path(__file__).resolve().parent +def agent_base(project: bool) -> Path: + return Path.cwd() if project else Path.home() + + def claude_root(project: bool) -> Path: - return (Path.cwd() if project else Path.home()) / ".claude" + return agent_base(project) / ".claude" + + +def codex_skill_root(project: bool) -> Path: + return agent_base(project) / ".agents" + + +def codex_config_root(project: bool) -> Path: + return agent_base(project) / ".codex" def ringer_skill_source() -> Path: - return repo_root() / ".claude" / "skills" / "ringer" / "SKILL.md" + return repo_root() / ".claude" / "skills" / "ringer" def ringer_hook_command(action: str) -> str: hook_path = repo_root() / "hooks" / "ringer_nudge.py" - return f"python3 {shlex.quote(str(hook_path))} {action}" + return shlex.join(["python3", str(hook_path), action]) + + +def ringer_hook_command_windows(action: str) -> str: + hook_path = repo_root() / "hooks" / "ringer_nudge.py" + return subprocess.list2cmdline(["py", "-3", str(hook_path), action]) def backup_file(path: Path) -> Path | None: @@ -8717,7 +8765,14 @@ def event_has_ringer_hook(groups: Any) -> bool: return False -def merge_ringer_hook(settings: dict[str, Any], event: str, matcher: str, command: str) -> bool: +def merge_ringer_hook( + settings: dict[str, Any], + event: str, + matcher: str, + command: str, + *, + command_windows: str | None = None, +) -> bool: hooks = settings.setdefault("hooks", {}) if not isinstance(hooks, dict): raise ValueError("settings hooks field must be a JSON object") @@ -8726,17 +8781,10 @@ def merge_ringer_hook(settings: dict[str, Any], event: str, matcher: str, comman raise ValueError(f"settings hooks.{event} field must be a JSON array") if event_has_ringer_hook(groups): return False - groups.append( - { - "matcher": matcher, - "hooks": [ - { - "type": "command", - "command": command, - } - ], - } - ) + handler = {"type": "command", "command": command} + if command_windows: + handler["commandWindows"] = command_windows + groups.append({"matcher": matcher, "hooks": [handler]}) return True @@ -8777,16 +8825,26 @@ def remove_ringer_hooks(settings: dict[str, Any]) -> int: return removed -def install_agent(project: bool = False) -> int: - root = claude_root(project) +def install_agent(project: bool = False, agent: str = "claude") -> int: + if agent == "claude": + skill_root = claude_root(project) + settings_root = skill_root + settings_name = "settings.json" + elif agent == "codex": + skill_root = codex_skill_root(project) + settings_root = codex_config_root(project) + settings_name = "hooks.json" + else: + raise ValueError(f"unsupported agent: {agent}") + skill_source = ringer_skill_source() - skill_target = root / "skills" / "ringer" / "SKILL.md" + skill_target = skill_root / "skills" / "ringer" if not skill_source.exists(): raise ValueError(f"ringer skill source not found: {skill_source}") skill_target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(skill_source, skill_target) + shutil.copytree(skill_source, skill_target, dirs_exist_ok=True) - settings_path = root / "settings.json" + settings_path = settings_root / settings_name settings = load_settings(settings_path) changed = False changed |= merge_ringer_hook( @@ -8794,18 +8852,20 @@ def install_agent(project: bool = False) -> int: "PreToolUse", "Bash", ringer_hook_command("pre-bash"), + command_windows=ringer_hook_command_windows("pre-bash") if agent == "codex" else None, ) changed |= merge_ringer_hook( settings, "PostToolUse", "Edit|Write", ringer_hook_command("post-edit"), + command_windows=ringer_hook_command_windows("post-edit") if agent == "codex" else None, ) if changed or not settings_path.exists(): write_settings(settings_path, settings) scope = "project" if project else "user" - print(f"Installed ringer agent for {scope} scope.") + print(f"Installed ringer agent for {agent} ({scope} scope).") print(f"Skill: {skill_target}") if changed: print(f"Hooks: added PreToolUse Bash and PostToolUse Edit|Write in {settings_path}") @@ -8814,9 +8874,15 @@ def install_agent(project: bool = False) -> int: return 0 -def uninstall_agent(project: bool = False) -> int: - root = claude_root(project) - settings_path = root / "settings.json" +def uninstall_agent(project: bool = False, agent: str = "claude") -> int: + if agent == "claude": + skill_root = claude_root(project) + settings_path = skill_root / "settings.json" + elif agent == "codex": + skill_root = codex_skill_root(project) + settings_path = codex_config_root(project) / "hooks.json" + else: + raise ValueError(f"unsupported agent: {agent}") removed_hooks = 0 if settings_path.exists(): settings = load_settings(settings_path) @@ -8824,14 +8890,14 @@ def uninstall_agent(project: bool = False) -> int: if removed_hooks: write_settings(settings_path, settings) - skill_dir = root / "skills" / "ringer" + skill_dir = skill_root / "skills" / "ringer" removed_skill = False if skill_dir.exists(): shutil.rmtree(skill_dir) removed_skill = True scope = "project" if project else "user" - print(f"Uninstalled ringer agent for {scope} scope.") + print(f"Uninstalled ringer agent for {agent} ({scope} scope).") print(f"Hooks removed: {removed_hooks}") print(f"Skill removed: {'yes' if removed_skill else 'no'}") return 0 @@ -9023,11 +9089,13 @@ def build_parser() -> argparse.ArgumentParser: ) demo_parser.add_argument("--dry-run", action="store_true", help="print the demo plan without spawning codex") - install_parser = subparsers.add_parser("install-agent", help="install the ringer Claude Code skill and hooks") - install_parser.add_argument("--project", action="store_true", help="install into ./.claude instead of ~/.claude") + install_parser = subparsers.add_parser("install-agent", help="install the ringer orchestrator skill and hooks") + install_parser.add_argument("--agent", choices=("claude", "codex"), default="claude", help="orchestrating agent to configure (default: claude)") + install_parser.add_argument("--project", action="store_true", help="install at project scope instead of user scope") - uninstall_parser = subparsers.add_parser("uninstall-agent", help="remove the ringer Claude Code skill and hooks") - uninstall_parser.add_argument("--project", action="store_true", help="remove from ./.claude instead of ~/.claude") + uninstall_parser = subparsers.add_parser("uninstall-agent", help="remove the ringer orchestrator skill and hooks") + uninstall_parser.add_argument("--agent", choices=("claude", "codex"), default="claude", help="orchestrating agent to clean up (default: claude)") + uninstall_parser.add_argument("--project", action="store_true", help="remove from project scope instead of user scope") return parser @@ -9039,9 +9107,9 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: if args.command == "install-agent": - return install_agent(project=args.project) + return install_agent(project=args.project, agent=args.agent) if args.command == "uninstall-agent": - return uninstall_agent(project=args.project) + return uninstall_agent(project=args.project, agent=args.agent) if args.command == "lint": manifest = Manifest.from_path(args.manifest) diff --git a/tests/test_agent_install.py b/tests/test_agent_install.py index 8f339f5..9189b97 100644 --- a/tests/test_agent_install.py +++ b/tests/test_agent_install.py @@ -3,18 +3,31 @@ import json import os +import shutil import subprocess import sys -import tempfile import unittest +import uuid from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +class WorkspaceTemporaryDirectory: + def __init__(self) -> None: + root = ROOT / ".test-tmp" + root.mkdir(exist_ok=True) + self.path = root / f"agent-install-{uuid.uuid4().hex}" + self.path.mkdir() + self.name = str(self.path) + + def cleanup(self) -> None: + shutil.rmtree(self.path, ignore_errors=True) + + class AgentInstallTests(unittest.TestCase): def setUp(self) -> None: - self.tmp = tempfile.TemporaryDirectory() + self.tmp = WorkspaceTemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.home = Path(self.tmp.name) / "home" self.ringer_home = Path(self.tmp.name) / "ringer-home" @@ -23,6 +36,7 @@ def setUp(self) -> None: def run_cli(self, *args: str, cwd: Path = ROOT) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env["HOME"] = str(self.home) + env["USERPROFILE"] = str(self.home) env["RINGER_HOME"] = str(self.ringer_home) return subprocess.run( [sys.executable, "ringer.py", *args], @@ -38,6 +52,10 @@ def read_settings(self, root: Path | None = None) -> dict[str, object]: base = self.home if root is None else root return json.loads((base / ".claude" / "settings.json").read_text(encoding="utf-8")) + def read_codex_hooks(self, root: Path | None = None) -> dict[str, object]: + base = self.home if root is None else root + return json.loads((base / ".codex" / "hooks.json").read_text(encoding="utf-8")) + def ringer_handlers(self, settings: dict[str, object]) -> list[dict[str, object]]: handlers: list[dict[str, object]] = [] hooks = settings.get("hooks") @@ -73,6 +91,63 @@ def test_fresh_install_creates_skill_copy_and_hook_entries(self) -> None: self.assertIn("ringer_nudge.py", hooks["PostToolUse"][0]["hooks"][0]["command"]) self.assertTrue(hooks["PostToolUse"][0]["hooks"][0]["command"].endswith(" post-edit")) + def test_codex_install_uses_agents_skill_and_codex_hooks(self) -> None: + result = self.run_cli("install-agent", "--agent", "codex") + self.assertEqual(0, result.returncode, result.stderr) + + skill = self.home / ".agents" / "skills" / "ringer" / "SKILL.md" + self.assertTrue(skill.exists()) + metadata = skill.parent / "agents" / "openai.yaml" + self.assertTrue(metadata.exists()) + self.assertEqual( + (ROOT / ".claude" / "skills" / "ringer" / "agents" / "openai.yaml").read_text(), + metadata.read_text(), + ) + hooks = self.read_codex_hooks()["hooks"] + pre_handler = hooks["PreToolUse"][0]["hooks"][0] + post_handler = hooks["PostToolUse"][0]["hooks"][0] + self.assertEqual("Bash", hooks["PreToolUse"][0]["matcher"]) + self.assertEqual("Edit|Write", hooks["PostToolUse"][0]["matcher"]) + self.assertIn("ringer_nudge.py", pre_handler["command"]) + self.assertIn("ringer_nudge.py", pre_handler["commandWindows"]) + self.assertIn("py -3", pre_handler["commandWindows"]) + self.assertIn("ringer_nudge.py", post_handler["commandWindows"]) + + def test_codex_install_and_uninstall_preserve_unrelated_hooks(self) -> None: + codex = self.home / ".codex" + codex.mkdir() + hooks_path = codex / "hooks.json" + hooks_path.write_text( + json.dumps( + { + "custom": {"keep": True}, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{"type": "command", "command": "echo keep-me"}], + } + ] + }, + } + ), + encoding="utf-8", + ) + + first = self.run_cli("install-agent", "--agent", "codex") + second = self.run_cli("install-agent", "--agent", "codex") + self.assertEqual(0, first.returncode, first.stderr) + self.assertEqual(0, second.returncode, second.stderr) + installed = self.read_codex_hooks() + self.assertEqual(2, len(self.ringer_handlers(installed))) + + uninstall = self.run_cli("uninstall-agent", "--agent", "codex") + self.assertEqual(0, uninstall.returncode, uninstall.stderr) + after = self.read_codex_hooks() + self.assertEqual({"keep": True}, after["custom"]) + self.assertEqual([], self.ringer_handlers(after)) + self.assertFalse((self.home / ".agents" / "skills" / "ringer").exists()) + def test_second_install_is_idempotent(self) -> None: first = self.run_cli("install-agent") self.assertEqual(0, first.returncode, first.stderr) @@ -171,6 +246,23 @@ def test_project_variant_writes_under_temp_cwd(self) -> None: settings = self.read_settings(project) self.assertEqual([], self.ringer_handlers(settings)) + def test_codex_project_variant_writes_under_temp_cwd(self) -> None: + project = Path(self.tmp.name) / "codex-project" + project.mkdir() + os.symlink(ROOT / "ringer.py", project / "ringer.py") + + install = self.run_cli("install-agent", "--agent", "codex", "--project", cwd=project) + self.assertEqual(0, install.returncode, install.stderr) + self.assertTrue((project / ".agents" / "skills" / "ringer" / "SKILL.md").exists()) + self.assertTrue((project / ".codex" / "hooks.json").exists()) + self.assertFalse((self.home / ".agents").exists()) + + uninstall = self.run_cli("uninstall-agent", "--agent", "codex", "--project", cwd=project) + self.assertEqual(0, uninstall.returncode, uninstall.stderr) + self.assertFalse((project / ".agents" / "skills" / "ringer").exists()) + hooks = self.read_codex_hooks(project) + self.assertEqual([], self.ringer_handlers(hooks)) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..4ac0c9a --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,37 @@ +import json +import shutil +import unittest +import uuid +from pathlib import Path +from unittest import mock + +from ringer import create_demo_manifest + + +ROOT = Path(__file__).resolve().parents[1] + + +class DemoManifestTests(unittest.TestCase): + def test_task_specs_disambiguate_literal_content_from_punctuation(self) -> None: + temp_root = ROOT / ".test-tmp" + temp_root.mkdir(exist_ok=True) + + def create_workspace_temp(*, prefix: str) -> str: + path = temp_root / f"{prefix}{uuid.uuid4().hex}" + path.mkdir() + return str(path) + + with mock.patch("ringer.tempfile.mkdtemp", side_effect=create_workspace_temp): + manifest_path = create_demo_manifest() + self.addCleanup(shutil.rmtree, manifest_path.parent, ignore_errors=True) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + for task in manifest["tasks"]: + expected = f"{task['key']} ready" + self.assertIn(f'complete contents must be exactly "{expected}"', task["spec"]) + self.assertIn("There is no period in the file content", task["spec"]) + self.assertNotIn(f"exactly: {expected}.", task["spec"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nudge_hook.py b/tests/test_nudge_hook.py index af9cebb..a463256 100644 --- a/tests/test_nudge_hook.py +++ b/tests/test_nudge_hook.py @@ -3,16 +3,31 @@ import json import os +import shutil import subprocess import sys -import tempfile import unittest +import uuid from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] HOOK = ROOT / "hooks" / "ringer_nudge.py" + + +class WorkspaceTemporaryDirectory: + def __init__(self) -> None: + root = ROOT / ".test-tmp" + root.mkdir(exist_ok=True) + self.path = root / f"nudge-hook-{uuid.uuid4().hex}" + self.path.mkdir() + self.name = str(self.path) + + def cleanup(self) -> None: + shutil.rmtree(self.path, ignore_errors=True) + + NUDGE_TEXT = ( "Ringer routing check: this looks like swarm-shaped work happening inline " "(model call/harness/edit loop outside a live Ringer run). Load the ringer " @@ -23,7 +38,7 @@ class NudgeHookTests(unittest.TestCase): def setUp(self) -> None: - self.temp = tempfile.TemporaryDirectory() + self.temp = WorkspaceTemporaryDirectory() self.addCleanup(self.temp.cleanup) self.home = Path(self.temp.name) / "home" self.ringer_home = Path(self.temp.name) / "ringer" @@ -66,6 +81,17 @@ def post_edit_payload( "tool_response": {"success": True}, } + def codex_patch_payload(self, file_path: str, session_id: str = "codex-session") -> dict[str, object]: + return { + "session_id": session_id, + "hook_event_name": "PostToolUse", + "tool_name": "apply_patch", + "tool_input": { + "command": f"*** Begin Patch\n*** Update File: {file_path}\n@@\n-old\n+new\n*** End Patch" + }, + "tool_response": {"success": True}, + } + def assertNudged(self, proc: subprocess.CompletedProcess[str], event_name: str) -> None: self.assertEqual(0, proc.returncode) data = json.loads(proc.stdout) @@ -117,6 +143,19 @@ def test_pre_bash_stays_silent_when_active_run_has_live_pid(self) -> None: proc = self.run_hook("pre-bash", self.pre_bash_payload("node probe-simulate.mjs")) self.assertSilent(proc) + @unittest.skipUnless(os.name == "nt", "Windows-specific process probe behavior") + def test_live_pid_probe_does_not_terminate_process_on_windows(self) -> None: + helper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + self.write_active_run(helper.pid) + proc = self.run_hook("pre-bash", self.pre_bash_payload("node probe-simulate.mjs")) + self.assertSilent(proc) + self.assertIsNone(helper.poll(), "liveness probe terminated the process it inspected") + finally: + if helper.poll() is None: + helper.terminate() + helper.wait(timeout=5) + def test_pre_bash_dedupes_per_session(self) -> None: first = self.run_hook("pre-bash", self.pre_bash_payload("node probe-simulate.mjs")) second = self.run_hook("pre-bash", self.pre_bash_payload("curl https://api.openai.com/v1/chat/completions")) @@ -147,6 +186,15 @@ def test_post_edit_stays_silent_for_seven_edits_and_two_files(self) -> None: for file_path in files: self.assertSilent(self.run_hook("post-edit", self.post_edit_payload(file_path, "session-2"))) + def test_codex_apply_patch_paths_trigger_edit_loop_nudge(self) -> None: + files = ["a.py", "a.py", "b.py", "b.py", "a.py", "b.py", "a.py", "c.py"] + for file_path in files[:-1]: + self.assertSilent(self.run_hook("post-edit", self.codex_patch_payload(file_path))) + self.assertNudged( + self.run_hook("post-edit", self.codex_patch_payload(files[-1])), + "PostToolUse", + ) + def test_malformed_stdin_exits_zero_silently(self) -> None: proc = self.run_hook("pre-bash", "{not json") self.assertSilent(proc) From 34bda15e1c973344d213987cee12a23291871174 Mon Sep 17 00:00:00 2001 From: David <289305201+victurbo37-debug@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:43:01 -0400 Subject: [PATCH 2/3] feat: add multi-model worker engines --- .claude/skills/ringer/SKILL.md | 27 ++-- README.md | 54 ++++--- config.sample.toml | 108 ++++++-------- engines/opencode-sandboxed-linux.sh | 127 +++++++++++++++++ registry/model-identity.toml | 39 +++++ ringer.py | 157 +++++++++++++++++++- tests/test_linux_sandbox_wrapper.py | 62 ++++++++ tests/test_worker_engines.py | 212 ++++++++++++++++++++++++++++ 8 files changed, 680 insertions(+), 106 deletions(-) create mode 100755 engines/opencode-sandboxed-linux.sh create mode 100644 tests/test_linux_sandbox_wrapper.py create mode 100644 tests/test_worker_engines.py diff --git a/.claude/skills/ringer/SKILL.md b/.claude/skills/ringer/SKILL.md index fb0c64d..3deb27a 100644 --- a/.claude/skills/ringer/SKILL.md +++ b/.claude/skills/ringer/SKILL.md @@ -61,8 +61,8 @@ runtime. `--no-dashboard` except in automated tests or when the user explicitly asks. -Ringer runs manifest tasks in parallel across cheap CLI workers (Codex, -OpenCode/GLM, others via config) and verifies every task by **executing a +Ringer runs manifest tasks in parallel across CLI workers (Codex, Cursor, +Claude Code, OpenCode/OpenRouter, and custom engines) and verifies every task by **executing a check command** — exit 0 is the only PASS. Failed tasks are retried once with the check's actual failure output injected into the retry prompt. You — the orchestrating model — pay tokens only for specs, orchestration, and @@ -224,10 +224,12 @@ audition one rung up in adjacent types; repeated first-attempt failures end the audition — record the demotion in MODEL-NOTES so the next orchestrator doesn't re-run the experiment. -**OpenCode is the harness; the model is a manifest field.** Unless a model -ships its own first-class harness (Codex does), it runs through the -`opencode` engine with the task's `"model"` field set to the OpenRouter -slug — e.g. `"engine": "opencode", "model": "openrouter/moonshotai/kimi-k2.7-code"`. +**The harness and model are separate manifest fields.** Use first-class +harnesses when the account provides them: `codex` for OpenAI/Codex access, +`cursor` for Cursor-enabled models such as Composer and Grok, and `claude` +for Claude subscription access. Use `opencode` for OpenRouter models with +the task's `"model"` field set to the full OpenRouter slug — e.g. +`"engine": "opencode", "model": "openrouter/moonshotai/kimi-k2.7-code"`. This holds even when someone — including the user, in the heat of a run — says to "call kimi directly" or reach for the model's own CLI: the harness is what provides the sandbox, raw logs, token counts, and executed @@ -237,13 +239,20 @@ what the `model` field is for, and a bakeoff is only real when the MANIFEST names each competitor (2026-07-06 lesson: an engine block with a hard-coded model ran one model under three competitors' names). -Engines are config blocks (`[engines.]` in config.toml), selectable -per task via the manifest `engine` field. Defaults are deliberate: +Built-in engines can be overridden with `[engines.]` in config.toml +and are selectable per task via the manifest `engine` field. Defaults are deliberate: - **codex** (default): strongest general worker. Use per-task `engine_args` to set reasoning effort — spend it on hard tasks, not boilerplate. +- **cursor**: the Cursor subscription lane. Pick an exact slug from + `cursor-agent models`; use Composer and Grok here rather than inventing a + direct Grok engine. +- **claude**: the Claude subscription lane. Use an explicit model and keep + Claude's OS sandbox prerequisites installed (`bubblewrap` + `socat` on + Linux/WSL2). - **opencode**: the universal lane — any OpenRouter model via the `model` - field (engine `model_default` is GLM-5.2, the cheap-intelligence pick). + field. There is no built-in model default; name the full slug so routing + cannot drift. Linux/WSL2 runs behind Ringer's bubblewrap wrapper. Validate a model new to you with a trivial one-task manifest before trusting it with a batch. - Small/flash-class models are the first to choke on long conversational or diff --git a/README.md b/README.md index 2f49554..2a63cc3 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,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 four built-in worker harnesses: **Codex CLI** is the default; **Cursor Agent** routes account-enabled models such as Composer and Grok; **Claude Code** uses a Claude subscription; and **OpenCode** routes explicit OpenRouter models. Anything else with a headless CLI is a config block away: ```toml [engines.mymodel] @@ -176,47 +176,45 @@ args_template = ["run", "{spec}", "--dir", "{taskdir}"] Per-task `"engine": "mymodel"` routes work to it — the invariants (stdin closed, process-group kill, executed verification, raw logs) apply to every engine identically. -### The universal harness: OpenCode + OpenRouter +### Cursor account lane: Composer and Grok -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. +Install the native CLI in the same Linux or WSL environment that runs Ringer, then authenticate it: -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). +```bash +curl https://cursor.com/install -fsS | bash +cursor-agent login +cursor-agent models +``` -Setting it up takes about five minutes: +Route a task with `"engine": "cursor"` and an explicit model slug from `cursor-agent models`. For example, `"model": "composer-2.5-fast"` selects Cursor's fast Composer lane and `"model": "grok-4.5-fast-high"` names its Grok 4.5 medium-effort fast lane. A listed slug is not proof of account entitlement: Cursor Free accounts can list named models that the server still rejects, so run a one-task probe before assigning a batch. Ringer invokes Cursor headlessly with structured output, `--force`, an explicit workspace, and `--sandbox enabled`; full access switches to `--sandbox disabled` only when both the task and Ringer config allow it. -```bash -# 1) Install the OpenCode CLI (pick one) -curl -fsSL https://opencode.ai/install | bash -# or: npm install -g opencode-ai -# or: brew install anomalyco/tap/opencode +### Claude subscription lane -# 2) Connect OpenRouter — create a key at https://openrouter.ai/settings/keys -opencode auth login # select OpenRouter, paste the key +Claude Code can use a Claude Pro or Max subscription without an Anthropic API 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.) -``` +```bash +curl -fsSL https://claude.ai/install.sh | bash +claude auth login --claudeai -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. +# Linux/WSL2 prerequisites for Claude's OS sandbox +sudo apt-get install bubblewrap socat +``` -### The plan lane: Grok Build CLI +Route a task with `"engine": "claude"` and an explicit model, such as the full model reported by a prior run. Ringer enables Claude's OS sandbox, sets `failIfUnavailable=true`, disables its unsandboxed-command escape hatch, uses structured output, and records the model from Claude's init event. Full access uses Claude's explicit permission bypass only when both Ringer gates allow it. -If you already pay for SuperGrok or X Premium Plus, Grok Build is a second flat-rate worker lane — no per-token bill: +### The universal harness: OpenCode + OpenRouter -```bash -# 1) Install (pick one) -curl -fsSL https://x.ai/cli/install.sh | bash -# or: npm install -g @xai-official/grok +OpenCode covers OpenRouter-served models through one engine. The task's `"model"` field is passed directly to the harness, so model selection remains explicit rather than drifting with a CLI default. -# 2) Sign in — OAuth on a SuperGrok or X Premium Plus plan -grok login +OpenCode does not supply its own OS boundary, so Ringer does. On Linux and WSL2, `engines/opencode-sandboxed-linux.sh` uses bubblewrap: only the task directory is mapped read/write, the system runtime is read-only, `HOME` is ephemeral, inherited environment variables are cleared, and the OpenCode credential is copied into that temporary home with owner-only permissions. On macOS, Ringer selects the Seatbelt wrapper. `--no-sandbox` remains behind Ringer's two-part full-access gate. -# 3) In ~/.config/ringer/config.toml, uncomment [engines.grok] +```bash +curl -fsSL https://opencode.ai/install | bash +opencode auth login # select OpenRouter +opencode models openrouter ``` -Route with per-task `"engine": "grok"` and pick the model with `"model": "grok-build"` or `"model": "grok-composer-2.5-fast"` (the shipped default — the speed pick). Grok brings its own OS sandbox on macOS (profile `workspace`: read everywhere, writes confined to the task dir, temp, and `~/.grok`), and its JSON output exposes no token counts — plan-billed workers report cost as included in plan. +Route with `"engine": "opencode"`, choose `"model": "openrouter//"`, and optionally pass a provider-supported reasoning variant through `engine_args`, for example `["--variant", "high"]`. The wrapper prioritizes the native Linux install at `~/.opencode/bin/opencode`, avoiding an accidental Windows npm shim in WSL. ## Ringside — mission control diff --git a/config.sample.toml b/config.sample.toml index b7476bd..d78f314 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -81,40 +81,41 @@ token_regex = "tokens\\s+used\\s*:?\\s*([0-9][0-9,]*)" # default reads the `model:` header; override it here only if that format changes. model_report_regex = "(?m)^model:[ \\t]*([^ \\t\\r\\n]+)[ \\t]*\\r?$" -# Grok Build CLI (xAI). Install: curl -fsSL https://x.ai/cli/install.sh | bash -# (or: npm install -g @xai-official/grok), then `grok login` — OAuth on a -# SuperGrok or X Premium Plus plan. Headless mode (-p) runs a full agentic -# loop with tools and exits. Grok brings its own OS sandbox (Seatbelt on -# macOS): profile "workspace" = read everywhere, write CWD + temp + ~/.grok, -# network allowed. Models: grok-build, grok-composer-2.5-fast (route with the -# per-task "model" field). Verified against Grok Build v0.2.81 (2026-07-06). -# Note: --no-auto-update is accepted but absent from --help in v0.2.81 (grok -# rejects unknown flags, so this is a hidden flag, not a typo) — it stops the -# CLI self-updating mid-swarm. -# Uncomment to enable. -# [engines.grok] -# bin = "grok" -# model_default = "grok-composer-2.5-fast" -# args_template = [ -# "--cwd", -# "{taskdir}", -# "{access_args}", -# "-m", -# "{model}", -# "--always-approve", -# "--no-auto-update", -# "--output-format", -# "json", -# "{engine_args}", -# "-p", -# "{spec}", -# ] -# sandbox_args = ["--sandbox", "workspace"] -# full_access_args = ["--sandbox", "off", "--permission-mode", "bypassPermissions"] -# Grok's JSON output carries no usage/token fields (verified v0.2.81) — plan -# CLIs report cost as "included in plan"; regex is a harmless no-match. Its JSON -# also does not self-report a model, so no model_report_regex is configured. -# token_regex = "\"total_tokens\"\\s*:\\s*([0-9]+)" +# Cursor Agent is also built in. Install: curl https://cursor.com/install -fsS | bash +# then `cursor-agent login`. Cursor exposes Composer, Grok, Claude, OpenAI, and +# other account-enabled models. Always set a task's "model" field explicitly; +# `cursor-agent models` prints the exact slugs available to the signed-in account. +# Free accounts may list named models that the server still rejects; probe first. +# Headless edits require --force. Sandbox mode remains explicit on every run. +[engines.cursor] +bin = "cursor-agent" +args_template = [ + "--print", + "--output-format", + "stream-json", + "--trust", + "--workspace", + "{taskdir}", + "--force", + "{access_args}", + "--model", + "{model}", + "{engine_args}", + "{spec}", +] +sandbox_args = ["--sandbox", "enabled"] +full_access_args = ["--sandbox", "disabled"] +model_report_regex = '"model"\s*:\s*"([^"]+)"' +model_report_aliases = { "Composer 2.5 Fast" = "composer-2.5-fast", "Cursor Grok 4.5 Medium Fast" = "grok-4.5-fast-high" } + +# Claude Code is built in. Install: curl -fsSL https://claude.ai/install.sh | bash +# then run `claude auth login --claudeai` to use a Pro or Max subscription. +# Ringer enables Claude's OS sandbox with failIfUnavailable=true and disables +# the unsandboxed-command escape hatch. Always set an explicit task model. +# Linux/WSL2 sandbox prerequisites: bubblewrap and socat. +# Override only machine-specific fields when needed: +# [engines.claude] +# bin = "claude" # For CI, acceptance evals, and trying Ringer without an API bill. # [engines.mock] @@ -126,39 +127,16 @@ 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 -# 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. +# OpenCode + OpenRouter is built in as the universal model harness. Ringer picks +# engines/opencode-sandboxed-linux.sh on Linux/WSL2 (bubblewrap) and the Seatbelt +# wrapper on macOS. The Linux wrapper exposes only the task directory read/write, +# uses an ephemeral HOME, and clears inherited environment variables. Install +# with `curl -fsSL https://opencode.ai/install | bash`, then `opencode auth login` +# and choose OpenRouter. Always set an explicit task model such as +# "openrouter/z-ai/glm-5.2". Per-task engine_args can set `--variant`. +# To pin a local default instead of requiring explicit task models: # [engines.opencode] -# bin = "/absolute/path/to/ringer/engines/opencode-sandboxed.sh" # model_default = "openrouter/z-ai/glm-5.2" -# args_template = [ -# "{taskdir}", -# "{access_args}", -# "run", -# "-m", -# "{model}", -# "--dangerously-skip-permissions", -# "--format", -# "json", -# "{engine_args}", -# "--dir", -# "{taskdir}", -# "{spec}", -# ] -# sandbox_args = [] -# full_access_args = ["--no-sandbox"] -# token_regex = '"tokens":\{"total":([0-9]+)' [artifact] # Zero-LLM, self-contained HTML artifacts rendered directly from state — no model calls, no diff --git a/engines/opencode-sandboxed-linux.sh b/engines/opencode-sandboxed-linux.sh new file mode 100755 index 0000000..ca389c5 --- /dev/null +++ b/engines/opencode-sandboxed-linux.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Ringer engine wrapper: run OpenCode under a Linux/WSL2 bubblewrap sandbox. +# +# Usage (as a Ringer engine bin): +# opencode-sandboxed-linux.sh [--no-sandbox] +# +# Sandboxed mode exposes a minimal read-only system, maps only the task directory +# read/write at /workspace, uses an ephemeral HOME, clears inherited environment +# variables, and copies OpenCode auth into that ephemeral HOME with mode 600. +# Network remains available for the model provider. Full-access mode is an +# explicit --no-sandbox escape wired through Ringer's allow_full_access gate. +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 + rewritten=() + for arg in "$@"; do + if [ "$arg" = "/workspace" ]; then + rewritten+=("$TASKDIR_REAL") + else + rewritten+=("$arg") + fi + done + cd "$TASKDIR_REAL" + exec "$OPENCODE_BIN" "${rewritten[@]}" < /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" +mkdir -p \ + "$SANDBOX_HOME/.cache" \ + "$SANDBOX_HOME/.config/opencode" \ + "$SANDBOX_HOME/.local/share/opencode" \ + "$SANDBOX_HOME/.local/state/opencode" + +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)" +OPENCODE_NAME="$(basename "$OPENCODE_BIN")" +sandbox_args=() +for arg in "$@"; do + if [ "$arg" = "$TASKDIR_REAL" ]; then + sandbox_args+=("/workspace") + else + sandbox_args+=("$arg") + fi +done + +set +e +"$BWRAP_BIN" \ + --die-with-parent \ + --new-session \ + --unshare-user \ + --unshare-pid \ + --unshare-uts \ + --unshare-ipc \ + --share-net \ + --clearenv \ + --ro-bind /usr /usr \ + --ro-bind-try /bin /bin \ + --ro-bind-try /sbin /sbin \ + --ro-bind-try /lib /lib \ + --ro-bind-try /lib64 /lib64 \ + --ro-bind /etc /etc \ + --ro-bind-try /sys /sys \ + --dir /mnt \ + --ro-bind-try /mnt/wsl /mnt/wsl \ + --proc /proc \ + --dev /dev \ + --tmpfs /tmp \ + --dir /opt \ + --ro-bind "$OPENCODE_DIR" /opt/opencode \ + --dir /home \ + --bind "$SANDBOX_HOME" /home/ringer \ + --dir /workspace \ + --bind "$TASKDIR_REAL" /workspace \ + --setenv HOME /home/ringer \ + --setenv PATH /opt/opencode:/usr/local/bin:/usr/bin:/bin \ + --setenv TMPDIR /tmp \ + --setenv XDG_CACHE_HOME /home/ringer/.cache \ + --setenv XDG_CONFIG_HOME /home/ringer/.config \ + --setenv XDG_DATA_HOME /home/ringer/.local/share \ + --setenv XDG_STATE_HOME /home/ringer/.local/state \ + --setenv LANG C.UTF-8 \ + --chdir /workspace \ + "/opt/opencode/$OPENCODE_NAME" "${sandbox_args[@]}" < /dev/null +status=$? +set -e +exit "$status" diff --git a/registry/model-identity.toml b/registry/model-identity.toml index 42a245c..0a9d4f2 100644 --- a/registry/model-identity.toml +++ b/registry/model-identity.toml @@ -40,6 +40,45 @@ confidence = "verified" source = "https://developers.openai.com/codex/models" last_verified = 2026-07-10 +[engines.cursor] +harness = "Cursor Agent" +access = "Cursor account" + +[engines.cursor.models."composer-2.5-fast"] +display = "Composer 2.5 Fast" +lab = "Cursor (Anysphere)" +confidence = "verified" +source = "authenticated Cursor Agent stream-json init event" +last_verified = 2026-07-12 + +[engines.cursor.models."grok-4.5-fast-high"] +display = "Grok 4.5 Medium Fast" +lab = "xAI" +confidence = "listed-only" +source = "authenticated model list; named run blocked on current Free tier" +last_verified = 2026-07-12 + +[engines.claude] +harness = "Claude Code" +access = "Claude Pro subscription" + +[engines.claude.models."claude-sonnet-5"] +display = "Claude Sonnet 5" +lab = "Anthropic" +confidence = "verified" +source = "authenticated Claude Code stream-json init event" +last_verified = 2026-07-12 + +[engines.claude.models."sonnet"] +display = "Claude Sonnet (rolling alias)" +lab = "Anthropic" +alias = true +confidence = "verified" +source = "https://code.claude.com/docs/en/cli-reference" +last_verified = 2026-07-12 + +# Legacy identity for historical direct-Grok runs. Grok is not a built-in +# worker engine; current Cursor-hosted Grok models route through engines.cursor. [engines.grok] harness = "Grok Build CLI" access = "OAuth plan" diff --git a/ringer.py b/ringer.py index 93f092d..2aec349 100755 --- a/ringer.py +++ b/ringer.py @@ -59,6 +59,16 @@ CATALOG_FETCH_TIMEOUT_S = 5 DEFAULT_TOKEN_REGEX = r"tokens\s+used\s*:?\s*([0-9][0-9,]*)" DEFAULT_CODEX_MODEL_REPORT_REGEX = r"(?m)^model:[ \t]*([^ \t\r\n]+)[ \t]*\r?$" +CLAUDE_SANDBOX_SETTINGS = json.dumps( + { + "sandbox": { + "enabled": True, + "failIfUnavailable": True, + "allowUnsandboxedCommands": False, + } + }, + separators=(",", ":"), +) ACTIVITY_TAIL_BYTES = 2048 ACTIVITY_TEXT_LIMIT = 80 ARTIFACT_WRAPPER_TAIL_BYTES = 256 * 1024 @@ -118,6 +128,9 @@ class EngineConfig: # its own "model" — this is what makes a harness engine (OpenCode) model # agnostic instead of hard-coding one model into the command line. model_default: str = "" + # Canonicalizes display labels emitted by harnesses that do not report the + # same stable slug accepted by their --model option. + model_report_aliases: tuple[tuple[str, str], ...] = () @property def process_name(self) -> str: @@ -517,6 +530,105 @@ def built_in_codex_engine() -> EngineConfig: ) +def built_in_cursor_engine() -> EngineConfig: + resolved = shutil.which("cursor-agent") or "cursor-agent" + return EngineConfig( + name="cursor", + bin=resolved, + args_template=( + "--print", + "--output-format", + "stream-json", + "--trust", + "--workspace", + "{taskdir}", + "--force", + "{access_args}", + "--model", + "{model}", + "{engine_args}", + "{spec}", + ), + full_access_args=("--sandbox", "disabled"), + sandbox_args=("--sandbox", "enabled"), + token_regex=None, + model_report_regex=r'"model"\s*:\s*"([^"]+)"', + model_report_aliases=( + ("Composer 2.5 Fast", "composer-2.5-fast"), + ("Cursor Grok 4.5 Medium Fast", "grok-4.5-fast-high"), + ), + model_default="", + ) + + +def built_in_claude_engine() -> EngineConfig: + resolved = shutil.which("claude") or "claude" + return EngineConfig( + name="claude", + bin=resolved, + args_template=( + "--print", + "--output-format", + "stream-json", + "--verbose", + "--no-session-persistence", + "--no-chrome", + "--disable-slash-commands", + "--strict-mcp-config", + "--mcp-config", + '{"mcpServers":{}}', + "{access_args}", + "--model", + "{model}", + "{engine_args}", + "{spec}", + ), + full_access_args=("--dangerously-skip-permissions",), + sandbox_args=( + "--permission-mode", + "acceptEdits", + "--settings", + CLAUDE_SANDBOX_SETTINGS, + ), + token_regex=None, + model_report_regex=r'"model"\s*:\s*"([^"]+)"', + model_default="", + ) + + +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 = {} @@ -553,7 +665,12 @@ 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()} + engines: dict[str, EngineConfig] = { + DEFAULT_ENGINE_NAME: built_in_codex_engine(), + "cursor": built_in_cursor_engine(), + "claude": built_in_claude_engine(), + "opencode": built_in_opencode_engine(), + } if raw is None: return engines if not isinstance(raw, dict): @@ -605,6 +722,25 @@ def load_engines(raw: Any) -> dict[str, EngineConfig]: raise ValueError( f"engines.{clean_name}.model_report_regex must have a capture group" ) + aliases_raw = section.get("model_report_aliases") + if aliases_raw is None: + model_report_aliases = base.model_report_aliases if base else () + else: + if not isinstance(aliases_raw, dict): + raise ValueError( + f"engines.{clean_name}.model_report_aliases must be a TOML table" + ) + alias_items: list[tuple[str, str]] = [] + for reported_raw, canonical_raw in aliases_raw.items(): + reported = str(reported_raw).strip() + canonical = str(canonical_raw).strip() + if not reported or not canonical: + raise ValueError( + f"engines.{clean_name}.model_report_aliases keys and values " + "must not be empty" + ) + alias_items.append((reported, canonical)) + model_report_aliases = tuple(alias_items) model_default = str( section.get("model_default", base.model_default if base else "") ).strip() @@ -616,6 +752,7 @@ def load_engines(raw: Any) -> dict[str, EngineConfig]: sandbox_args=sandbox_args, token_regex=token_regex, model_report_regex=model_report_regex, + model_report_aliases=model_report_aliases, model_default=model_default, ) return engines @@ -7801,7 +7938,11 @@ async def _run_worker(self, runtime: TaskRuntime, spec: str, attempt: int) -> Wo self.active_processes.pop(proc.pid, None) output_tail = capture.text() tokens = parse_token_count(output_tail, engine.token_regex) - reported_model = parse_reported_model(output_tail, engine.model_report_regex) + reported_model = parse_reported_model( + output_tail, + engine.model_report_regex, + dict(engine.model_report_aliases), + ) if timed_out: append_text(log_path, f"\n[ringer.py] worker timed out after {runtime.task.timeout_s}s\n") append_text(log_path, f"[ringer.py] attempt {attempt} exited rc={proc.returncode}\n") @@ -8121,14 +8262,20 @@ def parse_token_count(text: str, token_regex: str | None = DEFAULT_TOKEN_REGEX) return int(matches[-1].replace(",", "")) -def parse_reported_model(text: str, model_report_regex: str | None) -> str | None: +def parse_reported_model( + text: str, + model_report_regex: str | None, + model_report_aliases: dict[str, str] | None = None, +) -> str | None: if not model_report_regex: return None match = re.search(model_report_regex, text, flags=re.IGNORECASE) if match is None or match.lastindex is None: return None value = match.group(1).strip() - return value or None + if not value: + return None + return (model_report_aliases or {}).get(value, value) def effective_model_from_command(command: list[str]) -> str: @@ -8252,7 +8399,9 @@ def print_steering_notes(manifest: Manifest, config: AppConfig) -> None: ENGINE_INSTALL_HINTS = { + "claude": "install it with `curl -fsSL https://claude.ai/install.sh | bash`, then run `claude` and choose Claude App login", "codex": "install it with `npm install -g @openai/codex` (or `brew install --cask codex`), then run `codex login`", + "cursor": "install it with `curl https://cursor.com/install -fsS | bash`, then run `cursor-agent login`", "opencode": "install it with `curl -fsSL https://opencode.ai/install | bash`, then run `opencode auth login`", } diff --git a/tests/test_linux_sandbox_wrapper.py b/tests/test_linux_sandbox_wrapper.py new file mode 100644 index 0000000..6d61df5 --- /dev/null +++ b/tests/test_linux_sandbox_wrapper.py @@ -0,0 +1,62 @@ +#!/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_workspace_write_and_blocks_host_write(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_secret = root / "host-secret.txt" + host_secret.write_text("must stay outside sandbox\n", encoding="utf-8") + fake_bin_dir = root / "fake-bin" + fake_bin_dir.mkdir() + fake_worker = fake_bin_dir / "opencode" + fake_worker.write_text( + "#!/bin/sh\n" + "set -eu\n" + "if [ -n \"${RINGER_TEST_SECRET:-}\" ]; then exit 43; fi\n" + f"if [ -e {host_secret} ]; then exit 44; fi\n" + "printf 'allowed\\n' > /workspace/allowed.txt\n" + f"if printf 'blocked\\n' > {forbidden}; then exit 42; fi\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.assertFalse(forbidden.exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_worker_engines.py b/tests/test_worker_engines.py new file mode 100644 index 0000000..6ba8f8f --- /dev/null +++ b/tests/test_worker_engines.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from ringer import ( + ENGINE_INSTALL_HINTS, + build_worker_command, + load_engines, + load_model_identity_registry, + parse_reported_model, +) + + +class CursorEngineContractTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = load_engines(None)["cursor"] + + def test_cursor_requires_an_explicit_model_and_keeps_sandbox_enabled(self) -> None: + self.assertEqual("", self.engine.model_default) + taskdir = Path("/tmp/cursor-task") + command = build_worker_command( + self.engine, + taskdir=taskdir, + spec="write result.txt", + full_access=False, + model="composer-2.5-fast", + ) + self.assertEqual( + [ + self.engine.bin, + "--print", + "--output-format", + "stream-json", + "--trust", + "--workspace", + str(taskdir), + "--force", + "--sandbox", + "enabled", + "--model", + "composer-2.5-fast", + "write result.txt", + ], + command, + ) + + def test_cursor_full_access_is_explicitly_sandbox_disabled(self) -> None: + command = build_worker_command( + self.engine, + taskdir=Path("/tmp/cursor-task"), + spec="write result.txt", + full_access=True, + model="grok-4.5-fast-high", + ) + self.assertIn("disabled", command) + self.assertNotIn("enabled", command) + + def test_cursor_stream_reports_model_and_has_install_hint(self) -> None: + line = '{"type":"system","subtype":"init","model":"composer-2.5-fast"}' + self.assertEqual( + "composer-2.5-fast", + parse_reported_model( + line, + self.engine.model_report_regex, + dict(self.engine.model_report_aliases), + ), + ) + real_composer_init = ( + '{"type":"system","subtype":"init","model":"Composer 2.5 Fast"}' + ) + self.assertEqual( + "composer-2.5-fast", + parse_reported_model( + real_composer_init, + self.engine.model_report_regex, + dict(self.engine.model_report_aliases), + ), + ) + real_grok_init = ( + '{"type":"system","subtype":"init",' + '"model":"Cursor Grok 4.5 Medium Fast"}' + ) + self.assertEqual( + "grok-4.5-fast-high", + parse_reported_model( + real_grok_init, + self.engine.model_report_regex, + dict(self.engine.model_report_aliases), + ), + ) + self.assertIn("https://cursor.com/install", ENGINE_INSTALL_HINTS["cursor"]) + + def test_cursor_models_have_verified_harness_identities(self) -> None: + registry_path = Path(__file__).resolve().parents[1] / "registry" / "model-identity.toml" + registry = load_model_identity_registry(registry_path) + + composer = registry.resolve("cursor", "composer-2.5-fast") + self.assertEqual( + ("Composer 2.5 Fast", "Cursor (Anysphere)", "Cursor Agent", "Cursor account"), + (composer.model_display, composer.lab, composer.harness, composer.access), + ) + + grok = registry.resolve("cursor", "grok-4.5-fast-high") + self.assertEqual( + ("Grok 4.5 Medium Fast", "xAI", "Cursor Agent", "Cursor account"), + (grok.model_display, grok.lab, grok.harness, grok.access), + ) + + +class ClaudeEngineContractTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = load_engines(None)["claude"] + + def test_claude_requires_an_explicit_model_and_hard_fails_without_sandbox(self) -> None: + self.assertEqual("", self.engine.model_default) + command = build_worker_command( + self.engine, + taskdir=Path("/tmp/claude-task"), + spec="write result.txt", + full_access=False, + model="sonnet", + ) + self.assertEqual("claude", Path(command[0]).name) + self.assertIn("--print", command) + self.assertIn("stream-json", command) + self.assertIn("acceptEdits", command) + self.assertEqual( + {"mcpServers": {}}, + json.loads(command[command.index("--mcp-config") + 1]), + ) + settings = json.loads(command[command.index("--settings") + 1]) + self.assertEqual( + { + "enabled": True, + "failIfUnavailable": True, + "allowUnsandboxedCommands": False, + }, + settings["sandbox"], + ) + self.assertEqual("sonnet", command[command.index("--model") + 1]) + self.assertEqual("write result.txt", command[-1]) + + def test_claude_full_access_is_an_explicit_permission_bypass(self) -> None: + command = build_worker_command( + self.engine, + taskdir=Path("/tmp/claude-task"), + spec="write result.txt", + full_access=True, + model="sonnet", + ) + self.assertIn("--dangerously-skip-permissions", command) + self.assertNotIn("--settings", command) + + def test_claude_stream_reports_model_and_has_install_hint(self) -> None: + line = '{"type":"system","subtype":"init","model":"claude-sonnet-5"}' + self.assertEqual( + "claude-sonnet-5", + parse_reported_model(line, self.engine.model_report_regex), + ) + self.assertIn("https://claude.ai/install.sh", ENGINE_INSTALL_HINTS["claude"]) + + def test_claude_reported_model_has_verified_subscription_identity(self) -> None: + registry_path = Path(__file__).resolve().parents[1] / "registry" / "model-identity.toml" + identity = load_model_identity_registry(registry_path).resolve( + "claude", "claude-sonnet-5" + ) + self.assertEqual( + ("Claude Sonnet 5", "Anthropic", "Claude Code", "Claude Pro subscription"), + (identity.model_display, identity.lab, identity.harness, identity.access), + ) + + +class OpenCodeEngineContractTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = load_engines(None)["opencode"] + + def test_opencode_requires_an_explicit_openrouter_model_and_uses_linux_wrapper(self) -> None: + self.assertEqual("", self.engine.model_default) + taskdir = Path("/tmp/opencode-task") + command = build_worker_command( + self.engine, + taskdir=taskdir, + spec="write result.txt", + full_access=False, + model="openrouter/z-ai/glm-5.2", + ) + self.assertEqual("opencode-sandboxed-linux.sh", Path(command[0]).name) + 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]) + + def test_opencode_full_access_is_an_explicit_wrapper_bypass(self) -> None: + command = build_worker_command( + self.engine, + 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]) + self.assertIn("https://opencode.ai/install", ENGINE_INSTALL_HINTS["opencode"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 4f3dbceeb24ce96f04905d4285c919d904825256 Mon Sep 17 00:00:00 2001 From: David <289305201+victurbo37-debug@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:57:35 -0400 Subject: [PATCH 3/3] fix: verify Grok through Cursor Pro --- docs/MODEL-NOTES.md | 10 ++++++++++ registry/model-identity.toml | 4 ++-- tests/test_worker_engines.py | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/MODEL-NOTES.md b/docs/MODEL-NOTES.md index fbc2003..5b6855c 100644 --- a/docs/MODEL-NOTES.md +++ b/docs/MODEL-NOTES.md @@ -171,6 +171,16 @@ checks and raw logs support — no vibes, no worker self-reports. to k2.7. +## grok-4.5-fast-high (Cursor Agent, Cursor Pro) + +- 2026-07-12 — Pro entitlement probe: PASS attempt 1 in 7.8s. Cursor's + stream-json init event reported `Cursor Grok 4.5 Medium Fast`; the worker + wrote `solution.py`, and both Ringer's check and an independent execution + printed the exact `RINGER_GROK_PRO_OK` marker. The same slug had been blocked + when the account was on Free; after the Pro upgrade it is a verified Cursor + lane. Route it with `engine = "cursor"` and `model = "grok-4.5-fast-high"`. + + ## grok-build (Grok CLI engine, flat plan) - 2026-07-10 — identity correction (Jon): the Grok Build CLI is a HARNESS diff --git a/registry/model-identity.toml b/registry/model-identity.toml index 0a9d4f2..8c9f39b 100644 --- a/registry/model-identity.toml +++ b/registry/model-identity.toml @@ -54,8 +54,8 @@ last_verified = 2026-07-12 [engines.cursor.models."grok-4.5-fast-high"] display = "Grok 4.5 Medium Fast" lab = "xAI" -confidence = "listed-only" -source = "authenticated model list; named run blocked on current Free tier" +confidence = "verified" +source = "authenticated Cursor Agent stream-json init event and executed Ringer probe" last_verified = 2026-07-12 [engines.claude] diff --git a/tests/test_worker_engines.py b/tests/test_worker_engines.py index 6ba8f8f..86cf751 100644 --- a/tests/test_worker_engines.py +++ b/tests/test_worker_engines.py @@ -108,6 +108,8 @@ def test_cursor_models_have_verified_harness_identities(self) -> None: ("Grok 4.5 Medium Fast", "xAI", "Cursor Agent", "Cursor account"), (grok.model_display, grok.lab, grok.harness, grok.access), ) + self.assertEqual("verified", grok.confidence) + self.assertIn("executed Ringer probe", grok.source) class ClaudeEngineContractTests(unittest.TestCase):