From 09cea40494dc52ddd5b9c7773da77750bdfe47b5 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sun, 28 Jun 2026 23:11:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(operator):=20runner=20registry=20=E2=80=94?= =?UTF-8?q?=20spawn=20any=20agent=20CLI,=20not=20just=20Claude=20(P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the three Claude-hardcoded spots in bootstrap (launch argv, install plan, creds/config-dir) behind a Runner registry so a nested operator can be spawned with any agent CLI. The Claude path stays the default and byte-identical. - bootstrap.Runner + RUNNERS{claude,codex,gemini,cmd} + get_runner/runner_present - build_run_argv / install_plan / creds_source / plan_creds / child_env all take an optional `runner` (default claude → unchanged behaviour) - claude validated end-to-end; codex/gemini are best-effort defaults overridable via $HH__INSTALL etc.; generic `cmd` runner reads a full launch template from $HH_OPERATOR_CMD ({directive} placeholder) - bridge _op_spawn resolves req["runner"], rejects unknown; plan now carries runner/runner_present (was claude_present) - CLI: `spawn --runner {claude|codex|gemini|cmd}` - 7 new unit tests; full suite 132 passed Note: compose_directive still emits the Claude-flavoured ("hh-operator skill") directive — the portable Layer-1 prompt is P4. Co-Authored-By: Claude Opus 4.6 --- cmd_chat/operator/__main__.py | 7 +- cmd_chat/operator/bootstrap.py | 172 ++++++++++++++++++++++++++------- cmd_chat/operator/bridge.py | 19 ++-- tests/test_operator_bridge.py | 90 +++++++++++++++++ 4 files changed, 247 insertions(+), 41 deletions(-) diff --git a/cmd_chat/operator/__main__.py b/cmd_chat/operator/__main__.py index b83138d..0ba45be 100644 --- a/cmd_chat/operator/__main__.py +++ b/cmd_chat/operator/__main__.py @@ -87,11 +87,15 @@ def _build_parser() -> argparse.ArgumentParser: gt.add_argument("--out", default=None, help="write to this local path (else stdout)") gt.add_argument("--session", default=None) - sp = sub.add_parser("spawn", help="spawn a nested Claude operator (budgeted)") + sp = sub.add_parser("spawn", help="spawn a nested operator (budgeted)") sp.add_argument("objective", help="what the nested operator should achieve") sp.add_argument("--room-host", required=True) sp.add_argument("--room-port", type=int, required=True) sp.add_argument("--room-name", default="operator") + sp.add_argument("--runner", default="claude", + choices=["claude", "codex", "gemini", "cmd"], + help="which agent CLI to spawn as the child operator " + "(default: claude; 'cmd' reads $HH_OPERATOR_CMD)") sp.add_argument("--stop", action="append", default=None, help="a stop condition (repeatable)") sp.add_argument("--target", choices=["host"], default="host") @@ -366,6 +370,7 @@ def _run_spawn(args) -> int: "op": "spawn", "objective": args.objective, "room": {"host": args.room_host, "port": args.room_port, "name": args.room_name}, + "runner": args.runner, "stop": args.stop, "target": args.target, "config_dir": args.config_dir, "allow_creds": args.allow_creds, "skip_permissions": args.skip_permissions, "dry_run": not args.go}) diff --git a/cmd_chat/operator/bootstrap.py b/cmd_chat/operator/bootstrap.py index 4ee0074..8ce1014 100644 --- a/cmd_chat/operator/bootstrap.py +++ b/cmd_chat/operator/bootstrap.py @@ -26,6 +26,7 @@ from __future__ import annotations import os import platform +import shlex import shutil from dataclasses import dataclass, replace from pathlib import Path @@ -112,20 +113,110 @@ def claude_present() -> bool: return shutil.which("claude") is not None -def install_plan(sysinfo: dict | None = None) -> list[list[str]]: - """Ordered candidate commands to get the `claude` CLI onto the system. +# ── runner registry ────────────────────────────────────────────────────────── +# A *runner* captures everything that varies by which agent CLI we spawn as the +# nested operator. The Claude path stays the default and byte-identical; other +# CLIs are first-class but **best-effort** — their exact flags/creds paths are +# meant to be pinned per-site via the override env vars (or routed through the +# generic ``cmd`` runner). Only ``claude`` is validated end-to-end today. +@dataclass(frozen=True) +class Runner: + name: str + bin: str # executable to locate / invoke + prompt_flags: tuple[str, ...] = ("-p",) # flag(s) that precede the directive + unattended_flags: tuple[str, ...] = () # appended when skip_permissions + npm_package: str | None = None # for npm-installable CLIs + install_env: str | None = None # env var holding a full install cmd + cmd_env: str | None = None # generic template env var (the 'cmd' runner) + creds_path: str | None = None # ~-relative source credential file + config_env: str | None = None # env var for an isolated config dir - Honours $HH_CLAUDE_INSTALL (a full shell command) first so a site can pin - its own channel. Otherwise prefers the documented npm package; if npm is - missing, prefix the distro's node install. We never fabricate a download - URL — if none of these apply the caller surfaces "install claude manually".""" - override = os.environ.get("HH_CLAUDE_INSTALL") + def present(self) -> bool: + return bool(self.bin) and shutil.which(self.bin) is not None + + def argv(self, directive: str, *, skip_permissions: bool = False) -> list[str]: + """Launch argv for this runner. The generic ``cmd`` runner reads a full + command template from ``$cmd_env`` and substitutes ``{directive}``; + everything else is ``[bin, *prompt_flags, directive] (+ unattended)``.""" + if self.cmd_env: + tmpl = os.environ.get(self.cmd_env) + if not tmpl: + raise ValueError( + f"runner '{self.name}' needs ${self.cmd_env} set to a command " + "template (use {directive} as the placeholder)" + ) + if "{directive}" in tmpl: + return [p.replace("{directive}", directive) for p in shlex.split(tmpl)] + return [*shlex.split(tmpl), directive] + a = [self.bin, *self.prompt_flags, directive] + if skip_permissions: + a += list(self.unattended_flags) + return a + + def creds_source(self) -> Path | None: + if not self.creds_path: + return None + p = Path(self.creds_path).expanduser() + return p if p.exists() else None + + +RUNNERS: dict[str, Runner] = { + "claude": Runner( + "claude", "claude", ("-p",), ("--dangerously-skip-permissions",), + npm_package=CLAUDE_NPM_PACKAGE, install_env="HH_CLAUDE_INSTALL", + creds_path="~/.claude/.credentials.json", config_env="CLAUDE_CONFIG_DIR"), + # Best-effort defaults — override flags/install/creds via the env vars below + # or use the generic `cmd` runner if a CLI's interface differs. + "codex": Runner( + "codex", "codex", ("exec",), ("--dangerously-bypass-approvals-and-sandbox",), + npm_package="@openai/codex", install_env="HH_CODEX_INSTALL", + creds_path="~/.codex/auth.json", config_env="CODEX_HOME"), + "gemini": Runner( + "gemini", "gemini", ("-p",), ("--yolo",), + npm_package="@google/gemini-cli", install_env="HH_GEMINI_INSTALL", + creds_path="~/.gemini/oauth_creds.json", config_env="GEMINI_SYSTEM_MD"), + # Fully user-controlled escape hatch: $HH_OPERATOR_CMD is the launch template. + "cmd": Runner("cmd", "", cmd_env="HH_OPERATOR_CMD", install_env="HH_OPERATOR_INSTALL"), +} + + +def get_runner(runner: "Runner | str | None" = None) -> Runner: + """Resolve a runner by name (default ``claude``). Pass-through if already a + :class:`Runner`. Raises ``ValueError`` on an unknown name.""" + if isinstance(runner, Runner): + return runner + r = RUNNERS.get(runner or "claude") + if r is None: + raise ValueError( + f"unknown runner '{runner}' (known: {', '.join(RUNNERS)})" + ) + return r + + +def runner_present(runner: "Runner | str | None" = None) -> bool: + return get_runner(runner).present() + + +def install_plan(sysinfo: dict | None = None, *, + runner: "Runner | str | None" = None) -> list[list[str]]: + """Ordered candidate commands to get the runner's CLI onto the system. + + Honours the runner's install-override env (``$HH_CLAUDE_INSTALL`` for claude) + first so a site can pin its own channel. Otherwise prefers the runner's + documented npm package; if npm is missing, prefix the distro's node install. + We never fabricate a download URL — a runner with no npm package (e.g. the + generic ``cmd`` runner) returns ``[]`` and the caller surfaces "install + manually".""" + r = get_runner(runner) + override = os.environ.get(r.install_env) if r.install_env else None if override: return [["sh", "-c", override]] + if not r.npm_package: + return [] info = sysinfo or detect_system() plans: list[list[str]] = [] if info.get("has_npm"): - plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE]) + plans.append(["npm", "install", "-g", r.npm_package]) else: mgr = info.get("pkg_manager") node_install = { @@ -138,31 +229,38 @@ def install_plan(sysinfo: dict | None = None) -> list[list[str]]: }.get(mgr) if node_install: plans.append(node_install) - plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE]) + plans.append(["npm", "install", "-g", r.npm_package]) return plans # ── credentials (gated) ───────────────────────────────────────────────────── -def creds_source() -> Path | None: - """The parent session's own OAuth/API credentials file, if present.""" - p = Path.home() / ".claude" / ".credentials.json" - return p if p.exists() else None +def creds_source(runner: "Runner | str | None" = None) -> Path | None: + """The parent session's own credentials file for the runner, if present + (defaults to Claude's ``~/.claude/.credentials.json``).""" + return get_runner(runner).creds_source() -def plan_creds(dest_home: str | os.PathLike, *, allow: bool) -> dict: +def plan_creds(dest_home: str | os.PathLike, *, allow: bool, + runner: "Runner | str | None" = None) -> dict: """Decide whether to carry credentials into a child's config dir. Default (``allow=False``) → carry nothing; the child must authenticate - itself (`claude` interactive login or its own $ANTHROPIC_API_KEY). Only when - the operator *explicitly* opts in do we stage a copy of our own creds — and - only into a path we were handed. This function plans; the bridge performs the - copy so the side effect stays behind one audited flag.""" + itself (its own interactive login or API key). Only when the operator + *explicitly* opts in do we stage a copy of our own creds — and only into a + path we were handed. The destination mirrors the runner's own creds layout + under ``dest_home``. This function plans; the bridge performs the copy so the + side effect stays behind one audited flag.""" if not allow: return {"staged": False, "reason": "creds gating on — child authenticates itself"} - src = creds_source() + r = get_runner(runner) + src = r.creds_source() if src is None: return {"staged": False, "reason": "no parent credentials to carry"} - dest = Path(dest_home) / ".claude" / ".credentials.json" + try: + rel = Path(r.creds_path).expanduser().relative_to(Path.home()) + except ValueError: + rel = Path(Path(r.creds_path).name) + dest = Path(dest_home) / rel return {"staged": True, "src": str(src), "dest": str(dest)} @@ -195,21 +293,29 @@ def compose_directive(objective: str, room: dict, budget: Budget, def build_run_argv(directive: str, *, skip_permissions: bool = False, config_dir: str | None = None, + runner: "Runner | str | None" = None, claude_bin: str = "claude") -> list[str]: - """argv to launch a headless nested Claude session. `skip_permissions` maps - to Claude Code's unattended flag (only for trusted, sandboxed autonomy); - `config_dir` points the child at an isolated $CLAUDE_CONFIG_DIR so its creds - and state don't collide with the parent.""" - argv = [claude_bin, "-p", directive] - if skip_permissions: - argv.append("--dangerously-skip-permissions") - return argv + """argv to launch a headless nested operator session for the chosen runner + (default ``claude``). `skip_permissions` maps to the runner's unattended flag + (only for trusted, sandboxed autonomy); `config_dir` is applied via + :func:`child_env`, not the argv. `claude_bin` overrides the executable for the + default claude runner (back-compat).""" + r = get_runner(runner) + if r.name == "claude" and claude_bin != "claude": + argv = [claude_bin, *r.prompt_flags, directive] + if skip_permissions: + argv += list(r.unattended_flags) + return argv + return r.argv(directive, skip_permissions=skip_permissions) -def child_env(config_dir: str | None = None) -> dict: - """Environment for the child process. Isolates its config dir when given so - a nested session's auth/state is its own.""" +def child_env(config_dir: str | None = None, *, + runner: "Runner | str | None" = None) -> dict: + """Environment for the child process. Isolates its config dir when given (via + the runner's config-dir env var, e.g. $CLAUDE_CONFIG_DIR) so a nested + session's auth/state is its own.""" env = dict(os.environ) - if config_dir: - env["CLAUDE_CONFIG_DIR"] = str(config_dir) + r = get_runner(runner) + if config_dir and r.config_env: + env[r.config_env] = str(config_dir) return env diff --git a/cmd_chat/operator/bridge.py b/cmd_chat/operator/bridge.py index f6f11b4..1ab2fe1 100644 --- a/cmd_chat/operator/bridge.py +++ b/cmd_chat/operator/bridge.py @@ -606,22 +606,27 @@ class OperatorBridge(Client): dry_run = bool(req.get("dry_run", True)) skip_perms = bool(req.get("skip_permissions", False)) config_dir = req.get("config_dir") + try: + runner = boot.get_runner(req.get("runner")) + except ValueError as e: + return {"ok": False, "error": str(e)} stop = req.get("stop") or None directive = boot.compose_directive(objective, room, child_budget, stop=stop) argv = boot.build_run_argv(directive, skip_permissions=skip_perms, - config_dir=config_dir) - creds = boot.plan_creds(config_dir or "~", allow=allow_creds) + config_dir=config_dir, runner=runner) + creds = boot.plan_creds(config_dir or "~", allow=allow_creds, runner=runner) + present = boot.runner_present(runner) plan = {"argv": argv, "budget": child_budget.as_dict(), "creds": creds, "target": req.get("target", "host"), - "claude_present": boot.claude_present()} + "runner": runner.name, "runner_present": present} if dry_run: return {"ok": True, "dry_run": True, "plan": plan} - if not boot.claude_present(): - return {"ok": False, "error": "claude CLI not installed", - "install_plan": boot.install_plan(), "plan": plan} + if not present: + return {"ok": False, "error": f"{runner.name} CLI not installed", + "install_plan": boot.install_plan(runner=runner), "plan": plan} # Carry creds only behind the explicit gate. if creds.get("staged"): @@ -638,7 +643,7 @@ class OperatorBridge(Client): try: logf = open(self.session.dir / f"spawn-{self.seq + 1}.log", "a") # noqa: SIM115 proc = subprocess.Popen( - argv, env=boot.child_env(config_dir), + argv, env=boot.child_env(config_dir, runner=runner), stdout=logf, stderr=logf, stdin=subprocess.DEVNULL, start_new_session=True, close_fds=True) except OSError as e: diff --git a/tests/test_operator_bridge.py b/tests/test_operator_bridge.py index e6d0686..7f0f682 100644 --- a/tests/test_operator_bridge.py +++ b/tests/test_operator_bridge.py @@ -7,6 +7,7 @@ Each test builds the bridge *inside* its own ``asyncio.run`` so the bridge's import sys import asyncio import json +import os from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -335,6 +336,95 @@ def test_build_run_argv_flags(): assert "--dangerously-skip-permissions" not in argv +# ── runner registry (P3): model-agnostic spawn ─────────────────────────────── +def test_get_runner_default_and_unknown(): + assert boot.get_runner().name == "claude" + assert boot.get_runner("codex").name == "codex" + import pytest + with pytest.raises(ValueError): + boot.get_runner("bogus") + + +def test_build_run_argv_per_runner(): + # claude default unchanged + assert boot.build_run_argv("do x")[:3] == ["claude", "-p", "do x"] + # codex: own subcommand + unattended flag + argv = boot.build_run_argv("do x", skip_permissions=True, runner="codex") + assert argv[:3] == ["codex", "exec", "do x"] + assert "--dangerously-bypass-approvals-and-sandbox" in argv + # gemini + assert boot.build_run_argv("do x", runner="gemini")[:3] == ["gemini", "-p", "do x"] + # back-compat: claude_bin override still honoured for the default runner + assert boot.build_run_argv("do x", claude_bin="/opt/claude")[0] == "/opt/claude" + + +def test_cmd_runner_template(): + import os + os.environ["HH_OPERATOR_CMD"] = "mycli --go {directive}" + try: + assert boot.build_run_argv("do x", runner="cmd") == ["mycli", "--go", "do x"] + finally: + del os.environ["HH_OPERATOR_CMD"] + # no {directive} placeholder → directive appended + os.environ["HH_OPERATOR_CMD"] = "mycli run" + try: + assert boot.build_run_argv("do x", runner="cmd") == ["mycli", "run", "do x"] + finally: + del os.environ["HH_OPERATOR_CMD"] + # missing template env → clear error + import pytest + with pytest.raises(ValueError): + boot.build_run_argv("do x", runner="cmd") + + +def test_install_plan_per_runner(): + assert boot.install_plan({"has_npm": True}, runner="codex")[0] == \ + ["npm", "install", "-g", "@openai/codex"] + assert boot.install_plan({"has_npm": True}, runner="gemini")[0] == \ + ["npm", "install", "-g", "@google/gemini-cli"] + # generic cmd runner has no npm package → manual install + assert boot.install_plan({"has_npm": True}, runner="cmd") == [] + # per-runner override env wins + import os + os.environ["HH_CODEX_INSTALL"] = "echo codex-install" + try: + assert boot.install_plan({"has_npm": True}, runner="codex") == \ + [["sh", "-c", "echo codex-install"]] + finally: + del os.environ["HH_CODEX_INSTALL"] + + +def test_child_env_uses_runner_config_var(): + assert boot.child_env("/tmp/cd")["CLAUDE_CONFIG_DIR"] == "/tmp/cd" + assert boot.child_env("/tmp/cd", runner="codex")["CODEX_HOME"] == "/tmp/cd" + # claude's config var is not set under a different runner + assert boot.child_env("/tmp/cd", runner="codex").get("CLAUDE_CONFIG_DIR") == \ + os.environ.get("CLAUDE_CONFIG_DIR") + + +def test_spawn_dry_run_honours_runner(): + async def go(): + b = _bridge("oracle") + b.budget = boot.Budget(depth=1, fanout=1, cost_usd=2.0) + r = await b._op_spawn({"objective": "scout", "runner": "codex", + "room": {"host": "h", "port": 7, "name": "kid"}, + "dry_run": True}) + assert r["ok"] and r["plan"]["runner"] == "codex" + assert r["plan"]["argv"][0] == "codex" + assert "runner_present" in r["plan"] + asyncio.run(go()) + + +def test_spawn_rejects_unknown_runner(): + async def go(): + b = _bridge("oracle") + b.budget = boot.Budget(depth=1, fanout=1, cost_usd=2.0) + r = await b._op_spawn({"objective": "x", "runner": "bogus", + "room": {"host": "h", "port": 7}}) + assert r["ok"] is False and "unknown runner" in r["error"] + asyncio.run(go()) + + # ── spawn op: gating + dry-run ──────────────────────────────────────────── def test_spawn_dry_run_returns_plan_without_launch(): async def go():