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
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:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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> \\"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user