From e49dbca45181cf405f6f495b1dddf83a96494ec7 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 8 Jun 2026 13:03:41 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ai):=20strip=20Goose=20harness=20(Phas?= =?UTF-8?q?e=201)=20=E2=80=94=20native/simple=20host-side=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the Goose agentic harness across the codebase per docs/spec-native-harness.md §3. Goose made N sequential model calls inside the sandbox (slow on CPU-only hardware) and forced an in-container→host Ollama gateway that tripped the rootless-Podman slirp4netns loopback bug. - bridge.py: delete _run_goose/_goose_argv/_goose_present + GOOSE_* consts and the present-cache; __init__ now takes harness="simple"/max_turns=5; granted !task runs _run_simple until the native loop lands (Phase 2). - __main__.py: --harness {native,simple} (was {goose,simple}); drop --goose-max-turns, add --max-turns; default harness simple. - app.rs: /ai start accepts native|simple (plain aliases simple) instead of a bare plain flag; refresh harness comments. - sbx.rs: remove the in-container Ollama gateway (Docker host-gateway / Podman slirp4netns host-loopback) and the dk_bootstrap OLLAMA_HOST env — kills the slirp4netns loopback bug; drop Goose comments. - bootstrap.sh: drop goose from the prereq probe. - bootstrap-ai.sh: remove the entire Goose install block, --no-goose flag, GOOSE_INSTALLER_URL, host config writer, and goose_bin helper. - sandbox-bootstrap.sh: remove the in-sandbox Goose binary install + config. - spec-goose-harness.md: banner — harness portion superseded; Podman stays. cargo check + py_compile clean. No Goose refs remain (headroom/ untouched). Co-Authored-By: Claude Opus 4.6 --- cmd_chat/agent/__main__.py | 18 ++-- cmd_chat/agent/bridge.py | 182 ++++---------------------------- docs/spec-goose-harness.md | 6 ++ hh/scripts/bootstrap-ai.sh | 73 +------------ hh/scripts/bootstrap.sh | 2 +- hh/scripts/sandbox-bootstrap.sh | 33 +----- hh/src/app.rs | 29 +++-- hh/src/sbx.rs | 34 ++---- 8 files changed, 67 insertions(+), 310 deletions(-) diff --git a/cmd_chat/agent/__main__.py b/cmd_chat/agent/__main__.py index 9620739..9f13d58 100644 --- a/cmd_chat/agent/__main__.py +++ b/cmd_chat/agent/__main__.py @@ -30,7 +30,7 @@ from __future__ import annotations import argparse import sys -from .bridge import GOOSE_MAX_TURNS, AgentBridge +from .bridge import AgentBridge from .profiles import load_profiles, provider_from_profile from .providers import OllamaEmbedder, make_provider, preflight @@ -123,12 +123,12 @@ def main() -> None: help="Ollama CPU threads (default: Ollama's own ≈ physical cores; benchmark 4/6/8)") ap.add_argument("--num-predict", type=int, default=None, help="Ollama max reply tokens (default 512)") - ap.add_argument("--harness", choices=["goose", "simple"], default=None, - help="sandbox !task harness: goose (agentic loop run inside the " - "sandbox; default) or simple (legacy one-shot injector). " - "Goose auto-degrades to simple if its binary isn't in the sandbox.") - ap.add_argument("--goose-max-turns", type=int, default=GOOSE_MAX_TURNS, - help="max turns for a Goose agentic run (default %(default)s)") + ap.add_argument("--harness", choices=["native", "simple"], default=None, + help="sandbox !task harness: native (bounded host-side Ollama " + "tool-calling loop; default) or simple (one-shot injector). " + "native degrades to simple if the model has no tool support.") + ap.add_argument("--max-turns", type=int, default=5, + help="max turns for the native tool-calling loop (default %(default)s)") ap.add_argument("--system", default=None, help="override the system prompt") ap.add_argument("--context-window", type=int, default=12, help="max prior messages fed to the model per reply") @@ -201,8 +201,8 @@ def main() -> None: password=args.password, insecure=args.insecure, no_tls=args.no_tls, system_prompt=args.system, context_window=args.context_window, token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k, - code_provider=code_provider, harness=args.harness or "goose", - goose_max_turns=args.goose_max_turns, + code_provider=code_provider, harness=args.harness or "simple", + max_turns=args.max_turns, ) try: bridge.run() diff --git a/cmd_chat/agent/bridge.py b/cmd_chat/agent/bridge.py index 775a9e2..9c49aa6 100644 --- a/cmd_chat/agent/bridge.py +++ b/cmd_chat/agent/bridge.py @@ -85,13 +85,6 @@ ADVISORY_SYSTEM = ( MAX_COMMANDS = 20 MAX_BYTES = 8192 -# Goose harness limits. The container/VM is the blast radius (see -# spec-goose-harness.md §4); these bound how much we relay + how long we wait. -GOOSE_MAX_TURNS = 15 # cap Goose's agentic loop length -GOOSE_MAX_OUTPUT = 16384 # max bytes of Goose output relayed to chat -GOOSE_TIMEOUT = 300.0 # seconds before we kill a stuck Goose run -GOOSE_FLUSH_SECS = 0.5 # throttle: at most ~2 chat updates/sec while streaming - class AgentBridge(Client): def __init__(self, server: str, port: int, name: str, provider: Provider, @@ -99,7 +92,7 @@ class AgentBridge(Client): system_prompt: str | None = None, context_window: int = 12, token_budget: int = 2000, embedder=None, rag_top_k: int = 4, rag_min_score: float = 0.35, code_provider: Provider | None = None, - harness: str = "goose", goose_max_turns: int = GOOSE_MAX_TURNS): + harness: str = "simple", max_turns: int = 5): super().__init__(server, port, username=name, password=password, insecure=insecure, no_tls=no_tls) self.name = name @@ -128,17 +121,17 @@ class AgentBridge(Client): self.granted = False # may we type into the shared PTY? self.can_sudo = False # does our VM account have sudo? self._pending: list[str] | None = None # destructive plan awaiting /confirm - # Default `!task` harness: "goose" (agentic loop run *inside* the sandbox) - # or "simple" (the legacy one-shot keystroke injector). Goose degrades to - # simple automatically when its binary isn't present in the sandbox. - self.harness = harness if harness in ("goose", "simple") else "goose" - self.goose_max_turns = goose_max_turns + # `!task` harness: "simple" (one-shot keystroke injector — the only one + # wired today) or "native" (bounded host-side Ollama tool-calling loop, + # lands in Phase 2 per docs/spec-native-harness.md; currently runs as + # simple). The default is "simple" until native is implemented. + self.harness = harness if harness in ("native", "simple") else "simple" + self.max_turns = max_turns # cap for the native loop (Phase 2) # Where the shared sandbox lives, learned from the broker's `_sbx:status` - # frame so we can exec Goose into it. None until a sandbox is announced. + # frame so we can exec into it. None until a sandbox is announced. self.sbx_engine: str | None = None # docker|podman|multipass|local self.sbx_name: str = "" # container/instance handle ("" for local) self.sbx_backend: str | None = None # cosmetic label from the broker - self._goose_present_cache: dict[tuple[str, str], bool] = {} @staticmethod def _est_tokens(text: str) -> int: @@ -307,7 +300,7 @@ class AgentBridge(Client): (file transfer, sandbox data). The owner authorizes us via `/grant ` (or `/ai start allow`), so we mirror the ACL here to know whether we're allowed to act; the status frame tells us which engine + container - the (co-located) broker is hosting so we can exec Goose into it.""" + the (co-located) broker is hosting so we can exec commands into it.""" try: frame = json.loads(text) except json.JSONDecodeError: @@ -321,7 +314,6 @@ class AgentBridge(Client): self.sbx_engine = None self.sbx_name = "" self.sbx_backend = None - self._goose_present_cache.clear() return if frame.get("_perm") != "acl": return @@ -382,12 +374,10 @@ class AgentBridge(Client): - **Not granted** → advisory only: answer in chat, never touch the sandbox (`_advise`). - - **Granted** → act in the *spawned sandbox*. With the Goose harness - (default) run Goose's agentic loop INSIDE that sandbox — the container - /VM via the engine the broker advertised, or the host only for the - explicit `local` backend — and stream its output to chat. If Goose - isn't installed there, or the harness is `simple`, fall back to the - one-shot keystroke injector (`_run_simple`).""" + - **Granted** → act in the *spawned sandbox* via the one-shot keystroke + injector (`_run_simple`). The bounded host-side `native` tool-calling + loop (docs/spec-native-harness.md, Phase 2) will branch here on + `self.harness == "native"`; until then every granted task runs simple.""" if not task: await self._send_chat( ws, f"{asker}: tell me what to run, e.g. `/ai {self.name} !create a hello.py`.") @@ -395,15 +385,6 @@ class AgentBridge(Client): if not self.granted: await self._advise(ws, task, asker) return - if self.harness == "goose" and self.sbx_engine is not None: - if await self._goose_present(): - await self._run_goose(ws, task, asker) - return - await self._send_chat( - ws, - f"{asker}: Goose isn't installed in this sandbox — using the simple " - f"one-shot harness instead.", - ) await self._run_simple(ws, task, asker) async def _advise(self, ws, task: str, asker: str) -> None: @@ -430,139 +411,12 @@ class AgentBridge(Client): f"but here's how:\n{reply}", ) - def _goose_argv(self, task: str) -> list[str] | None: - """argv that runs Goose *inside* the current sandbox, or None if we don't - know where it lives. `task` is a single argv element (never a shell - string), so untrusted room text can't inject shell metacharacters.""" - eng, name = self.sbx_engine, self.sbx_name - goose = ["goose", "run", "-t", task, "--no-session", "-q", - "--max-turns", str(self.goose_max_turns)] - if eng in ("docker", "podman"): - return [eng, "exec", "-i", name, *goose] if name else None - if eng == "multipass": - return ["multipass", "exec", name, "--", *goose] if name else None - if eng == "local": - return goose # host shell — the explicit, warned exception - return None - - async def _goose_present(self) -> bool: - """Is the Goose binary runnable inside the current sandbox? Cached per - (engine, name). A missing binary triggers the simple-injector fallback, - so Goose is the default but never load-bearing.""" - eng, name = self.sbx_engine, self.sbx_name - if eng is None: - return False - key = (eng, name) - if key in self._goose_present_cache: - return self._goose_present_cache[key] - probe = "command -v goose >/dev/null 2>&1" - if eng in ("docker", "podman"): - argv = [eng, "exec", name, "sh", "-c", probe] if name else None - elif eng == "multipass": - argv = ["multipass", "exec", name, "--", "sh", "-c", probe] if name else None - elif eng == "local": - argv = ["sh", "-c", probe] - else: - argv = None - if argv is None: - return False - try: - proc = await asyncio.create_subprocess_exec( - *argv, stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL) - present = await proc.wait() == 0 - except (FileNotFoundError, OSError): - present = False # the engine binary itself isn't on this host - self._goose_present_cache[key] = present - return present - - async def _run_goose(self, ws, task: str, asker: str) -> None: - """Tier 1 (granted): run Goose's agentic loop INSIDE the spawned sandbox - and relay its output to chat — live preview while it runs, then a final - permanent line. Output is byte-capped and the run is time-bounded; the - container/VM is the blast radius. For the `local` backend (host shell) we - warn loudly first since there is no container isolation.""" - argv = self._goose_argv(task) - if argv is None: - await self._send_chat(ws, f"{asker}: I can't locate the sandbox to run Goose in.") - return - if self.sbx_engine == "local": - await self._send_chat( - ws, - f"⚠ {self.name}: the sandbox is the LOCAL host shell (no container " - f"isolation) — Goose will run on this machine.", - ) - await self._send_chat(ws, f"⛧ {self.name}: Goose working on — {task}") - try: - proc = await asyncio.create_subprocess_exec( - *argv, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - except (FileNotFoundError, OSError) as e: - await self._send_chat(ws, f"{asker}: [goose launch failed: {e}]") - return - - loop = asyncio.get_running_loop() - parts: list[str] = [] - total = 0 - truncated = False - timed_out = False - last_emit = 0.0 - deadline = loop.time() + GOOSE_TIMEOUT - await self._send_typing(ws, True) - try: - while True: - remaining = deadline - loop.time() - if remaining <= 0: - proc.kill() - timed_out = True - break - try: - chunk = await asyncio.wait_for(proc.stdout.read(1024), timeout=remaining) - except asyncio.TimeoutError: - proc.kill() - timed_out = True - break - if not chunk: - break - text = chunk.decode(errors="replace") - room = GOOSE_MAX_OUTPUT - total - if room <= 0: - truncated = True - proc.kill() - break - clip = text[:room] - parts.append(clip) - total += len(clip) - if len(clip) < len(text): - truncated = True - proc.kill() - break - now = loop.time() - if now - last_emit >= GOOSE_FLUSH_SECS: - await self._send_stream(ws, "".join(parts), False) - last_emit = now - finally: - await self._send_stream(ws, "", True) # clear the live preview - await self._send_typing(ws, False) - rc = await proc.wait() - suffix = "" - if timed_out: - suffix = "\n[goose timed out — killed]" - elif truncated: - suffix = f"\n[output capped at {GOOSE_MAX_OUTPUT} bytes]" - body = ("".join(parts).strip() or "(no output)") + suffix - self.transcript.append(Msg("assistant", "(goose) " + body[:1000])) - await self._send_chat(ws, f"⛧ {self.name} (goose) for {asker}:\n{body}") - self.success(f"goose run for {asker} exited rc={rc}") - async def _run_simple(self, ws, task: str, asker: str) -> None: - """Legacy one-shot harness (granted): turn the request into shell commands - with the code provider and type them into the shared PTY via keystroke - frames. Guarded by the destructive-command check + blast-radius caps. Used - when harness=simple or as the Goose fallback.""" + """One-shot harness (granted): turn the request into shell commands with + the code provider and type them into the shared PTY via keystroke frames. + Guarded by the destructive-command check + blast-radius caps. This is the + proven injector (commit 47019dd) and the default until the native + tool-calling loop lands (docs/spec-native-harness.md, Phase 2).""" await self._send_typing(ws, True) try: context = await self._model_messages(task) diff --git a/docs/spec-goose-harness.md b/docs/spec-goose-harness.md index d263414..4329e90 100644 --- a/docs/spec-goose-harness.md +++ b/docs/spec-goose-harness.md @@ -1,5 +1,11 @@ # hack-house → Podman backend + Goose harness — Spec +> ⚠️ **SUPERSEDED (harness portion only).** The Goose harness described here was +> stripped on 2026-06-08 and replaced by the lightweight, host-side, Ollama-native +> harness — see **`docs/spec-native-harness.md`**. **The Podman backend introduced +> by this spec still stands** (it is not Goose-specific). Read this document only +> for the Podman backend rationale; ignore every Goose section below. + > **Status:** Draft v1 · **Date:** 2026-06-07 > **Scope:** Add **Podman** as an additional sandbox backend (Docker stays), and > make **Goose** (block/goose) the default agentic harness for the `/ai !task` diff --git a/hh/scripts/bootstrap-ai.sh b/hh/scripts/bootstrap-ai.sh index 3737536..868d211 100755 --- a/hh/scripts/bootstrap-ai.sh +++ b/hh/scripts/bootstrap-ai.sh @@ -8,18 +8,16 @@ # # The baseline is untouched: ./bootstrap.sh alone never installs or enables AI. # -# It also installs Goose (block/goose) by default — the agentic harness the /ai -# agent uses for the sandbox `!task` path — and writes a host Goose config that -# points it at the local Ollama. Skip it with --no-goose (the agent still works: -# the bridge falls back to its built-in one-shot injector). +# The /ai agent's sandbox `!task` harness runs host-side (native tool-calling / +# one-shot injector) and needs no extra binary, so this script installs nothing +# beyond Ollama + the model. # # usage: -# ./bootstrap-ai.sh # baseline setup + Ollama + default model + Goose +# ./bootstrap-ai.sh # baseline setup + Ollama + default model # ./bootstrap-ai.sh --release # ...and build the client in release mode # ./bootstrap-ai.sh --check # report only; install/pull nothing # ./bootstrap-ai.sh --yes # don't prompt before installing Ollama / pulling the model # ./bootstrap-ai.sh --ai-only # only the AI layer; skip the baseline ./bootstrap.sh -# ./bootstrap-ai.sh --no-goose # skip the Goose harness install (one-shot injector only) # ./bootstrap-ai.sh -h | --help # this help # # environment: @@ -31,22 +29,19 @@ ROOT="$(cd "$(dirname "$0")/../.." && pwd)" MODEL="${HH_AI_MODEL:-qwen2.5:3b}" OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}" INSTALLER_URL="https://ollama.com/install.sh" -GOOSE_INSTALLER_URL="https://github.com/block/goose/releases/download/stable/download_cli.sh" RELEASE_ARGS=() CHECK_ONLY=0 ASSUME_YES=0 AI_ONLY=0 -DO_GOOSE=1 for arg in "$@"; do case "$arg" in --release) RELEASE_ARGS+=(--release) ;; --check) CHECK_ONLY=1 ;; --yes|-y) ASSUME_YES=1 ;; --ai-only) AI_ONLY=1 ;; - --no-goose) DO_GOOSE=0 ;; -h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; - *) echo "✖ unknown arg: $arg (try --release / --check / --yes / --ai-only / --no-goose / --help)" >&2; exit 2 ;; + *) echo "✖ unknown arg: $arg (try --release / --check / --yes / --ai-only / --help)" >&2; exit 2 ;; esac done @@ -74,14 +69,6 @@ if have ollama; then echo " ✓ ollama ($(ollama --version 2>&1 | head -1))" else echo " · ollama not installed"; fi if ollama_up; then echo " ✓ ollama daemon reachable at $OLLAMA_HOST" else echo " · ollama daemon not reachable at $OLLAMA_HOST"; fi -# Goose may install to ~/.local/bin, which isn't always on PATH in this shell. -goose_bin() { command -v goose 2>/dev/null || { [[ -x "$HOME/.local/bin/goose" ]] && echo "$HOME/.local/bin/goose"; }; } -if [[ $DO_GOOSE -eq 1 ]]; then - if [[ -n "$(goose_bin)" ]]; then echo " ✓ goose ($("$(goose_bin)" --version 2>&1 | head -1))" - else echo " · goose not installed (will install — the agent's default !task harness)"; fi -else - echo " · goose skipped (--no-goose; agent uses its one-shot injector)" -fi if [[ $CHECK_ONLY -eq 1 ]]; then echo "--check: no changes made" @@ -143,56 +130,6 @@ else echo " ✓ model '$MODEL' ready" fi -# 5. Goose harness (default on; --no-goose skips). Goose is the agentic harness -# the /ai agent runs for the sandbox `!task` path. It ships as a release binary -# (not apt/pip), so we use its official installer with CONFIGURE=false to skip -# the interactive setup — we write a minimal host config ourselves instead. -# Failure here is non-fatal: the bridge falls back to its built-in one-shot -# injector, so the agent still works without Goose. -if [[ $DO_GOOSE -eq 1 ]]; then - echo - echo "── Goose harness ──" - if [[ -z "$(goose_bin)" ]]; then - if [[ "$(uname -s)" != "Linux" && "$(uname -s)" != "Darwin" ]]; then - echo " ✖ automatic Goose install supports Linux/macOS only — see https://block.github.io/goose/ ; the agent will use its one-shot injector." >&2 - else - echo " Goose is not installed. The official installer will run:" - echo " curl -fsSL $GOOSE_INSTALLER_URL | CONFIGURE=false bash" - proceed=1 - if [[ $ASSUME_YES -ne 1 && -t 0 ]]; then - read -r -p " install Goose now? [Y/n] " ans - [[ -z "$ans" || "$ans" == [yY]* ]] || proceed=0 - fi - if [[ $proceed -eq 1 ]]; then - if curl -fsSL "$GOOSE_INSTALLER_URL" | CONFIGURE=false bash; then - echo " ✓ goose installed ($([ -n "$(goose_bin)" ] && "$(goose_bin)" --version 2>&1 | head -1))" - else - echo " ⚠ Goose install failed — the agent will fall back to its one-shot injector" >&2 - fi - else - echo " · skipped Goose install — the agent will use its one-shot injector" - fi - fi - fi - # Write a minimal host Goose config pointing at the local Ollama. Goose reads - # ~/.config/goose/config.yaml; we only set it if absent so we never clobber a - # config the user has tuned themselves. - GOOSE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/goose/config.yaml" - if [[ -f "$GOOSE_CFG" ]]; then - echo " ✓ host Goose config already exists ($GOOSE_CFG) — leaving it untouched" - else - mkdir -p "$(dirname "$GOOSE_CFG")" - cat > "$GOOSE_CFG" < \\" diff --git a/hh/scripts/bootstrap.sh b/hh/scripts/bootstrap.sh index bbecafe..22a868c 100755 --- a/hh/scripts/bootstrap.sh +++ b/hh/scripts/bootstrap.sh @@ -49,7 +49,7 @@ for bin in python3 cargo; do if have "$bin"; then echo " ✓ $bin ($($bin --version 2>&1 | head -1))" else echo " ✖ $bin — REQUIRED"; missing=1; fi done -for bin in tmux docker podman multipass direnv goose; do +for bin in tmux docker podman multipass direnv; do if have "$bin"; then echo " ✓ $bin (optional)" else echo " · $bin not found (optional)"; fi done diff --git a/hh/scripts/sandbox-bootstrap.sh b/hh/scripts/sandbox-bootstrap.sh index 9527e09..6357cdd 100644 --- a/hh/scripts/sandbox-bootstrap.sh +++ b/hh/scripts/sandbox-bootstrap.sh @@ -50,36 +50,9 @@ if ! apt-get install -y --no-install-recommends $PKGS; then done fi -# ---- Goose harness (the /ai agent's sandbox `!task` path) -------------------- -# Goose ships as a release binary (not apt), so install it via its official -# installer into a system path so every container user can run it. Best-effort: -# a failure here just means the agent falls back to its built-in one-shot -# injector, so it never blocks provisioning. Skip entirely with HH_SBX_GOOSE=0. -if [[ "${HH_SBX_GOOSE:-1}" != "0" ]] && ! command -v goose >/dev/null 2>&1; then - if command -v curl >/dev/null 2>&1; then - GOOSE_BIN_DIR=/usr/local/bin CONFIGURE=false \ - bash -c 'curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash' \ - || true - fi -fi - -# Container-side Goose config: point it at the host's Ollama via the engine's -# host gateway (passed in as $HH_OLLAMA_HOST by the launcher). The shared shell -# runs as root here, so write it under /root — only if absent, so a tuned config -# survives a re-provision. -if command -v goose >/dev/null 2>&1; then - GOOSE_CFG=/root/.config/goose/config.yaml - if [[ ! -f "$GOOSE_CFG" ]]; then - mkdir -p "$(dirname "$GOOSE_CFG")" - cat > "$GOOSE_CFG" < "$SENTINEL" # mark done; skip on the next provision pass diff --git a/hh/src/app.rs b/hh/src/app.rs index 68c66d6..45275ab 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -2624,11 +2624,12 @@ fn handle_command( app.sys("an AI agent is already running from this client — /ai stop first"); } else { // Trailing flag words (any order): `allow` auto-grants the agent - // sandbox drive on launch; `plain` selects the legacy one-shot - // injector instead of the default Goose harness. + // sandbox drive on launch; the harness word `native` (default) or + // `simple` picks the granted-`!task` harness. `plain` is a back-compat + // alias for `simple`. let mut raw = rest.trim(); let mut grant_sbx = false; - let mut plain = false; + let mut harness: Option<&str> = None; loop { if let Some(head) = raw .strip_suffix("allow") @@ -2638,17 +2639,20 @@ fn handle_command( raw = head.trim(); continue; } - if let Some(head) = raw - .strip_suffix("plain") - .filter(|h| h.is_empty() || h.ends_with(' ')) + if let Some((word, head)) = ["native", "simple", "plain"] + .iter() + .find_map(|w| { + raw.strip_suffix(w) + .filter(|h| h.is_empty() || h.ends_with(' ')) + .map(|h| (*w, h)) + }) { - plain = true; + harness = Some(if word == "native" { "native" } else { "simple" }); raw = head.trim(); continue; } break; } - let harness = if plain { Some("simple") } else { None }; // A bare name (no ':' tag, no '/' path) is a models.toml profile; // anything else is treated as a literal Ollama model tag. let (profile, model): (Option<&str>, &str) = if raw.is_empty() { @@ -2671,7 +2675,10 @@ fn handle_command( Some(p) => format!("profile {p}"), None => format!("ollama/{model}"), }; - let hdesc = if plain { ", simple harness" } else { "" }; + let hdesc = match harness { + Some(h) => format!(", {h} harness"), + None => String::new(), + }; app.sys(format!( "⛧ summoning {name} ({desc}{hdesc})… it will announce when online" )); @@ -3245,8 +3252,8 @@ fn spawn_agent( .arg(model); } } - // Override the agent's default `!task` harness (goose) when the launcher - // asked for the legacy one-shot injector via `/ai start … plain`. + // Override the agent's default `!task` harness when the launcher asked for a + // specific one via `/ai start … native|simple` (plain aliases simple). if let Some(h) = harness { cmd.arg("--harness").arg(h); } diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index cfe24b2..bf48e89 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -685,7 +685,7 @@ impl Backend { /// sandbox — `docker`/`podman exec`, `multipass exec`, or host `local`. /// Advertised in the `_sbx:status` frame (distinct from `label`, whose Local /// value is the cosmetic "local-shell") so the bridge can build the right - /// `goose run` invocation for the backend that holds the sandbox. + /// ` exec` invocation for the backend that holds the sandbox. pub fn engine(self) -> &'static str { match self { Backend::Local => "local", @@ -777,19 +777,11 @@ pub fn prepare( // surfaced through the returned error (shown in the error popup). let mut run = Command::new(engine); run.args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root"]); - // Goose (and any in-container tool) reaches the host Ollama via a - // gateway. Docker maps `host.docker.internal` to the host gateway IP. - // For rootless Podman the native `host.containers.internal` resolves to - // the host's *LAN* interface, which can't reach an Ollama bound to - // 127.0.0.1 (the safe default) — so request slirp4netns host-loopback - // forwarding and point the in-container OLLAMA_HOST at the slirp gateway - // 10.0.2.2 (see dk_bootstrap). Without this the granted `!task` path dies - // with "Could not connect to host.containers.internal:11434". - if backend == Backend::Docker { - run.arg("--add-host=host.docker.internal:host-gateway"); - } else if backend == Backend::Podman { - run.arg("--network=slirp4netns:allow_host_loopback=true"); - } + // The native harness runs the model host-side and only execs commands + // into the container, so the sandbox no longer needs to reach host + // Ollama. The old in-container Ollama gateway (Docker host-gateway / + // Podman slirp4netns host-loopback) is therefore gone — and with it the + // rootless-Podman loopback bug it used to work around. run.args([image, "sleep", "infinity"]); let out = run .output() @@ -1246,21 +1238,9 @@ fn dk_bootstrap(engine: &str, name: &str) { "apt-get update -qq && apt-get install -y --no-install-recommends $HH_SBX_PKGS".into() }); let pkgs_env = format!("HH_SBX_PKGS={}", sandbox_pkgs()); - // The in-container Goose config points OLLAMA_HOST at the host gateway, whose - // address depends on the engine. Docker resolves `host.docker.internal` via the - // `--add-host` we add at run time. Rootless Podman's `host.containers.internal` - // resolves to the host LAN IP and can't reach a loopback-bound Ollama, so we - // launch the container with slirp4netns:allow_host_loopback=true (see launch()) - // and target the slirp host-loopback gateway 10.0.2.2 instead. Pass it through - // so sandbox-bootstrap.sh can bake the right URL. - let gateway = if engine == "podman" { - "HH_OLLAMA_HOST=http://10.0.2.2:11434" - } else { - "HH_OLLAMA_HOST=http://host.docker.internal:11434" - }; let child = Command::new(engine) .args([ - "exec", "-i", "-e", &pkgs_env, "-e", gateway, name, "bash", "-s", + "exec", "-i", "-e", &pkgs_env, name, "bash", "-s", ]) .stdin(Stdio::piped()) .stdout(Stdio::null())