From d6d44128c0476d767517d006b607f86087c96c52 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sat, 6 Jun 2026 16:47:57 -0700 Subject: [PATCH] feat(sbx,ai): snapshot all backends w/ local export; robust AI + rejoin - /sbx save [--local] and new /sbx vmsave/vmsnaps: snapshot Docker, Multipass, and VirtualBox; --local also writes a portable artifact (docker .tar / VBox .ova) under hh-snapshots/ that survives pruning. - sandbox reappears for anyone who leaves and rejoins (host replays status + screen snapshot + ACL on Joined); SbxStatus ready handler is now idempotent so it never wipes scrollback. - received files auto-bridge into the hosted sandbox (ft::tar_path). - AI agent: translate Ollama's cryptic 404 into model/host/fix guidance. - bootstrap installs the AI layer (Ollama + default model) by default, with consent gates; --no-ai opts out, --yes skips prompts. - help menu lists the new save/vm flags. Co-Authored-By: Claude Opus 4.6 --- bootstrap-ai.sh | 36 +++++-- bootstrap.sh | 31 +++++- cmd_chat/agent/providers.py | 33 ++++++- hh/src/app.rs | 184 ++++++++++++++++++++++++++++++----- hh/src/ft.rs | 25 +++++ hh/src/sbx.rs | 186 +++++++++++++++++++++++++++++++++++- hh/src/ui.rs | 9 +- 7 files changed, 467 insertions(+), 37 deletions(-) diff --git a/bootstrap-ai.sh b/bootstrap-ai.sh index c2db871..a2c2575 100755 --- a/bootstrap-ai.sh +++ b/bootstrap-ai.sh @@ -12,7 +12,8 @@ # ./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 +# ./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 -h | --help # this help # # environment: @@ -28,13 +29,15 @@ INSTALLER_URL="https://ollama.com/install.sh" RELEASE_ARGS=() CHECK_ONLY=0 ASSUME_YES=0 +AI_ONLY=0 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 ;; -h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; - *) echo "✖ unknown arg: $arg (try --release / --check / --yes / --help)" >&2; exit 2 ;; + *) echo "✖ unknown arg: $arg (try --release / --check / --yes / --ai-only / --help)" >&2; exit 2 ;; esac done @@ -44,10 +47,14 @@ ollama_up() { curl -s --max-time 3 "$OLLAMA_HOST/api/tags" >/dev/null 2>&1; } # 0. Baseline setup (venv + server/agent deps + client build). The agent's own # runtime deps (requests, websockets) are already in requirements.txt, so the # baseline install covers them — this script adds only the model runtime. -if [[ $CHECK_ONLY -eq 1 ]]; then - "$ROOT/bootstrap.sh" --check || exit $? -else - "$ROOT/bootstrap.sh" "${RELEASE_ARGS[@]}" || exit $? +# --no-ai stops bootstrap.sh re-entering this script (it now runs the AI layer +# by default). --ai-only skips the baseline entirely (caller already ran it). +if [[ $AI_ONLY -ne 1 ]]; then + if [[ $CHECK_ONLY -eq 1 ]]; then + "$ROOT/bootstrap.sh" --no-ai --check || exit $? + else + "$ROOT/bootstrap.sh" --no-ai "${RELEASE_ARGS[@]}" || exit $? + fi fi echo @@ -95,10 +102,25 @@ if ! ollama_up; then echo " ✓ daemon up" fi -# 4. Pull the default model (idempotent). +# 4. Pull the default model (idempotent). Pulling downloads several GB onto THIS +# machine, so ask first on an interactive terminal (skip with --yes) — never +# pull a model onto someone's box without their say-so. if ollama list 2>/dev/null | awk 'NR>1{print $1}' | grep -Fxq "$MODEL"; then echo " ✓ model '$MODEL' already present" else + echo " model '$MODEL' is not present — pulling it downloads several GB to THIS machine." + if [[ $ASSUME_YES -ne 1 ]]; then + if [[ -t 0 ]]; then + read -r -p " pull '$MODEL' now? [y/N] " ans + [[ "$ans" == [yY]* ]] || { echo " aborted — no model pulled. pull it yourself with: ollama pull $MODEL" >&2; exit 1; } + else + # No terminal to confirm on — never pull onto someone's machine + # unasked. Require explicit --yes (or an interactive yes) instead. + echo " ✖ not pulling '$MODEL' without confirmation (no terminal to ask)." >&2 + echo " re-run with --yes to allow it, or pull manually: ollama pull $MODEL" >&2 + exit 1 + fi + fi echo " pulling model '$MODEL' (first pull can take a while)…" ollama pull "$MODEL" || { echo " ✖ failed to pull '$MODEL'" >&2; exit 1; } echo " ✓ model '$MODEL' ready" diff --git a/bootstrap.sh b/bootstrap.sh index ea06ac2..562c727 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -5,11 +5,18 @@ # for the server, installs its dependencies, and builds the Rust client. # # usage: -# ./bootstrap.sh # full setup (venv + deps + cargo build) +# ./bootstrap.sh # full setup (venv + deps + client + AI layer) # ./bootstrap.sh --release # build the client in release mode +# ./bootstrap.sh --no-ai # skip the AI layer (no Ollama install / model pull) +# ./bootstrap.sh --yes # don't prompt during the AI layer (install / pull) # ./bootstrap.sh --check # only report what's installed; change nothing # ./bootstrap.sh -h | --help # this help # +# The AI layer (local Ollama + a default model for the /ai agent) is set up by +# default so the agent works out of the box and nobody hits "model not found". +# It still prompts before installing/pulling on an interactive terminal — skip +# the whole thing with --no-ai, or skip the prompts with --yes. +# # After it finishes, spin up a local test session with: cd hh && ./lets-hack.sh set -uo pipefail @@ -19,12 +26,16 @@ HH_DIR="$ROOT/hh" RELEASE=0 CHECK_ONLY=0 +DO_AI=1 +ASSUME_YES=0 for arg in "$@"; do case "$arg" in --release) RELEASE=1 ;; --check) CHECK_ONLY=1 ;; + --no-ai) DO_AI=0 ;; + --yes|-y) ASSUME_YES=1 ;; -h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; - *) echo "✖ unknown arg: $arg (try --release / --check / --help)" >&2; exit 2 ;; + *) echo "✖ unknown arg: $arg (try --release / --no-ai / --yes / --check / --help)" >&2; exit 2 ;; esac done @@ -73,8 +84,22 @@ else echo " ✓ client built: hh/target/debug/hack-house" fi +# 4. AI layer (on by default): install Ollama + pull the default model so the +# /ai agent works out of the box and nobody hits "model not found" later. The +# logic lives in bootstrap-ai.sh; --ai-only skips its baseline re-run (we just +# did it). Declining/failing here is non-fatal — the baseline setup still +# stands and the AI layer can be added later with ./bootstrap-ai.sh. +if [[ $DO_AI -eq 1 ]]; then + ai_args=(--ai-only) + [[ $ASSUME_YES -eq 1 ]] && ai_args+=(--yes) + "$ROOT/bootstrap-ai.sh" "${ai_args[@]}" \ + || echo "⚠ AI layer not completed — re-run ./bootstrap-ai.sh when ready" >&2 +fi + echo echo "ready. next steps:" echo " cd hh && ./lets-hack.sh # local test session (server + clients in tmux)" echo " # or run the server + client by hand — see README.MD" -echo " # want the local AI agent? ./bootstrap-ai.sh (installs Ollama + a model)" +if [[ $DO_AI -eq 0 ]]; then + echo " # AI layer skipped (--no-ai); add it later with ./bootstrap-ai.sh" +fi diff --git a/cmd_chat/agent/providers.py b/cmd_chat/agent/providers.py index 664231e..8324214 100644 --- a/cmd_chat/agent/providers.py +++ b/cmd_chat/agent/providers.py @@ -63,6 +63,35 @@ class OllamaProvider: opts["num_thread"] = self.num_thread return opts + def _raise_for_status(self, r: requests.Response) -> None: + """Turn an Ollama HTTP error into an actionable message. + + Ollama answers /api/chat with 404 + ``{"error": "model ... not found"}`` + when the model isn't pulled on the box running this agent. Because the + agent talks to *its own* localhost:11434, a teammate who summoned /ai + without that model pulled hits this even when the host has it. The bare + ``raise_for_status`` only reports "404 Not Found for url", hiding the + cause — so name the model, the host, and the fix instead. This text is + what the bridge posts to the room as ``[ai error: …]``. + """ + if r.ok: + return + try: + detail = (r.json().get("error") or "").strip() + except ValueError: + detail = (r.text or "").strip() + if r.status_code == 404: + raise RuntimeError( + f"model '{self.model}' isn't pulled on the ollama at {self.host} " + f"(the agent uses ollama on the machine that ran /ai, not the host). " + f"fix: `ollama pull {self.model}` there, or `/ai start ` " + f"for a cloud model. [{detail or 'model not found'}]" + ) + raise RuntimeError( + f"ollama at {self.host} returned {r.status_code}" + + (f": {detail}" if detail else "") + ) + def complete(self, system: str, messages: list[Msg]) -> str: payload = { "model": self.model, @@ -73,7 +102,7 @@ class OllamaProvider: + [{"role": m.role, "content": m.content} for m in messages], } r = requests.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout) - r.raise_for_status() + self._raise_for_status(r) return (r.json().get("message", {}).get("content") or "").strip() def stream(self, system: str, messages: list[Msg]): @@ -89,7 +118,7 @@ class OllamaProvider: } with requests.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout, stream=True) as r: - r.raise_for_status() + self._raise_for_status(r) for line in r.iter_lines(): if not line: continue diff --git a/hh/src/app.rs b/hh/src/app.rs index 5c5c02a..eda576e 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -374,11 +374,25 @@ impl App { cols, } => { if ready { - self.sandbox = Some(SbxView { - parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000), - backend: backend.clone(), - }); - self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive")); + // Converge on the shared shell. If we already track this + // sandbox, just match its size — recreating the parser would + // wipe scrollback and re-announce on every re-broadcast. The + // owner re-sends `status:ready` whenever a member (re)joins, so + // this MUST be idempotent for everyone already in the room; only + // a genuinely new sandbox (none yet) is created and announced. + match &mut self.sandbox { + Some(v) => { + v.parser.set_size(rows.max(1), cols.max(1)); + v.backend = backend.clone(); + } + None => { + self.sandbox = Some(SbxView { + parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000), + backend: backend.clone(), + }); + self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive")); + } + } } else { self.sandbox = None; self.driving = false; @@ -534,11 +548,14 @@ fn handle_ft( out: &UnboundedSender, room: &Arc, downloads: &std::path::Path, -) { +) -> Option { + // Set to the saved path when a transfer completes & verifies, so the caller + // can auto-bridge it into a running sandbox. + let mut saved = None; match f { ft::Ft::Offer(o) => { if o.from == app.me { - return; // our own offer echo + return None; // our own offer echo } app.sys(format!( "⛧ {} offers {} ({}{}) — /accept or /reject", @@ -604,11 +621,14 @@ fn handle_ft( app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name)); } else { match ft::save(downloads, &t.meta, &t.buf) { - Ok(p) => app.sys(format!( - "⛧ saved {} ({}) — verified ✓", - p.display(), - ft::human(t.buf.len()) - )), + Ok(p) => { + app.sys(format!( + "⛧ saved {} ({}) — verified ✓", + p.display(), + ft::human(t.buf.len()) + )); + saved = Some(p); + } Err(e) => app.err(format!("save failed: {e}")), } } @@ -624,6 +644,7 @@ fn handle_ft( } } } + saved } /// Put the terminal back the way we found it: leave raw mode, leave the @@ -970,7 +991,42 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme } } } - Net::Ft(f) => handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads), + Net::Ft(f) => { + // On a received, SHA-verified file, auto-bridge it into + // a sandbox we host so the whole clergy can use it from + // the shared shell. Runs off the UI thread (docker/mp + // exec + tar stream) and reports back via the app channel. + // A local backend already shares the host fs — nothing to do. + if let Some(path) = + handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads) + { + if let Some((be, name)) = &broker_meta { + if !matches!(be, sbx::Backend::Local) { + let (be, name) = (*be, name.clone()); + let run_user = sbx::run_user_for( + be, + app.owner.as_deref().unwrap_or(app.me.as_str()), + ); + let tx = app_tx.clone(); + tokio::spawn(async move { + let res = tokio::task::spawn_blocking(move || { + sbx::push(be, &name, &run_user, &path) + }) + .await; + let _ = match res { + Ok(Ok(dest)) => tx.send(Net::Sys(format!( + "⛧ bridged into sandbox → {dest}" + ))), + Ok(Err(e)) => tx.send(Net::Sys(format!( + "(received file not bridged into sandbox: {e})" + ))), + Err(e) => tx.send(Net::Err(format!("bridge task: {e}"))), + }; + }); + } + } + } + } // The broker renders its sandbox locally from the PTY, so it // ignores its own echoed status/data; everyone else uses them. Net::SbxData(b) => { @@ -980,12 +1036,27 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme } Net::SbxStatus { .. } if broker.is_some() => {} ev @ Net::Joined(_) => { - // A late joiner (e.g. a just-summoned agent) missed any - // ACL broadcast sent before they connected. If we host the - // sandbox, re-broadcast so their local grant state syncs — - // this is what makes `/ai start allow` actually reach - // the agent. + // Someone (re)entered and missed everything broadcast + // before they connected. If we host the sandbox, replay + // it so it reappears for them: `status:ready` makes the + // pane show up, the screen snapshot (`_sbx:data`) paints + // its current contents instead of a blank pane, and the + // ACL re-broadcast re-syncs grant state (also what makes + // `/ai start allow` reach a freshly-joined agent). + // The status/data handlers are idempotent, so members + // already in the room aren't disturbed by the replay. if broker.is_some() { + if let (Some(v), Some((be, _))) = (&app.sandbox, &broker_meta) { + let (rows, cols) = v.parser.screen().size(); + send_frame(&out_tx, &session.room, json!({ + "_sbx":"status","state":"ready", + "backend": be.label(),"rows": rows,"cols": cols + })); + let snap = v.parser.screen().contents_formatted(); + send_frame(&out_tx, &session.room, json!({ + "_sbx":"data","b64": STANDARD.encode(&snap) + })); + } broadcast_acl(&out_tx, &session.room, &app); } app.apply(ev); @@ -1360,14 +1431,28 @@ fn handle_command( } } Some("save") => { - let label = p.next().unwrap_or("snap").to_string(); + // `--local` (alias `-l`) also writes a portable, standalone copy + // under hh-snapshots/ (a docker `.tar`) the saver fully owns; the + // default keeps just the in-backend snapshot. The label is the + // first non-flag arg. + let args: Vec<&str> = p.collect(); + let local = args.iter().any(|a| matches!(*a, "--local" | "-l")); + let label = args + .iter() + .copied() + .find(|a| !a.starts_with('-')) + .unwrap_or("snap") + .to_string(); if !is_snap_label(&label) { app.sys("snapshot label must be alphanumerics, '.', '_' or '-'"); } else if let Some((be, name)) = broker_meta.clone() { - app.sys(format!("saving sandbox state as '{label}'…")); + app.sys(format!( + "saving sandbox state as '{label}'{}…", + if local { " (+ local copy)" } else { "" } + )); let (tx, lbl) = (app_tx.clone(), label.clone()); tokio::spawn(async move { - let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label)).await; + let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label, local)).await; let _ = match res { Ok(Ok(desc)) => tx.send(Net::Sys(format!( "⛧ saved sandbox → {desc} · reload with `/sbx load {lbl}`"))), @@ -1447,6 +1532,61 @@ fn handle_command( }; }); } + Some("vmsave") => { + // Snapshot a local VirtualBox VM. `--local` (alias `-l`) also + // exports a portable `.ova` appliance under hh-snapshots/. + let args: Vec<&str> = p.collect(); + let local = args.iter().any(|a| matches!(*a, "--local" | "-l")); + let mut pos = args.iter().copied().filter(|a| !a.starts_with('-')); + match pos.next() { + None => app.sys( + "usage: /sbx vmsave [label] [--local] (snapshot a VirtualBox VM; list VMs with /sbx vms)", + ), + Some(vm) => { + let label = pos.next().unwrap_or("snap").to_string(); + if !is_snap_label(&label) { + app.sys("snapshot label must be alphanumerics, '.', '_' or '-'"); + } else { + app.sys(format!( + "snapshotting VM '{vm}' as '{label}'{}…", + if local { " (+ local .ova)" } else { "" } + )); + let (tx, vm) = (app_tx.clone(), vm.to_string()); + tokio::spawn(async move { + let res = tokio::task::spawn_blocking(move || { + sbx::vm_save_state(&vm, &label, local) + }) + .await; + let _ = match res { + Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ saved VM → {desc}"))), + Ok(Err(e)) => tx.send(Net::Err(format!("vmsave failed: {e}"))), + Err(e) => tx.send(Net::Err(format!("vmsave task: {e}"))), + }; + }); + } + } + } + } + Some("vmsnaps") => { + match p.next() { + None => app.sys("usage: /sbx vmsnaps (list a VirtualBox VM's snapshots)"), + Some(vm) => { + let (tx, vm) = (app_tx.clone(), vm.to_string()); + tokio::spawn(async move { + let res = tokio::task::spawn_blocking(move || sbx::vm_snapshots(&vm)).await; + let _ = match res { + Ok(Ok(v)) if !v.is_empty() => { + tx.send(Net::Sys(format!("VM snapshots: {}", v.join(", ")))) + } + Ok(Ok(_)) => tx.send(Net::Sys( + "no VM snapshots yet — `/sbx vmsave [label]` to make one".into())), + Ok(Err(e)) => tx.send(Net::Err(format!("vmsnaps: {e}"))), + Err(e) => tx.send(Net::Err(format!("vmsnaps task: {e}"))), + }; + }); + } + } + } Some("gui") => { let gargs: Vec<&str> = p.collect(); let first = gargs.iter().copied().find(|a| !a.starts_with('-')); @@ -1511,7 +1651,7 @@ fn handle_command( } } _ => app.sys( - "usage: /sbx launch [local|docker|multipass] [image] · gui · vms · stop · save [label] · load