feat(operator): Phase 5 — bootstrap + budgeted recursive spawn

Lets an operator stand up a nested Claude Code operator against another room —
a tree of operators — behind hard guardrails:

- Budget(depth, fanout, cost): depth/fanout are hard caps decremented on every
  descend(); a leaf (depth 0) or a spent level (fanout 0) refuses to spawn,
  stopping a runaway tree. Cost is carried down as a soft ceiling.
- detect_system / install_plan: decide install from what's present; prefer the
  documented npm package `@anthropic-ai/claude-code` or a $HH_CLAUDE_INSTALL
  override — never a guessed URL.
- Credentials are gated OFF by default: a child authenticates itself unless the
  operator explicitly passes allow_creds, and only its own creds, only to a path
  it was handed.
- compose_directive bakes the objective, stop conditions and inherited budget
  into the child's `claude -p` prompt so it self-limits.

`spawn` op + CLI verb default to a dry-run plan (inspect the tree before growing
it); `--go` launches detached on the host. `up`/`serve` take --depth/--fanout/
--cost. 24 offline tests green; dry-run plan verified live through the socket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-26 13:38:42 -07:00
parent 669266dc55
commit 60215bbdd4
4 changed files with 436 additions and 2 deletions
+43 -1
View File
@@ -37,6 +37,12 @@ def _add_conn_args(p: argparse.ArgumentParser) -> None:
p.add_argument("--trigger", default=None,
help="extra phrase that marks a message 'addressed' to us "
"(the operator name / @name always count)")
p.add_argument("--depth", type=int, default=1,
help="recursion depth budget (levels of nested operators)")
p.add_argument("--fanout", type=int, default=2,
help="recursion fan-out budget (children per level)")
p.add_argument("--cost", type=float, default=5.0,
help="soft cost ceiling (USD) carried to children")
def _build_parser() -> argparse.ArgumentParser:
@@ -81,6 +87,24 @@ 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.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("--stop", action="append", default=None,
help="a stop condition (repeatable)")
sp.add_argument("--target", choices=["host"], default="host")
sp.add_argument("--config-dir", default=None,
help="isolated CLAUDE_CONFIG_DIR for the child")
sp.add_argument("--allow-creds", action="store_true",
help="carry our credentials to the child (default: off)")
sp.add_argument("--skip-permissions", action="store_true",
help="run the child unattended (--dangerously-skip-permissions)")
sp.add_argument("--go", action="store_true",
help="actually launch (default is a dry-run plan)")
sp.add_argument("--session", default=None)
ky = sub.add_parser("keys", help="inject keystrokes into the room's shared PTY")
ky.add_argument("keys", nargs="*",
help="tokens: literal text, named keys (enter, ctrl-c, esc, "
@@ -96,10 +120,12 @@ def _build_parser() -> argparse.ArgumentParser:
# ── daemon ───────────────────────────────────────────────────────────────
def _run_serve(args) -> int:
from .bridge import OperatorBridge # heavy imports only on the daemon path
from .bootstrap import Budget
budget = Budget(depth=args.depth, fanout=args.fanout, cost_usd=args.cost)
bridge = OperatorBridge(
args.host, args.port, name=args.user, password=args.password,
insecure=args.insecure, no_tls=args.no_tls,
session=args.session, trigger=args.trigger)
session=args.session, trigger=args.trigger, budget=budget)
try:
bridge.run()
except KeyboardInterrupt:
@@ -139,6 +165,8 @@ def _run_up(args) -> int:
cmd += ["--session", args.session]
if args.trigger:
cmd += ["--trigger", args.trigger]
cmd += ["--depth", str(args.depth), "--fanout", str(args.fanout),
"--cost", str(args.cost)]
log = open(sess.log_path, "a") # noqa: SIM115 — handed to the child
proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
@@ -267,6 +295,18 @@ def _run_get(args) -> int:
return 0
def _run_spawn(args) -> int:
resp = _client_request(args, {
"op": "spawn", "objective": args.objective,
"room": {"host": args.room_host, "port": args.room_port,
"name": args.room_name},
"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})
print(json.dumps(resp, indent=2))
return 0 if resp.get("ok") else 1
def _run_keys(args) -> int:
from . import sandbox as sbx
if args.help_keys:
@@ -309,6 +349,8 @@ def main(argv: list[str] | None = None) -> int:
return _run_get(args)
if verb == "keys":
return _run_keys(args)
if verb == "spawn":
return _run_spawn(args)
if verb in ("roster", "status", "down"):
return _run_simple(args, verb)
return 2
+215
View File
@@ -0,0 +1,215 @@
"""Bootstrap + recursive-spawn primitives for the operator bridge (Phase 5).
Goal: from inside one room (or a sandbox/VM the operator owns), stand up a
*nested* Claude Code session that runs the `hh-operator` skill against another
room — a tree of operators. Doing that safely needs four things, all here:
1. **Detect** the target system (OS / arch / package manager / what's already
installed) so install is a decision, not a guess.
2. **Install** Claude Code via a *known* package name (npm `@anthropic-ai/
claude-code`) or an explicitly-configured installer — never a guessed URL.
3. **Authenticate** by carrying the parent's own credentials into the child —
but **gated off by default**: a child auths itself unless the operator
explicitly opts in (`allow_creds`). We never exfiltrate creds to a system we
don't own, and never enable this implicitly.
4. **Budget** the recursion: hard **depth** and **fan-out** caps plus a soft
**cost** ceiling, decremented on every descent, refusing at zero. This is the
guardrail that stops a runaway tree.
This module is pure/decision-making: it builds argv, plans, and budgets. It does
not itself run network installs or launch detached Claude sessions — the bridge
does that behind explicit flags, so unit tests can exercise all the logic with
no side effects.
"""
from __future__ import annotations
import os
import platform
import shutil
from dataclasses import dataclass, replace
from pathlib import Path
# Documented install package — NOT a guessed URL. Override the whole install
# command with $HH_CLAUDE_INSTALL when a site has its own channel.
CLAUDE_NPM_PACKAGE = "@anthropic-ai/claude-code"
class BudgetExhausted(RuntimeError):
"""A spawn was refused because a recursion budget hit zero."""
@dataclass(frozen=True)
class Budget:
"""Hard caps on a recursion tree.
- ``depth`` — how many more levels may be spawned below here (0 = leaf).
- ``fanout`` — how many children *this* node may still spawn.
- ``cost_usd`` — soft ceiling carried down and surfaced to the child as a
directive (Claude Code has no native $ cap, so this is advisory; depth and
fanout are the hard stops).
"""
depth: int = 1
fanout: int = 2
cost_usd: float = 5.0
def can_spawn(self) -> bool:
return self.depth > 0 and self.fanout > 0
def descend(self) -> "Budget":
"""Consume one child slot at this level and return the budget the child
inherits (one level shallower, its own fresh fanout). Raises when this
node can't spawn any more."""
if self.depth <= 0:
raise BudgetExhausted("depth budget exhausted — cannot nest deeper")
if self.fanout <= 0:
raise BudgetExhausted("fan-out budget exhausted at this level")
# The child gets depth-1 and the same fanout/cost to share among its own
# children; *this* node records one fewer remaining child via `spent`.
return Budget(depth=self.depth - 1, fanout=self.fanout, cost_usd=self.cost_usd)
def spent(self) -> "Budget":
"""Return this node's budget after using one of its child slots."""
return replace(self, fanout=self.fanout - 1)
def as_dict(self) -> dict:
return {"depth": self.depth, "fanout": self.fanout, "cost_usd": self.cost_usd}
# ── detection ───────────────────────────────────────────────────────────────
def _distro_id() -> str:
try:
for line in Path("/etc/os-release").read_text().splitlines():
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"')
except OSError:
pass
return ""
def _pkg_manager() -> str | None:
for mgr in ("apt-get", "dnf", "yum", "apk", "pacman", "brew"):
if shutil.which(mgr):
return mgr
return None
def detect_system() -> dict:
"""Snapshot what the bootstrap needs to decide install strategy. Pure read —
no mutation, safe to call anywhere (including inside a sandbox via exec)."""
return {
"os": platform.system().lower(), # linux / darwin
"arch": platform.machine(), # x86_64 / aarch64 …
"distro": _distro_id(), # ubuntu / debian / alpine …
"pkg_manager": _pkg_manager(),
"has_node": shutil.which("node") is not None,
"has_npm": shutil.which("npm") is not None,
"claude": shutil.which("claude"), # path or None
}
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.
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")
if override:
return [["sh", "-c", override]]
info = sysinfo or detect_system()
plans: list[list[str]] = []
if info.get("has_npm"):
plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE])
else:
mgr = info.get("pkg_manager")
node_install = {
"apt-get": ["apt-get", "install", "-y", "nodejs", "npm"],
"dnf": ["dnf", "install", "-y", "nodejs", "npm"],
"yum": ["yum", "install", "-y", "nodejs", "npm"],
"apk": ["apk", "add", "nodejs", "npm"],
"pacman": ["pacman", "-Sy", "--noconfirm", "nodejs", "npm"],
"brew": ["brew", "install", "node"],
}.get(mgr)
if node_install:
plans.append(node_install)
plans.append(["npm", "install", "-g", CLAUDE_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 plan_creds(dest_home: str | os.PathLike, *, allow: bool) -> 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."""
if not allow:
return {"staged": False, "reason": "creds gating on — child authenticates itself"}
src = creds_source()
if src is None:
return {"staged": False, "reason": "no parent credentials to carry"}
dest = Path(dest_home) / ".claude" / ".credentials.json"
return {"staged": True, "src": str(src), "dest": str(dest)}
# ── directive + run argv ────────────────────────────────────────────────────
def compose_directive(objective: str, room: dict, budget: Budget,
stop: list[str] | None = None) -> str:
"""The prompt the nested Claude runs under `claude -p`. Tells it to use the
hh-operator skill, where to join, what to achieve, when to stop, and the
recursion budget it inherits (so it self-limits its own spawns)."""
host, port = room.get("host", "?"), room.get("port", "?")
name = room.get("name", "operator")
stop = stop or ["objective met", "owner says stop/leave", "idle past remit"]
lines = [
"Use the hh-operator skill. You are an autonomous operator joining a "
"hack-house room.",
f"Join: `hh-bridge up {host} {port} {name}` "
f"(password via room config; add --no-tls for plain ws).",
f"Objective: {objective}",
"Stop conditions (leave with `hh-bridge down` when any is met): "
+ "; ".join(stop) + ".",
f"Recursion budget you inherit: depth={budget.depth}, "
f"fanout={budget.fanout}, cost≈${budget.cost_usd:.2f}. "
"You may spawn at most `fanout` child operators and only while depth>0; "
"pass each child a decremented budget. Stay within the cost ceiling.",
"Drive sandboxes only after the owner grants you. Never touch a system "
"you weren't asked to.",
]
return "\n".join(lines)
def build_run_argv(directive: str, *, skip_permissions: bool = False,
config_dir: 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
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."""
env = dict(os.environ)
if config_dir:
env["CLAUDE_CONFIG_DIR"] = str(config_dir)
return env
+87 -1
View File
@@ -25,6 +25,7 @@ import time
import websockets
from ..client.client import Client
from . import bootstrap as boot
from . import sandbox as sbx
from .session import Session
@@ -39,12 +40,14 @@ class OperatorBridge(Client):
def __init__(self, server: str, port: int, name: str,
password: str | None = None, insecure: bool = False,
no_tls: bool = False, session: str | None = None,
trigger: str | None = None):
trigger: str | None = None, budget: boot.Budget | None = None):
super().__init__(server, port, username=name, password=password,
insecure=insecure, no_tls=no_tls)
self.name = name
self.trigger = (trigger or "").strip()
self.session = Session(session or name)
# Recursion budget this operator may spend spawning nested operators.
self.budget = budget or boot.Budget()
# Inbox: monotonically-seq'd events + an async condition to wake waiters.
self.events: list[dict] = []
@@ -302,6 +305,8 @@ class OperatorBridge(Client):
return await self._op_get(req)
if op == "keys":
return await self._op_keys(req)
if op == "spawn":
return await self._op_spawn(req)
if op == "down":
asyncio.get_running_loop().call_soon(self._begin_shutdown)
return {"ok": True, "stopping": True}
@@ -469,6 +474,87 @@ class OperatorBridge(Client):
await self._emit("keys", bytes=len(data))
return {"ok": True, "bytes": len(data)}
# ── recursive spawn (Phase 5) ────────────────────────────────────────
async def _op_spawn(self, req: dict) -> dict:
"""Stand up a nested Claude Code operator against another room.
Guardrails, in order:
- **budget** — refuse unless this node may still spawn (depth>0 &
fanout>0); the child inherits a decremented budget, we record one spent
slot. This is the hard stop against a runaway tree.
- **creds gating** — we carry our own credentials to the child *only* when
`allow_creds` is explicitly set; otherwise the child authenticates
itself. Never implicit, never to a system we don't own.
- **dry_run** — default returns the full plan (argv, budget, creds
decision) and launches nothing, so a tree can be inspected before it's
grown.
`target` is "host" (Popen detached here) — sandbox/VM placement is the
operator's to run via `exec` using the returned plan."""
objective = str(req.get("objective", "")).strip()
if not objective:
return {"ok": False, "error": "spawn needs an objective"}
room = req.get("room") or {}
if not room.get("host") or not room.get("port"):
return {"ok": False, "error": "spawn needs room {host, port, name}"}
if not self.budget.can_spawn():
return {"ok": False, "error": "recursion budget exhausted "
f"(depth={self.budget.depth}, fanout={self.budget.fanout})"}
try:
child_budget = self.budget.descend()
except boot.BudgetExhausted as e:
return {"ok": False, "error": str(e)}
allow_creds = bool(req.get("allow_creds", False))
dry_run = bool(req.get("dry_run", True))
skip_perms = bool(req.get("skip_permissions", False))
config_dir = req.get("config_dir")
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)
plan = {"argv": argv, "budget": child_budget.as_dict(), "creds": creds,
"target": req.get("target", "host"),
"claude_present": boot.claude_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}
# Carry creds only behind the explicit gate.
if creds.get("staged"):
try:
dest = boot.Path(creds["dest"])
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(boot.Path(creds["src"]).read_bytes())
dest.chmod(0o600)
except OSError as e:
return {"ok": False, "error": f"creds staging failed: {e}"}
# Launch detached so the child outlives this daemon; logs to the session.
import subprocess
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),
stdout=logf, stderr=logf, stdin=subprocess.DEVNULL,
start_new_session=True, close_fds=True)
except OSError as e:
return {"ok": False, "error": f"spawn launch failed: {e}"}
self.budget = self.budget.spent() # consume one child slot here
await self._emit("spawn", objective=objective, pid=proc.pid,
budget=child_budget.as_dict(),
creds_staged=creds.get("staged", False))
return {"ok": True, "pid": proc.pid, "plan": plan,
"remaining": self.budget.as_dict()}
def _begin_shutdown(self) -> None:
self._stop.set()
self.running = False