refactor(ai): strip Goose harness (Phase 1) — native/simple host-side only
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run

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 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-08 13:03:41 -07:00
parent cf6b0b5b73
commit e49dbca451
8 changed files with 67 additions and 310 deletions
+9 -9
View File
@@ -30,7 +30,7 @@ from __future__ import annotations
import argparse import argparse
import sys import sys
from .bridge import GOOSE_MAX_TURNS, AgentBridge from .bridge import AgentBridge
from .profiles import load_profiles, provider_from_profile from .profiles import load_profiles, provider_from_profile
from .providers import OllamaEmbedder, make_provider, preflight 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)") help="Ollama CPU threads (default: Ollama's own ≈ physical cores; benchmark 4/6/8)")
ap.add_argument("--num-predict", type=int, default=None, ap.add_argument("--num-predict", type=int, default=None,
help="Ollama max reply tokens (default 512)") help="Ollama max reply tokens (default 512)")
ap.add_argument("--harness", choices=["goose", "simple"], default=None, ap.add_argument("--harness", choices=["native", "simple"], default=None,
help="sandbox !task harness: goose (agentic loop run inside the " help="sandbox !task harness: native (bounded host-side Ollama "
"sandbox; default) or simple (legacy one-shot injector). " "tool-calling loop; default) or simple (one-shot injector). "
"Goose auto-degrades to simple if its binary isn't in the sandbox.") "native degrades to simple if the model has no tool support.")
ap.add_argument("--goose-max-turns", type=int, default=GOOSE_MAX_TURNS, ap.add_argument("--max-turns", type=int, default=5,
help="max turns for a Goose agentic run (default %(default)s)") 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("--system", default=None, help="override the system prompt")
ap.add_argument("--context-window", type=int, default=12, ap.add_argument("--context-window", type=int, default=12,
help="max prior messages fed to the model per reply") 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, password=args.password, insecure=args.insecure, no_tls=args.no_tls,
system_prompt=args.system, context_window=args.context_window, system_prompt=args.system, context_window=args.context_window,
token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k, token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k,
code_provider=code_provider, harness=args.harness or "goose", code_provider=code_provider, harness=args.harness or "simple",
goose_max_turns=args.goose_max_turns, max_turns=args.max_turns,
) )
try: try:
bridge.run() bridge.run()
+18 -164
View File
@@ -85,13 +85,6 @@ ADVISORY_SYSTEM = (
MAX_COMMANDS = 20 MAX_COMMANDS = 20
MAX_BYTES = 8192 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): class AgentBridge(Client):
def __init__(self, server: str, port: int, name: str, provider: Provider, 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, system_prompt: str | None = None, context_window: int = 12,
token_budget: int = 2000, embedder=None, rag_top_k: int = 4, token_budget: int = 2000, embedder=None, rag_top_k: int = 4,
rag_min_score: float = 0.35, code_provider: Provider | None = None, 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, super().__init__(server, port, username=name, password=password,
insecure=insecure, no_tls=no_tls) insecure=insecure, no_tls=no_tls)
self.name = name self.name = name
@@ -128,17 +121,17 @@ class AgentBridge(Client):
self.granted = False # may we type into the shared PTY? self.granted = False # may we type into the shared PTY?
self.can_sudo = False # does our VM account have sudo? self.can_sudo = False # does our VM account have sudo?
self._pending: list[str] | None = None # destructive plan awaiting /confirm self._pending: list[str] | None = None # destructive plan awaiting /confirm
# Default `!task` harness: "goose" (agentic loop run *inside* the sandbox) # `!task` harness: "simple" (one-shot keystroke injector — the only one
# or "simple" (the legacy one-shot keystroke injector). Goose degrades to # wired today) or "native" (bounded host-side Ollama tool-calling loop,
# simple automatically when its binary isn't present in the sandbox. # lands in Phase 2 per docs/spec-native-harness.md; currently runs as
self.harness = harness if harness in ("goose", "simple") else "goose" # simple). The default is "simple" until native is implemented.
self.goose_max_turns = goose_max_turns 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` # 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_engine: str | None = None # docker|podman|multipass|local
self.sbx_name: str = "" # container/instance handle ("" for local) self.sbx_name: str = "" # container/instance handle ("" for local)
self.sbx_backend: str | None = None # cosmetic label from the broker self.sbx_backend: str | None = None # cosmetic label from the broker
self._goose_present_cache: dict[tuple[str, str], bool] = {}
@staticmethod @staticmethod
def _est_tokens(text: str) -> int: def _est_tokens(text: str) -> int:
@@ -307,7 +300,7 @@ class AgentBridge(Client):
(file transfer, sandbox data). The owner authorizes us via `/grant <name>` (file transfer, sandbox data). The owner authorizes us via `/grant <name>`
(or `/ai start <name> allow`), so we mirror the ACL here to know whether (or `/ai start <name> allow`), so we mirror the ACL here to know whether
we're allowed to act; the status frame tells us which engine + container 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: try:
frame = json.loads(text) frame = json.loads(text)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -321,7 +314,6 @@ class AgentBridge(Client):
self.sbx_engine = None self.sbx_engine = None
self.sbx_name = "" self.sbx_name = ""
self.sbx_backend = None self.sbx_backend = None
self._goose_present_cache.clear()
return return
if frame.get("_perm") != "acl": if frame.get("_perm") != "acl":
return return
@@ -382,12 +374,10 @@ class AgentBridge(Client):
- **Not granted** → advisory only: answer in chat, never touch the - **Not granted** → advisory only: answer in chat, never touch the
sandbox (`_advise`). sandbox (`_advise`).
- **Granted** → act in the *spawned sandbox*. With the Goose harness - **Granted** → act in the *spawned sandbox* via the one-shot keystroke
(default) run Goose's agentic loop INSIDE that sandbox — the container injector (`_run_simple`). The bounded host-side `native` tool-calling
/VM via the engine the broker advertised, or the host only for the loop (docs/spec-native-harness.md, Phase 2) will branch here on
explicit `local` backend — and stream its output to chat. If Goose `self.harness == "native"`; until then every granted task runs simple."""
isn't installed there, or the harness is `simple`, fall back to the
one-shot keystroke injector (`_run_simple`)."""
if not task: if not task:
await self._send_chat( await self._send_chat(
ws, f"{asker}: tell me what to run, e.g. `/ai {self.name} !create a hello.py`.") 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: if not self.granted:
await self._advise(ws, task, asker) await self._advise(ws, task, asker)
return 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) await self._run_simple(ws, task, asker)
async def _advise(self, ws, task: str, asker: str) -> None: async def _advise(self, ws, task: str, asker: str) -> None:
@@ -430,139 +411,12 @@ class AgentBridge(Client):
f"but here's how:\n{reply}", 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: async def _run_simple(self, ws, task: str, asker: str) -> None:
"""Legacy one-shot harness (granted): turn the request into shell commands """One-shot harness (granted): turn the request into shell commands with
with the code provider and type them into the shared PTY via keystroke the code provider and type them into the shared PTY via keystroke frames.
frames. Guarded by the destructive-command check + blast-radius caps. Used Guarded by the destructive-command check + blast-radius caps. This is the
when harness=simple or as the Goose fallback.""" 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) await self._send_typing(ws, True)
try: try:
context = await self._model_messages(task) context = await self._model_messages(task)
+6
View File
@@ -1,5 +1,11 @@
# hack-house → Podman backend + Goose harness — Spec # 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 > **Status:** Draft v1 · **Date:** 2026-06-07
> **Scope:** Add **Podman** as an additional sandbox backend (Docker stays), and > **Scope:** Add **Podman** as an additional sandbox backend (Docker stays), and
> make **Goose** (block/goose) the default agentic harness for the `/ai !task` > make **Goose** (block/goose) the default agentic harness for the `/ai !task`
+5 -68
View File
@@ -8,18 +8,16 @@
# #
# The baseline is untouched: ./bootstrap.sh alone never installs or enables AI. # 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 # The /ai agent's sandbox `!task` harness runs host-side (native tool-calling /
# agent uses for the sandbox `!task` path — and writes a host Goose config that # one-shot injector) and needs no extra binary, so this script installs nothing
# points it at the local Ollama. Skip it with --no-goose (the agent still works: # beyond Ollama + the model.
# the bridge falls back to its built-in one-shot injector).
# #
# usage: # 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 --release # ...and build the client in release mode
# ./bootstrap-ai.sh --check # report only; install/pull nothing # ./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 --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 --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 # ./bootstrap-ai.sh -h | --help # this help
# #
# environment: # environment:
@@ -31,22 +29,19 @@ ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
MODEL="${HH_AI_MODEL:-qwen2.5:3b}" MODEL="${HH_AI_MODEL:-qwen2.5:3b}"
OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}" OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}"
INSTALLER_URL="https://ollama.com/install.sh" INSTALLER_URL="https://ollama.com/install.sh"
GOOSE_INSTALLER_URL="https://github.com/block/goose/releases/download/stable/download_cli.sh"
RELEASE_ARGS=() RELEASE_ARGS=()
CHECK_ONLY=0 CHECK_ONLY=0
ASSUME_YES=0 ASSUME_YES=0
AI_ONLY=0 AI_ONLY=0
DO_GOOSE=1
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--release) RELEASE_ARGS+=(--release) ;; --release) RELEASE_ARGS+=(--release) ;;
--check) CHECK_ONLY=1 ;; --check) CHECK_ONLY=1 ;;
--yes|-y) ASSUME_YES=1 ;; --yes|-y) ASSUME_YES=1 ;;
--ai-only) AI_ONLY=1 ;; --ai-only) AI_ONLY=1 ;;
--no-goose) DO_GOOSE=0 ;;
-h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 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 esac
done done
@@ -74,14 +69,6 @@ if have ollama; then echo " ✓ ollama ($(ollama --version 2>&1 | head -1))"
else echo " · ollama not installed"; fi else echo " · ollama not installed"; fi
if ollama_up; then echo " ✓ ollama daemon reachable at $OLLAMA_HOST" if ollama_up; then echo " ✓ ollama daemon reachable at $OLLAMA_HOST"
else echo " · ollama daemon not reachable at $OLLAMA_HOST"; fi 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 if [[ $CHECK_ONLY -eq 1 ]]; then
echo "--check: no changes made" echo "--check: no changes made"
@@ -143,56 +130,6 @@ else
echo " ✓ model '$MODEL' ready" echo " ✓ model '$MODEL' ready"
fi 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" <<EOF
# Written by hh bootstrap-ai.sh — local-first Goose defaults (host).
GOOSE_PROVIDER: ollama
GOOSE_MODEL: $MODEL
OLLAMA_HOST: $OLLAMA_HOST
EOF
echo " ✓ wrote host Goose config → $GOOSE_CFG (ollama/$MODEL)"
fi
echo " ⓘ in-sandbox Goose (containers/VMs) is installed separately by scripts/sandbox-bootstrap.sh"
fi
echo echo
echo "AI ready. start a local agent against a running room with:" echo "AI ready. start a local agent against a running room with:"
echo " .venv/bin/python -m cmd_chat.agent <host> <port> \\" echo " .venv/bin/python -m cmd_chat.agent <host> <port> \\"
+1 -1
View File
@@ -49,7 +49,7 @@ for bin in python3 cargo; do
if have "$bin"; then echo "$bin ($($bin --version 2>&1 | head -1))" if have "$bin"; then echo "$bin ($($bin --version 2>&1 | head -1))"
else echo "$bin — REQUIRED"; missing=1; fi else echo "$bin — REQUIRED"; missing=1; fi
done 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)" if have "$bin"; then echo "$bin (optional)"
else echo " · $bin not found (optional)"; fi else echo " · $bin not found (optional)"; fi
done done
+3 -30
View File
@@ -50,36 +50,9 @@ if ! apt-get install -y --no-install-recommends $PKGS; then
done done
fi fi
# ---- Goose harness (the /ai agent's sandbox `!task` path) -------------------- # No in-sandbox agentic harness is installed here: the native `!task` harness
# Goose ships as a release binary (not apt), so install it via its official # runs the model host-side and only execs commands into this sandbox, so nothing
# installer into a system path so every container user can run it. Best-effort: # extra needs to live in the container.
# 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" <<EOF
# Written by hh sandbox-bootstrap.sh — Goose runs INSIDE this sandbox and reaches
# the host Ollama over the engine's host gateway. No host filesystem is mounted.
GOOSE_PROVIDER: ollama
GOOSE_MODEL: ${HH_GOOSE_MODEL:-qwen2.5:3b}
OLLAMA_HOST: ${HH_OLLAMA_HOST:-http://host.containers.internal:11434}
EOF
fi
fi
mkdir -p "$(dirname "$SENTINEL")" mkdir -p "$(dirname "$SENTINEL")"
date -u +%FT%TZ > "$SENTINEL" # mark done; skip on the next provision pass date -u +%FT%TZ > "$SENTINEL" # mark done; skip on the next provision pass
+18 -11
View File
@@ -2624,11 +2624,12 @@ fn handle_command(
app.sys("an AI agent is already running from this client — /ai stop first"); app.sys("an AI agent is already running from this client — /ai stop first");
} else { } else {
// Trailing flag words (any order): `allow` auto-grants the agent // Trailing flag words (any order): `allow` auto-grants the agent
// sandbox drive on launch; `plain` selects the legacy one-shot // sandbox drive on launch; the harness word `native` (default) or
// injector instead of the default Goose harness. // `simple` picks the granted-`!task` harness. `plain` is a back-compat
// alias for `simple`.
let mut raw = rest.trim(); let mut raw = rest.trim();
let mut grant_sbx = false; let mut grant_sbx = false;
let mut plain = false; let mut harness: Option<&str> = None;
loop { loop {
if let Some(head) = raw if let Some(head) = raw
.strip_suffix("allow") .strip_suffix("allow")
@@ -2638,17 +2639,20 @@ fn handle_command(
raw = head.trim(); raw = head.trim();
continue; continue;
} }
if let Some(head) = raw if let Some((word, head)) = ["native", "simple", "plain"]
.strip_suffix("plain") .iter()
.filter(|h| h.is_empty() || h.ends_with(' ')) .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(); raw = head.trim();
continue; continue;
} }
break; break;
} }
let harness = if plain { Some("simple") } else { None };
// A bare name (no ':' tag, no '/' path) is a models.toml profile; // A bare name (no ':' tag, no '/' path) is a models.toml profile;
// anything else is treated as a literal Ollama model tag. // anything else is treated as a literal Ollama model tag.
let (profile, model): (Option<&str>, &str) = if raw.is_empty() { let (profile, model): (Option<&str>, &str) = if raw.is_empty() {
@@ -2671,7 +2675,10 @@ fn handle_command(
Some(p) => format!("profile {p}"), Some(p) => format!("profile {p}"),
None => format!("ollama/{model}"), 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!( app.sys(format!(
"⛧ summoning {name} ({desc}{hdesc})… it will announce when online" "⛧ summoning {name} ({desc}{hdesc})… it will announce when online"
)); ));
@@ -3245,8 +3252,8 @@ fn spawn_agent(
.arg(model); .arg(model);
} }
} }
// Override the agent's default `!task` harness (goose) when the launcher // Override the agent's default `!task` harness when the launcher asked for a
// asked for the legacy one-shot injector via `/ai start … plain`. // specific one via `/ai start … native|simple` (plain aliases simple).
if let Some(h) = harness { if let Some(h) = harness {
cmd.arg("--harness").arg(h); cmd.arg("--harness").arg(h);
} }
+7 -27
View File
@@ -685,7 +685,7 @@ impl Backend {
/// sandbox — `docker`/`podman exec`, `multipass exec`, or host `local`. /// sandbox — `docker`/`podman exec`, `multipass exec`, or host `local`.
/// Advertised in the `_sbx:status` frame (distinct from `label`, whose 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 /// value is the cosmetic "local-shell") so the bridge can build the right
/// `goose run` invocation for the backend that holds the sandbox. /// `<engine> exec` invocation for the backend that holds the sandbox.
pub fn engine(self) -> &'static str { pub fn engine(self) -> &'static str {
match self { match self {
Backend::Local => "local", Backend::Local => "local",
@@ -777,19 +777,11 @@ pub fn prepare(
// surfaced through the returned error (shown in the error popup). // surfaced through the returned error (shown in the error popup).
let mut run = Command::new(engine); let mut run = Command::new(engine);
run.args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root"]); run.args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root"]);
// Goose (and any in-container tool) reaches the host Ollama via a // The native harness runs the model host-side and only execs commands
// gateway. Docker maps `host.docker.internal` to the host gateway IP. // into the container, so the sandbox no longer needs to reach host
// For rootless Podman the native `host.containers.internal` resolves to // Ollama. The old in-container Ollama gateway (Docker host-gateway /
// the host's *LAN* interface, which can't reach an Ollama bound to // Podman slirp4netns host-loopback) is therefore gone — and with it the
// 127.0.0.1 (the safe default) — so request slirp4netns host-loopback // rootless-Podman loopback bug it used to work around.
// 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");
}
run.args([image, "sleep", "infinity"]); run.args([image, "sleep", "infinity"]);
let out = run let out = run
.output() .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() "apt-get update -qq && apt-get install -y --no-install-recommends $HH_SBX_PKGS".into()
}); });
let pkgs_env = format!("HH_SBX_PKGS={}", sandbox_pkgs()); 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) let child = Command::new(engine)
.args([ .args([
"exec", "-i", "-e", &pkgs_env, "-e", gateway, name, "bash", "-s", "exec", "-i", "-e", &pkgs_env, name, "bash", "-s",
]) ])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::null()) .stdout(Stdio::null())