feat(operator): runner registry — spawn any agent CLI, not just Claude (P3)
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_<RUNNER>_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 <noreply@anthropic.com>
This commit is contained in:
@@ -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("--out", default=None, help="write to this local path (else stdout)")
|
||||||
gt.add_argument("--session", default=None)
|
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("objective", help="what the nested operator should achieve")
|
||||||
sp.add_argument("--room-host", required=True)
|
sp.add_argument("--room-host", required=True)
|
||||||
sp.add_argument("--room-port", type=int, required=True)
|
sp.add_argument("--room-port", type=int, required=True)
|
||||||
sp.add_argument("--room-name", default="operator")
|
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,
|
sp.add_argument("--stop", action="append", default=None,
|
||||||
help="a stop condition (repeatable)")
|
help="a stop condition (repeatable)")
|
||||||
sp.add_argument("--target", choices=["host"], default="host")
|
sp.add_argument("--target", choices=["host"], default="host")
|
||||||
@@ -366,6 +370,7 @@ def _run_spawn(args) -> int:
|
|||||||
"op": "spawn", "objective": args.objective,
|
"op": "spawn", "objective": args.objective,
|
||||||
"room": {"host": args.room_host, "port": args.room_port,
|
"room": {"host": args.room_host, "port": args.room_port,
|
||||||
"name": args.room_name},
|
"name": args.room_name},
|
||||||
|
"runner": args.runner,
|
||||||
"stop": args.stop, "target": args.target,
|
"stop": args.stop, "target": args.target,
|
||||||
"config_dir": args.config_dir, "allow_creds": args.allow_creds,
|
"config_dir": args.config_dir, "allow_creds": args.allow_creds,
|
||||||
"skip_permissions": args.skip_permissions, "dry_run": not args.go})
|
"skip_permissions": args.skip_permissions, "dry_run": not args.go})
|
||||||
|
|||||||
+137
-31
@@ -26,6 +26,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -112,20 +113,110 @@ def claude_present() -> bool:
|
|||||||
return shutil.which("claude") is not None
|
return shutil.which("claude") is not None
|
||||||
|
|
||||||
|
|
||||||
def install_plan(sysinfo: dict | None = None) -> list[list[str]]:
|
# ── runner registry ──────────────────────────────────────────────────────────
|
||||||
"""Ordered candidate commands to get the `claude` CLI onto the system.
|
# 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
|
def present(self) -> bool:
|
||||||
its own channel. Otherwise prefers the documented npm package; if npm is
|
return bool(self.bin) and shutil.which(self.bin) is not None
|
||||||
missing, prefix the distro's node install. We never fabricate a download
|
|
||||||
URL — if none of these apply the caller surfaces "install claude manually"."""
|
def argv(self, directive: str, *, skip_permissions: bool = False) -> list[str]:
|
||||||
override = os.environ.get("HH_CLAUDE_INSTALL")
|
"""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:
|
if override:
|
||||||
return [["sh", "-c", override]]
|
return [["sh", "-c", override]]
|
||||||
|
if not r.npm_package:
|
||||||
|
return []
|
||||||
info = sysinfo or detect_system()
|
info = sysinfo or detect_system()
|
||||||
plans: list[list[str]] = []
|
plans: list[list[str]] = []
|
||||||
if info.get("has_npm"):
|
if info.get("has_npm"):
|
||||||
plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE])
|
plans.append(["npm", "install", "-g", r.npm_package])
|
||||||
else:
|
else:
|
||||||
mgr = info.get("pkg_manager")
|
mgr = info.get("pkg_manager")
|
||||||
node_install = {
|
node_install = {
|
||||||
@@ -138,31 +229,38 @@ def install_plan(sysinfo: dict | None = None) -> list[list[str]]:
|
|||||||
}.get(mgr)
|
}.get(mgr)
|
||||||
if node_install:
|
if node_install:
|
||||||
plans.append(node_install)
|
plans.append(node_install)
|
||||||
plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE])
|
plans.append(["npm", "install", "-g", r.npm_package])
|
||||||
return plans
|
return plans
|
||||||
|
|
||||||
|
|
||||||
# ── credentials (gated) ─────────────────────────────────────────────────────
|
# ── credentials (gated) ─────────────────────────────────────────────────────
|
||||||
def creds_source() -> Path | None:
|
def creds_source(runner: "Runner | str | None" = None) -> Path | None:
|
||||||
"""The parent session's own OAuth/API credentials file, if present."""
|
"""The parent session's own credentials file for the runner, if present
|
||||||
p = Path.home() / ".claude" / ".credentials.json"
|
(defaults to Claude's ``~/.claude/.credentials.json``)."""
|
||||||
return p if p.exists() else None
|
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.
|
"""Decide whether to carry credentials into a child's config dir.
|
||||||
|
|
||||||
Default (``allow=False``) → carry nothing; the child must authenticate
|
Default (``allow=False``) → carry nothing; the child must authenticate
|
||||||
itself (`claude` interactive login or its own $ANTHROPIC_API_KEY). Only when
|
itself (its own interactive login or API key). Only when the operator
|
||||||
the operator *explicitly* opts in do we stage a copy of our own creds — and
|
*explicitly* opts in do we stage a copy of our own creds — and only into a
|
||||||
only into a path we were handed. This function plans; the bridge performs the
|
path we were handed. The destination mirrors the runner's own creds layout
|
||||||
copy so the side effect stays behind one audited flag."""
|
under ``dest_home``. This function plans; the bridge performs the copy so the
|
||||||
|
side effect stays behind one audited flag."""
|
||||||
if not allow:
|
if not allow:
|
||||||
return {"staged": False, "reason": "creds gating on — child authenticates itself"}
|
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:
|
if src is None:
|
||||||
return {"staged": False, "reason": "no parent credentials to carry"}
|
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)}
|
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,
|
def build_run_argv(directive: str, *, skip_permissions: bool = False,
|
||||||
config_dir: str | None = None,
|
config_dir: str | None = None,
|
||||||
|
runner: "Runner | str | None" = None,
|
||||||
claude_bin: str = "claude") -> list[str]:
|
claude_bin: str = "claude") -> list[str]:
|
||||||
"""argv to launch a headless nested Claude session. `skip_permissions` maps
|
"""argv to launch a headless nested operator session for the chosen runner
|
||||||
to Claude Code's unattended flag (only for trusted, sandboxed autonomy);
|
(default ``claude``). `skip_permissions` maps to the runner's unattended flag
|
||||||
`config_dir` points the child at an isolated $CLAUDE_CONFIG_DIR so its creds
|
(only for trusted, sandboxed autonomy); `config_dir` is applied via
|
||||||
and state don't collide with the parent."""
|
:func:`child_env`, not the argv. `claude_bin` overrides the executable for the
|
||||||
argv = [claude_bin, "-p", directive]
|
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:
|
if skip_permissions:
|
||||||
argv.append("--dangerously-skip-permissions")
|
argv += list(r.unattended_flags)
|
||||||
return argv
|
return argv
|
||||||
|
return r.argv(directive, skip_permissions=skip_permissions)
|
||||||
|
|
||||||
|
|
||||||
def child_env(config_dir: str | None = None) -> dict:
|
def child_env(config_dir: str | None = None, *,
|
||||||
"""Environment for the child process. Isolates its config dir when given so
|
runner: "Runner | str | None" = None) -> dict:
|
||||||
a nested session's auth/state is its own."""
|
"""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)
|
env = dict(os.environ)
|
||||||
if config_dir:
|
r = get_runner(runner)
|
||||||
env["CLAUDE_CONFIG_DIR"] = str(config_dir)
|
if config_dir and r.config_env:
|
||||||
|
env[r.config_env] = str(config_dir)
|
||||||
return env
|
return env
|
||||||
|
|||||||
@@ -606,22 +606,27 @@ class OperatorBridge(Client):
|
|||||||
dry_run = bool(req.get("dry_run", True))
|
dry_run = bool(req.get("dry_run", True))
|
||||||
skip_perms = bool(req.get("skip_permissions", False))
|
skip_perms = bool(req.get("skip_permissions", False))
|
||||||
config_dir = req.get("config_dir")
|
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
|
stop = req.get("stop") or None
|
||||||
directive = boot.compose_directive(objective, room, child_budget, stop=stop)
|
directive = boot.compose_directive(objective, room, child_budget, stop=stop)
|
||||||
argv = boot.build_run_argv(directive, skip_permissions=skip_perms,
|
argv = boot.build_run_argv(directive, skip_permissions=skip_perms,
|
||||||
config_dir=config_dir)
|
config_dir=config_dir, runner=runner)
|
||||||
creds = boot.plan_creds(config_dir or "~", allow=allow_creds)
|
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,
|
plan = {"argv": argv, "budget": child_budget.as_dict(), "creds": creds,
|
||||||
"target": req.get("target", "host"),
|
"target": req.get("target", "host"),
|
||||||
"claude_present": boot.claude_present()}
|
"runner": runner.name, "runner_present": present}
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return {"ok": True, "dry_run": True, "plan": plan}
|
return {"ok": True, "dry_run": True, "plan": plan}
|
||||||
|
|
||||||
if not boot.claude_present():
|
if not present:
|
||||||
return {"ok": False, "error": "claude CLI not installed",
|
return {"ok": False, "error": f"{runner.name} CLI not installed",
|
||||||
"install_plan": boot.install_plan(), "plan": plan}
|
"install_plan": boot.install_plan(runner=runner), "plan": plan}
|
||||||
|
|
||||||
# Carry creds only behind the explicit gate.
|
# Carry creds only behind the explicit gate.
|
||||||
if creds.get("staged"):
|
if creds.get("staged"):
|
||||||
@@ -638,7 +643,7 @@ class OperatorBridge(Client):
|
|||||||
try:
|
try:
|
||||||
logf = open(self.session.dir / f"spawn-{self.seq + 1}.log", "a") # noqa: SIM115
|
logf = open(self.session.dir / f"spawn-{self.seq + 1}.log", "a") # noqa: SIM115
|
||||||
proc = subprocess.Popen(
|
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,
|
stdout=logf, stderr=logf, stdin=subprocess.DEVNULL,
|
||||||
start_new_session=True, close_fds=True)
|
start_new_session=True, close_fds=True)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Each test builds the bridge *inside* its own ``asyncio.run`` so the bridge's
|
|||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
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
|
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 ────────────────────────────────────────────
|
# ── spawn op: gating + dry-run ────────────────────────────────────────────
|
||||||
def test_spawn_dry_run_returns_plan_without_launch():
|
def test_spawn_dry_run_returns_plan_without_launch():
|
||||||
async def go():
|
async def go():
|
||||||
|
|||||||
Reference in New Issue
Block a user