chore: consolidate all shell scripts into hh/scripts/
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
Move the 14 hack-house scripts (bootstrap, lets-hack, host-room, smoke, demo/film harnesses, connect/join, ensure-docker/vbox) into hh/scripts/ and fix their path resolution for the deeper location. Update cross-refs: sbx.rs script consts, app.rs hints, CI, direnv .envrc, README, and docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# bootstrap-ai.sh — optional AI layer for hack-house (Ollama, local-first)
|
||||
#
|
||||
# Runs the baseline ./bootstrap.sh first, then sets up everything the /ai agent
|
||||
# bridge needs to run a LOCAL model: installs Ollama (if missing) and pulls a
|
||||
# default model. Cloud providers (anthropic / openai) need no install — just an
|
||||
# API key in the agent's env — so this script only handles the local default.
|
||||
#
|
||||
# The baseline is untouched: ./bootstrap.sh alone never installs or enables AI.
|
||||
#
|
||||
# usage:
|
||||
# ./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 -h | --help # this help
|
||||
#
|
||||
# environment:
|
||||
# HH_AI_MODEL model to pull (default: qwen2.5:3b)
|
||||
# OLLAMA_HOST daemon URL (default: http://localhost:11434)
|
||||
set -uo pipefail
|
||||
|
||||
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"
|
||||
|
||||
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 / --ai-only / --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
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.
|
||||
# --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/hh/scripts/bootstrap.sh" --no-ai --check || exit $?
|
||||
else
|
||||
"$ROOT/hh/scripts/bootstrap.sh" --no-ai "${RELEASE_ARGS[@]}" || exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── AI layer (Ollama, local) ──"
|
||||
|
||||
# 1. Report current state.
|
||||
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
|
||||
|
||||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||||
echo "--check: no changes made"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Install Ollama if missing. The official installer pipes a remote script into
|
||||
# a shell, so we print the exact command and — on an interactive terminal —
|
||||
# ask first (skip with --yes). Already installed → nothing to do.
|
||||
if ! have ollama; then
|
||||
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||
echo " ✖ automatic install is Linux-only." >&2
|
||||
echo " install Ollama for your OS from https://ollama.com/download, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Ollama is not installed. The official installer will run:"
|
||||
echo " curl -fsSL $INSTALLER_URL | sh"
|
||||
if [[ $ASSUME_YES -ne 1 && -t 0 ]]; then
|
||||
read -r -p " proceed? [y/N] " ans
|
||||
[[ "$ans" == [yY]* ]] || { echo " aborted — install Ollama yourself, then re-run." >&2; exit 1; }
|
||||
fi
|
||||
curl -fsSL "$INSTALLER_URL" | sh || { echo " ✖ Ollama install failed" >&2; exit 1; }
|
||||
have ollama || { echo " ✖ ollama still not on PATH after install" >&2; exit 1; }
|
||||
echo " ✓ ollama installed"
|
||||
fi
|
||||
|
||||
# 3. Make sure the daemon is up. The Linux installer usually registers a systemd
|
||||
# service; if it isn't running we start one in the background so we can pull.
|
||||
if ! ollama_up; then
|
||||
echo " starting ollama daemon…"
|
||||
if have systemctl && systemctl start ollama 2>/dev/null; then :
|
||||
else nohup ollama serve >/tmp/hh-ollama.log 2>&1 & fi
|
||||
for _ in $(seq 1 20); do ollama_up && break; sleep 1; done
|
||||
ollama_up || { echo " ✖ could not reach ollama at $OLLAMA_HOST (see /tmp/hh-ollama.log)" >&2; exit 1; }
|
||||
echo " ✓ daemon up"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "AI ready. start a local agent against a running room with:"
|
||||
echo " .venv/bin/python -m cmd_chat.agent <host> <port> \\"
|
||||
echo " --password <room-pw> --provider ollama --model $MODEL --no-tls"
|
||||
echo
|
||||
echo "tip: pick a different model with HH_AI_MODEL=llama3 ./bootstrap-ai.sh"
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# bootstrap.sh — one-shot setup for hack-house
|
||||
#
|
||||
# Gets a fresh clone ready to run: checks prerequisites, creates a Python venv
|
||||
# for the server, installs its dependencies, and builds the Rust client.
|
||||
#
|
||||
# usage:
|
||||
# ./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 && ./scripts/lets-hack.sh
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
VENV="$ROOT/.venv"
|
||||
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 / --no-ai / --yes / --check / --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# 1. Prerequisites. python3 + cargo are required; tmux/docker/multipass are
|
||||
# optional (only needed for the test harness / certain sandbox backends).
|
||||
echo "checking prerequisites…"
|
||||
missing=0
|
||||
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 multipass direnv; do
|
||||
if have "$bin"; then echo " ✓ $bin (optional)"
|
||||
else echo " · $bin not found (optional)"; fi
|
||||
done
|
||||
if [[ $missing -eq 1 ]]; then
|
||||
echo "✖ install the required tools above, then re-run ./bootstrap.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||||
echo "--check: prerequisites OK, no changes made"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Python venv + server dependencies.
|
||||
if [[ ! -d "$VENV" ]]; then
|
||||
echo "creating venv at $VENV…"
|
||||
python3 -m venv "$VENV" || { echo "✖ could not create venv" >&2; exit 1; }
|
||||
fi
|
||||
echo "installing server dependencies…"
|
||||
"$VENV/bin/pip" install --quiet --upgrade pip || true
|
||||
"$VENV/bin/pip" install --quiet -r "$ROOT/requirements.txt" \
|
||||
|| { echo "✖ pip install failed — see output above" >&2; exit 1; }
|
||||
echo " ✓ server deps installed into .venv"
|
||||
|
||||
# 3. Build the Rust client.
|
||||
echo "building the client…"
|
||||
if [[ $RELEASE -eq 1 ]]; then
|
||||
( cd "$HH_DIR" && cargo build --release ) || { echo "✖ cargo build --release failed" >&2; exit 1; }
|
||||
echo " ✓ client built: hh/target/release/hack-house"
|
||||
else
|
||||
( cd "$HH_DIR" && cargo build ) || { echo "✖ cargo build failed" >&2; exit 1; }
|
||||
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/hh/scripts/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 && ./scripts/lets-hack.sh # local test session (server + clients in tmux)"
|
||||
echo " # or run the server + client by hand — see README.MD"
|
||||
if [[ $DO_AI -eq 0 ]]; then
|
||||
echo " # AI layer skipped (--no-ai); add it later with hh/scripts/bootstrap-ai.sh"
|
||||
fi
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# connect.sh — join a hack-house room without leaving the password on disk.
|
||||
#
|
||||
# The password lives only in RAM: it is read into a shell variable (never a
|
||||
# file), and the interactive prompt keeps it out of your shell history. Supply
|
||||
# it three ways, most to least private:
|
||||
# 1) interactive (recommended): ./connect.sh alice 100.117.177.50
|
||||
# → prompts "room password:" with no echo
|
||||
# 2) environment: HH_PASSWORD=secret ./connect.sh alice <host>
|
||||
# 3) flag: ./connect.sh alice <host> -p secret
|
||||
#
|
||||
# Caveat: however it arrives, the client receives the password as a CLI argument,
|
||||
# so it is briefly visible in the process list (ps) to other *local* users for
|
||||
# the lifetime of the session. Nothing is ever written to disk.
|
||||
#
|
||||
# Usage: ./connect.sh [NAME] [HOST] [-p PASSWORD] [-P PORT] [--tls] [--insecure]
|
||||
# NAME display handle; omit to be prompted for one on join
|
||||
# HOST server IP/host (default: 127.0.0.1)
|
||||
# -P port (default: 4173, or $HH_PORT)
|
||||
# --tls use wss/https instead of the default plaintext-over-Tailscale
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DEFAULT_PORT=4173
|
||||
DEFAULT_HOST=127.0.0.1
|
||||
|
||||
NAME=""
|
||||
HOST=""
|
||||
PORT="${HH_PORT:-$DEFAULT_PORT}"
|
||||
PASSWORD="${HH_PASSWORD:-}"
|
||||
NO_TLS=1 # rooms run --no-tls over Tailscale/LAN by default
|
||||
INSECURE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-p|--password) PASSWORD="$2"; shift 2 ;;
|
||||
-P|--port) PORT="$2"; shift 2 ;;
|
||||
--tls) NO_TLS=0; shift ;;
|
||||
--insecure) INSECURE=1; shift ;;
|
||||
-h|--help) sed -n '2,/^set /{/^set /d;s/^# \{0,1\}//;p}' "$0"; exit 0 ;;
|
||||
-*) echo "✖ unknown option: $1" >&2; exit 2 ;;
|
||||
*)
|
||||
if [[ -z "$NAME" ]]; then NAME="$1"
|
||||
elif [[ -z "$HOST" ]]; then HOST="$1"
|
||||
else echo "✖ unexpected argument: $1" >&2; exit 2; fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
HOST="${HOST:-$DEFAULT_HOST}"
|
||||
|
||||
# No password yet? Prompt with no echo — straight into RAM, out of history.
|
||||
if [[ -z "$PASSWORD" ]]; then
|
||||
read -rsp "⛧ room password: " PASSWORD < /dev/tty
|
||||
echo
|
||||
fi
|
||||
[[ -n "$PASSWORD" ]] || { echo "✖ a password is required" >&2; exit 1; }
|
||||
|
||||
BIN=./target/release/hack-house
|
||||
[[ -x "$BIN" ]] || BIN=./target/debug/hack-house
|
||||
[[ -x "$BIN" ]] || { echo "✖ no hack-house binary — run: cargo build --release" >&2; exit 1; }
|
||||
|
||||
args=(connect "$HOST" "$PORT")
|
||||
[[ -n "$NAME" ]] && args+=("$NAME") # omit → client prompts for a handle
|
||||
args+=(--password "$PASSWORD")
|
||||
[[ "$NO_TLS" -eq 1 ]] && args+=(--no-tls)
|
||||
[[ "$INSECURE" -eq 1 ]] && args+=(--insecure)
|
||||
|
||||
# exec replaces this shell, so our $PASSWORD copy dies here; only the child holds
|
||||
# it (via argv), and we never exported it into the child's environment.
|
||||
exec "$BIN" "${args[@]}"
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env bash
|
||||
# demo-save-load.sh — PoC harness for the "persistent sandbox" video beat.
|
||||
#
|
||||
# Flow (see docs/demo-save-load-poc.md):
|
||||
# session A: /sbx launch docker → /ai start → /grant qwen2.5:3b →
|
||||
# /ai qwen2.5:3b !build fib.py & run it → /sbx save buildbox → quit
|
||||
# prove: container purged on quit, but hh-snap:buildbox image survives
|
||||
# session B: fresh client → /sbx load buildbox → the model's code is intact
|
||||
#
|
||||
# Headless: drives the ratatui client over tmux send-keys, asserts via
|
||||
# capture-pane + `docker exec`. PoC/correctness first; feeds video-toolkit later.
|
||||
#
|
||||
# Usage: hh/scripts/demo-save-load.sh [--keep]
|
||||
# --keep leave the server, container, image and tmux sessions up afterwards
|
||||
set -uo pipefail
|
||||
|
||||
# ---- config -----------------------------------------------------------------
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
# Pick a free TCP port so we never collide with a stale server from another
|
||||
# session (a leftover server on a fixed port answers SRP with its own password
|
||||
# → spurious 401s). Honour an explicit $PORT if the caller forces one.
|
||||
pick_port() { local p; for p in $(seq 4200 4280); do ss -ltn 2>/dev/null | grep -q ":$p " || { echo "$p"; return; }; done; echo 4173; }
|
||||
PORT="${PORT:-$(pick_port)}"
|
||||
PW="${PW:-malware-bless}"
|
||||
LABEL="${LABEL:-buildbox}"
|
||||
IMG="${IMG:-python:3.12-slim}" # base image: ships python3 so the built code runs
|
||||
CTR="hack-house" # sbx::SBX_NAME — the container/instance name
|
||||
SNAP="hh-snap:${LABEL}"
|
||||
PY="$REPO/.venv/bin/python"
|
||||
BIN="$REPO/hh/target/debug/hack-house"
|
||||
SRV_SESS="hhpoc-srv"
|
||||
A_SESS="hhpoc-a"
|
||||
B_SESS="hhpoc-b"
|
||||
EVID="$(mktemp -d /tmp/hh-poc.XXXXXX)"
|
||||
KEEP=0; [[ "${1:-}" == "--keep" ]] && KEEP=1
|
||||
|
||||
GREEN=$'\e[32m'; RED=$'\e[31m'; YEL=$'\e[33m'; DIM=$'\e[2m'; RST=$'\e[0m'
|
||||
step() { printf '\n%s== %s ==%s\n' "$YEL" "$*" "$RST"; }
|
||||
ok() { printf '%s ok %s%s\n' "$GREEN" "$*" "$RST"; }
|
||||
bad() { printf '%s XX %s%s\n' "$RED" "$*" "$RST"; }
|
||||
note() { printf '%s %s%s\n' "$DIM" "$*" "$RST"; }
|
||||
|
||||
FAIL=0
|
||||
fail() { bad "$*"; FAIL=1; }
|
||||
|
||||
cleanup() {
|
||||
if [[ $KEEP -eq 1 ]]; then
|
||||
note "--keep: leaving server/sessions/image up. Evidence: $EVID"
|
||||
return
|
||||
fi
|
||||
step "cleanup"
|
||||
tmux kill-session -t "$A_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$B_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$SRV_SESS" 2>/dev/null
|
||||
docker rm -f "$CTR" >/dev/null 2>&1
|
||||
docker rmi -f "$SNAP" >/dev/null 2>&1
|
||||
note "removed container + $SNAP; sessions killed. Evidence kept: $EVID"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---- helpers ----------------------------------------------------------------
|
||||
# say <session> <text> : type a literal line then Enter (no Ctrl-U; renders race)
|
||||
say() {
|
||||
local sess="$1"; shift
|
||||
tmux send-keys -t "$sess" -l "$*"
|
||||
sleep 0.4
|
||||
tmux send-keys -t "$sess" Enter
|
||||
sleep 0.6
|
||||
}
|
||||
cap() { tmux capture-pane -t "$1" -p 2>/dev/null; } # snapshot a pane to stdout
|
||||
snap_evid() { cap "$1" > "$EVID/$2.txt"; } # ...and save it
|
||||
|
||||
# wait_for <session> <regex> <timeout_s> : poll the pane until regex appears
|
||||
wait_for() {
|
||||
local sess="$1" re="$2" t="${3:-30}" i=0
|
||||
while (( i < t*2 )); do
|
||||
cap "$sess" | grep -qE "$re" && return 0
|
||||
sleep 0.5; ((i++))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# wait_cmd <cmd...> : succeeds within a timeout (seconds via $WT, default 30)
|
||||
wait_cmd() {
|
||||
local t="${WT:-30}" i=0
|
||||
while (( i < t )); do "$@" >/dev/null 2>&1 && return 0; sleep 1; ((i++)); done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- 0. preflight -----------------------------------------------------------
|
||||
step "preflight"
|
||||
command -v tmux >/dev/null || { echo "tmux required"; exit 2; }
|
||||
[[ -x "$PY" ]] || { echo "venv python missing: $PY"; exit 2; }
|
||||
docker info >/dev/null 2>&1 || { echo "docker daemon down - start it first"; exit 2; }
|
||||
ollama list 2>/dev/null | grep -q 'qwen2.5-coder' || note "warn: qwen2.5-coder not in 'ollama list' (coder path may fall back)"
|
||||
ollama list 2>/dev/null | grep -q 'qwen2.5:3b' || note "warn: qwen2.5:3b not present (chat default)"
|
||||
docker image inspect "$IMG" >/dev/null 2>&1 || { echo "pulling $IMG..."; docker pull "$IMG"; }
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
step "building client (debug)"; ( cd "$REPO/hh" && cargo build ) || exit 2
|
||||
fi
|
||||
ok "tools present, docker up, models checked"
|
||||
note "evidence dir: $EVID"
|
||||
|
||||
# clear any stale state
|
||||
tmux kill-session -t "$A_SESS" 2>/dev/null; tmux kill-session -t "$B_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$SRV_SESS" 2>/dev/null
|
||||
docker rm -f "$CTR" >/dev/null 2>&1
|
||||
docker rmi -f "$SNAP" >/dev/null 2>&1
|
||||
|
||||
# ---- 1. server --------------------------------------------------------------
|
||||
step "boot server :$PORT"
|
||||
tmux new-session -d -s "$SRV_SESS" -x 200 -y 50 \
|
||||
"cd '$REPO' && '$PY' cmd_chat.py serve 127.0.0.1 $PORT --password '$PW' --no-tls 2>&1 | tee '$EVID/server.log'"
|
||||
WT=20 wait_cmd bash -c "grep -qiE 'listening|running|serving|started|websocket' '$EVID/server.log'" \
|
||||
|| sleep 3 # some builds log nothing; give it a beat
|
||||
ok "server session up"
|
||||
|
||||
# ---- 2. session A: client ---------------------------------------------------
|
||||
step "session A - alice joins"
|
||||
tmux new-session -d -s "$A_SESS" -x 200 -y 50 \
|
||||
"'$BIN' connect 127.0.0.1 $PORT alice --password '$PW' --no-tls 2>&1 | tee '$EVID/clientA.log'"
|
||||
wait_for "$A_SESS" 'alice|roster|hack-house|owner' 20 && ok "alice in the room" \
|
||||
|| fail "alice never joined (see $EVID/clientA.log)"
|
||||
snap_evid "$A_SESS" 01-joined
|
||||
|
||||
# ---- 3. launch docker sandbox ----------------------------------------------
|
||||
step "launch docker sandbox ($IMG)"
|
||||
say "$A_SESS" "/sbx launch docker $IMG"
|
||||
WT=60 wait_cmd docker ps --format '{{.Names}}' --filter "name=^${CTR}$" \
|
||||
&& ok "container '$CTR' running" || fail "sandbox container never came up"
|
||||
wait_for "$A_SESS" 'summoned|sandbox|ready|online' 60 >/dev/null
|
||||
snap_evid "$A_SESS" 02-sandbox
|
||||
|
||||
# ---- 4. spawn the coder agent + grant drive --------------------------------
|
||||
step "spawn agent (qwen2.5:3b chat, qwen2.5-coder:1.5b for !task)"
|
||||
say "$A_SESS" "/ai start"
|
||||
wait_for "$A_SESS" 'online|ollama|qwen' 45 && ok "agent announced" \
|
||||
|| note "no 'online' line yet - agent log: ${TMPDIR:-/tmp}/hh-agent-qwen2.5:3b.log"
|
||||
say "$A_SESS" "/grant qwen2.5:3b"
|
||||
sleep 1
|
||||
snap_evid "$A_SESS" 03-agent
|
||||
|
||||
# ---- 5. fast model builds code in the sandbox ------------------------------
|
||||
step "fast qwen builds /root/fib.py in the sandbox"
|
||||
say "$A_SESS" "/ai qwen2.5:3b !create /root/fib.py that prints the first 10 fibonacci numbers space-separated on one line, then run it with python3"
|
||||
# Give the CPU coder model room to think, then poll for the file.
|
||||
WT=150 wait_cmd docker exec "$CTR" test -s /root/fib.py
|
||||
NEED='0 1 1 2 3 5 8 13 21 34'
|
||||
runout() { docker exec "$CTR" sh -c 'cd /root && python3 fib.py' 2>&1; }
|
||||
# Accept the model's work only if the file exists AND actually runs to the right
|
||||
# sequence. A 1.5B model typed through a PTY sometimes drops indentation, so fall
|
||||
# back to a known-good file (written BEFORE save, so the snapshot is meaningful).
|
||||
if docker exec "$CTR" test -s /root/fib.py 2>/dev/null && runout | grep -qE "$NEED"; then
|
||||
ok "model wrote a working /root/fib.py"
|
||||
BUILT_BY="qwen2.5-coder"
|
||||
else
|
||||
note "model output missing or not runnable - writing deterministic fallback so the"
|
||||
note "save/load proof still completes (retry for a clean model take in the video)."
|
||||
docker exec "$CTR" sh -c 'cat > /root/fib.py <<"PY"
|
||||
a, b = 0, 1
|
||||
out = []
|
||||
for _ in range(10):
|
||||
out.append(str(a))
|
||||
a, b = b, a + b
|
||||
print(" ".join(out))
|
||||
PY'
|
||||
BUILT_BY="fallback"
|
||||
fi
|
||||
runout > "$EVID/fib-output.txt" 2>&1
|
||||
ORIG_SHA="$(docker exec "$CTR" sha256sum /root/fib.py | awk '{print $1}')"
|
||||
note "fib.py built by: $BUILT_BY"
|
||||
note "fib.py output: $(cat "$EVID/fib-output.txt")"
|
||||
docker exec "$CTR" cat /root/fib.py > "$EVID/fib-src-original.py"
|
||||
snap_evid "$A_SESS" 04-built
|
||||
grep -qE "$NEED" "$EVID/fib-output.txt" \
|
||||
&& ok "fib.py prints the sequence" || fail "fib.py output unexpected"
|
||||
|
||||
# ---- 6. snapshot to an image -----------------------------------------------
|
||||
step "/sbx save $LABEL (docker commit -> $SNAP)"
|
||||
say "$A_SESS" "/sbx save $LABEL"
|
||||
WT=40 wait_cmd bash -c "docker images $SNAP --format '{{.Tag}}' | grep -qx '$LABEL'" \
|
||||
&& ok "image $SNAP created" || fail "snapshot image not found"
|
||||
wait_for "$A_SESS" "saved|hh-snap|$LABEL" 10 >/dev/null
|
||||
snap_evid "$A_SESS" 05-saved
|
||||
|
||||
# ---- 7. close the session (quit the client) --------------------------------
|
||||
step "close session A (Ctrl-Q -> teardown purges the container)"
|
||||
tmux send-keys -t "$A_SESS" C-q
|
||||
sleep 3
|
||||
tmux kill-session -t "$A_SESS" 2>/dev/null
|
||||
WT=20 wait_cmd bash -c "! docker ps -a --format '{{.Names}}' | grep -qx '$CTR'" \
|
||||
&& ok "container '$CTR' purged on quit" || fail "container still present after quit"
|
||||
if docker images "$SNAP" --format '{{.Tag}}' | grep -qx "$LABEL"; then
|
||||
ok "image $SNAP survived the purge"
|
||||
else
|
||||
fail "image $SNAP missing after purge"
|
||||
fi
|
||||
|
||||
# ---- 8. session B: reopen and load -----------------------------------------
|
||||
step "session B - fresh client, /sbx load $LABEL"
|
||||
tmux new-session -d -s "$B_SESS" -x 200 -y 50 \
|
||||
"'$BIN' connect 127.0.0.1 $PORT alice --password '$PW' --no-tls 2>&1 | tee '$EVID/clientB.log'"
|
||||
wait_for "$B_SESS" 'alice|roster|hack-house|owner' 20 && ok "alice re-joined" \
|
||||
|| fail "alice never re-joined"
|
||||
say "$B_SESS" "/sbx load $LABEL"
|
||||
WT=60 wait_cmd docker ps --format '{{.Names}}' --filter "name=^${CTR}$" \
|
||||
&& ok "container relaunched from $SNAP" || fail "load never started a container"
|
||||
wait_for "$B_SESS" 'summoned|sandbox|ready|loading|online' 60 >/dev/null
|
||||
snap_evid "$B_SESS" 06-loaded
|
||||
|
||||
# ---- 9. the reveal: the model's code is intact -----------------------------
|
||||
step "verify the work persisted"
|
||||
WT=30 wait_cmd docker exec "$CTR" test -s /root/fib.py
|
||||
NEW_SHA="$(docker exec "$CTR" sha256sum /root/fib.py 2>/dev/null | awk '{print $1}')"
|
||||
docker exec "$CTR" cat /root/fib.py > "$EVID/fib-src-loaded.py" 2>/dev/null
|
||||
docker exec "$CTR" sh -c 'cd /root && python3 fib.py' > "$EVID/fib-output-loaded.txt" 2>&1
|
||||
note "original sha: $ORIG_SHA"
|
||||
note "loaded sha: $NEW_SHA"
|
||||
note "loaded output: $(cat "$EVID/fib-output-loaded.txt" 2>/dev/null)"
|
||||
if [[ -n "$NEW_SHA" && "$NEW_SHA" == "$ORIG_SHA" ]]; then
|
||||
ok "fib.py is byte-for-byte identical after close+reload - PERSISTENCE PROVEN"
|
||||
else
|
||||
fail "fib.py differs or missing after reload"
|
||||
fi
|
||||
# show it on the TUI for the camera
|
||||
tmux send-keys -t "$B_SESS" F2; sleep 1 # drive
|
||||
say "$B_SESS" "cat /root/fib.py && python3 /root/fib.py"
|
||||
sleep 2
|
||||
snap_evid "$B_SESS" 07-reveal
|
||||
|
||||
# ---- summary ----------------------------------------------------------------
|
||||
step "result"
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
printf '%sPoC PASS%s - built-by=%s, saved=%s, purged-on-quit, reloaded-intact\n' \
|
||||
"$GREEN" "$RST" "$BUILT_BY" "$SNAP"
|
||||
else
|
||||
printf '%sPoC FAIL%s - inspect captures in %s\n' "$RED" "$RST" "$EVID"
|
||||
fi
|
||||
note "captures: $EVID/{01-joined,02-sandbox,03-agent,04-built,05-saved,06-loaded,07-reveal}.txt"
|
||||
note "code: $EVID/fib-src-original.py vs fib-src-loaded.py"
|
||||
exit $FAIL
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# ensure-docker.sh — make sure the Docker daemon is up before /sbx launch docker.
|
||||
#
|
||||
# Without this, `docker run` fails with "Cannot connect to the Docker daemon"
|
||||
# and the sandbox launch dies with a raw error. This script detects a dead
|
||||
# daemon and — after confirmation — starts it, then waits until it's accepting
|
||||
# connections.
|
||||
#
|
||||
# usage:
|
||||
# ./ensure-docker.sh # interactive: prompt before starting the daemon
|
||||
# ./ensure-docker.sh --yes # start without prompting (used by hack-house --start)
|
||||
# ./ensure-docker.sh --check # test only; exit 0 if up, 1 if down (no changes)
|
||||
set -uo pipefail
|
||||
|
||||
ASSUME_YES=0
|
||||
CHECK_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
--check) CHECK_ONLY=1 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
daemon_up() { docker info >/dev/null 2>&1; }
|
||||
|
||||
if daemon_up; then
|
||||
[[ $CHECK_ONLY -eq 1 ]] || echo "docker daemon already running" >&2
|
||||
exit 0
|
||||
fi
|
||||
[[ $CHECK_ONLY -eq 1 ]] && exit 1
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "✖ docker is not installed — install Docker first" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Work out how to start the daemon on this platform.
|
||||
start_cmd=""
|
||||
need_sudo=0
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
start_cmd="systemctl start docker"; need_sudo=1
|
||||
elif command -v service >/dev/null 2>&1; then
|
||||
start_cmd="service docker start"; need_sudo=1
|
||||
fi
|
||||
;;
|
||||
Darwin)
|
||||
# Docker Desktop: opening the app boots the daemon VM.
|
||||
start_cmd="open -a Docker"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -z "$start_cmd" ]]; then
|
||||
echo "✖ don't know how to start the docker daemon here — start it manually" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $need_sudo -eq 1 ]] && start_cmd="sudo $start_cmd"
|
||||
|
||||
# Confirmation (skipped with --yes).
|
||||
if [[ $ASSUME_YES -ne 1 ]]; then
|
||||
printf 'docker daemon is not running. Start it with "%s"? [y/N] ' "$start_cmd" >&2
|
||||
read -r reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) echo "✖ aborted — docker daemon left stopped" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "starting docker daemon: $start_cmd" >&2
|
||||
eval "$start_cmd" || { echo "✖ failed to start docker daemon (sudo password needed? run it in a terminal)" >&2; exit 1; }
|
||||
|
||||
# Wait for it to accept connections (Desktop / a fresh VM can take a while).
|
||||
for _ in $(seq 1 60); do
|
||||
daemon_up && { echo "docker daemon is up" >&2; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "✖ docker daemon did not come up in time" >&2
|
||||
exit 1
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# ensure-vbox.sh — make sure VirtualBox is installed before /sbx launch virtualbox
|
||||
# or /sbx gui <vm>.
|
||||
#
|
||||
# Detect-first, never silent: if VirtualBox is already present this exits 0 and
|
||||
# changes nothing. If it's missing it prints the EXACT command it would run and
|
||||
# only installs with explicit consent (--yes). --plan shows a real, no-change
|
||||
# download plan (apt's own --simulate) so you can see what would be fetched.
|
||||
#
|
||||
# usage:
|
||||
# ./ensure-vbox.sh # interactive: prompt before installing
|
||||
# ./ensure-vbox.sh --yes # install without prompting (used by --install)
|
||||
# ./ensure-vbox.sh --check # test only; exit 0 if present, 1 if missing
|
||||
# ./ensure-vbox.sh --plan # show the install/download plan; change nothing
|
||||
set -uo pipefail
|
||||
|
||||
ASSUME_YES=0
|
||||
CHECK_ONLY=0
|
||||
PLAN_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
--check) CHECK_ONLY=1 ;;
|
||||
--plan|--dry-run) PLAN_ONLY=1 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# HH_VBOX_FORCE_MISSING=1 lets a demo exercise the missing→install path without
|
||||
# actually uninstalling anything (the probe is the single source of truth).
|
||||
installed() {
|
||||
[[ "${HH_VBOX_FORCE_MISSING:-0}" = "1" ]] && return 1
|
||||
command -v VBoxManage >/dev/null 2>&1 && VBoxManage --version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
vbox_version() { VBoxManage --version 2>/dev/null | head -1; }
|
||||
|
||||
# Already present: report and stop (idempotent). --plan still prints the plan.
|
||||
if installed; then
|
||||
if [[ $PLAN_ONLY -ne 1 ]]; then
|
||||
[[ $CHECK_ONLY -eq 1 ]] || echo "VirtualBox already installed ($(vbox_version)) — nothing to do" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
[[ $CHECK_ONLY -eq 1 ]] && exit 1
|
||||
|
||||
# Work out how to install on this platform, and the matching --simulate/plan cmd.
|
||||
# The plan command is deliberately runnable WITHOUT root where the tool allows it
|
||||
# (apt --simulate does), so the download plan can be shown with zero privilege.
|
||||
install_cmd=""
|
||||
plan_cmd=""
|
||||
need_sudo=0
|
||||
plan_sudo=0
|
||||
manual_note=""
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
install_cmd="apt-get install -y virtualbox"
|
||||
plan_cmd="apt-get install --simulate virtualbox" # no root needed
|
||||
need_sudo=1
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
install_cmd="dnf install -y VirtualBox"
|
||||
plan_cmd="dnf install --assumeno VirtualBox"
|
||||
need_sudo=1; plan_sudo=1
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
install_cmd="pacman -S --noconfirm virtualbox"
|
||||
plan_cmd="pacman -S --print virtualbox"
|
||||
need_sudo=1; plan_sudo=1
|
||||
fi
|
||||
;;
|
||||
Darwin)
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
install_cmd="brew install --cask virtualbox"
|
||||
plan_cmd="brew info --cask virtualbox"
|
||||
manual_note="macOS: you'll be asked to approve Oracle's kernel extension in System Settings → Privacy & Security, then reboot."
|
||||
fi
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
if command -v winget >/dev/null 2>&1; then
|
||||
install_cmd="winget install -e --id Oracle.VirtualBox"
|
||||
plan_cmd="winget show -e --id Oracle.VirtualBox"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -z "$install_cmd" ]]; then
|
||||
echo "✖ don't know how to install VirtualBox here — get it from https://www.virtualbox.org/wiki/Downloads" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd"
|
||||
[[ $plan_sudo -eq 1 ]] && plan_cmd="sudo $plan_cmd"
|
||||
|
||||
# Secure Boot needs the vboxdrv kernel module signed/enrolled (MOK) or it won't
|
||||
# load. We check and warn rather than fail opaquely — it's a one-time manual step.
|
||||
if command -v mokutil >/dev/null 2>&1; then
|
||||
if mokutil --sb-state 2>/dev/null | grep -qi 'SecureBoot enabled'; then
|
||||
echo "⚠ Secure Boot is ENABLED — after install you must enroll a MOK so the" >&2
|
||||
echo " vboxdrv kernel module can load (the installer prompts for a password" >&2
|
||||
echo " you re-enter at the blue MOK screen on next reboot)." >&2
|
||||
fi
|
||||
fi
|
||||
[[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2
|
||||
|
||||
# --plan: show the real download/install plan and change nothing.
|
||||
if [[ $PLAN_ONLY -eq 1 ]]; then
|
||||
echo "plan (no changes will be made): $plan_cmd" >&2
|
||||
eval "$plan_cmd"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Confirmation (skipped with --yes).
|
||||
if [[ $ASSUME_YES -ne 1 ]]; then
|
||||
printf 'VirtualBox is not installed. Install it with "%s"? [y/N] ' "$install_cmd" >&2
|
||||
read -r reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) echo "✖ aborted — VirtualBox left uninstalled" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "installing VirtualBox: $install_cmd" >&2
|
||||
eval "$install_cmd" || { echo "✖ install failed (sudo password needed? run it in a terminal)" >&2; exit 1; }
|
||||
|
||||
# Confirm the binary is now callable.
|
||||
if installed; then
|
||||
echo "VirtualBox is ready ($(vbox_version))" >&2
|
||||
exit 0
|
||||
fi
|
||||
echo "✖ install ran but VBoxManage is still not callable — check the install log" >&2
|
||||
exit 1
|
||||
Executable
+245
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env bash
|
||||
# film-save-load.sh — RECORD the "persistent sandbox" beat to an asciinema cast,
|
||||
# then render an MP4. Sibling of demo-save-load.sh (the correctness harness):
|
||||
# this one is for the camera, so it paces the beats and records a single,
|
||||
# continuous take of the real flow:
|
||||
#
|
||||
# launch docker sandbox → /ai start (fast qwen) → agent builds code in it
|
||||
# → /sbx save <label> → Ctrl-Q quit (container purged, image survives)
|
||||
# → fresh client → /sbx load <label> → reveal: the work is intact
|
||||
#
|
||||
# Recording trick (per the TUI-tmux recipe): the demo runs in an inner tmux
|
||||
# session; `asciinema rec` runs in its own detached session that `tmux attach`es
|
||||
# to the inner one, so it mirrors exactly what we drive with send-keys.
|
||||
#
|
||||
# Usage: hh/scripts/film-save-load.sh [--keep] [--no-render]
|
||||
# --keep leave server/sessions/container/image up afterwards
|
||||
# --no-render stop after writing the .cast (skip the mp4 render)
|
||||
set -uo pipefail
|
||||
|
||||
# ---- config -----------------------------------------------------------------
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
pick_port() { local p; for p in $(seq 4200 4280); do ss -ltn 2>/dev/null | grep -q ":$p " || { echo "$p"; return; }; done; echo 4173; }
|
||||
PORT="${PORT:-$(pick_port)}"
|
||||
PW="${PW:-malware-bless}"
|
||||
LABEL="${LABEL:-buildbox}"
|
||||
IMG="${IMG:-python:3.12-slim}"
|
||||
CTR="hack-house"
|
||||
SNAP="hh-snap:${LABEL}"
|
||||
PY="$REPO/.venv/bin/python"
|
||||
BIN="$REPO/hh/target/debug/hack-house"
|
||||
COLS=110; ROWS=32
|
||||
SRV_SESS="hhfilm-srv" # server (not recorded)
|
||||
RUN_SESS="hhfilm" # the demo pane we drive
|
||||
REC_SESS="hhfilm-rec" # asciinema attaches here and records
|
||||
OUTDIR="$REPO/docs/demo"
|
||||
CAST="$OUTDIR/save-load.cast"
|
||||
MP4="$OUTDIR/save-load.mp4"
|
||||
CODER="qwen2.5-coder:1.5b"
|
||||
NEED='0 1 1 2 3 5 8 13 21 34'
|
||||
|
||||
KEEP=0; RENDER=1
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--keep) KEEP=1 ;;
|
||||
--no-render) RENDER=0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
GREEN=$'\e[32m'; RED=$'\e[31m'; YEL=$'\e[33m'; DIM=$'\e[2m'; RST=$'\e[0m'
|
||||
step() { printf '\n%s== %s ==%s\n' "$YEL" "$*" "$RST"; }
|
||||
ok() { printf '%s ok %s%s\n' "$GREEN" "$*" "$RST"; }
|
||||
bad() { printf '%s XX %s%s\n' "$RED" "$*" "$RST"; }
|
||||
note() { printf '%s %s%s\n' "$DIM" "$*" "$RST"; }
|
||||
FAIL=0; fail() { bad "$*"; FAIL=1; }
|
||||
|
||||
cleanup() {
|
||||
if [[ $KEEP -eq 1 ]]; then
|
||||
note "--keep: leaving server/sessions/container/image up."
|
||||
return
|
||||
fi
|
||||
step "cleanup"
|
||||
tmux kill-session -t "$REC_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$RUN_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$SRV_SESS" 2>/dev/null
|
||||
docker rm -f "$CTR" >/dev/null 2>&1
|
||||
docker rmi -f "$SNAP" >/dev/null 2>&1
|
||||
note "removed container + $SNAP; sessions killed."
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---- helpers ----------------------------------------------------------------
|
||||
# type into the recorded pane: literal text, a beat, then Enter (no Ctrl-U)
|
||||
say() { tmux send-keys -t "$RUN_SESS" -l "$*"; sleep 0.5; tmux send-keys -t "$RUN_SESS" Enter; sleep 0.8; }
|
||||
key() { tmux send-keys -t "$RUN_SESS" "$@"; }
|
||||
cap() { tmux capture-pane -t "$RUN_SESS" -p 2>/dev/null; }
|
||||
wait_for() { local re="$1" t="${2:-30}" i=0; while (( i < t*2 )); do cap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; }
|
||||
wait_cmd() { local t="${WT:-30}" i=0; while (( i < t )); do "$@" >/dev/null 2>&1 && return 0; sleep 1; ((i++)); done; return 1; }
|
||||
runout() { docker exec "$CTR" sh -c 'cd /root && python3 fib.py' 2>&1; }
|
||||
|
||||
# ---- 0. preflight -----------------------------------------------------------
|
||||
step "preflight"
|
||||
command -v tmux >/dev/null || { echo "tmux required"; exit 2; }
|
||||
command -v "$HOME/anaconda3/bin/asciinema" >/dev/null || command -v asciinema >/dev/null || { echo "asciinema required"; exit 2; }
|
||||
ASCIINEMA="$( [[ -x "$HOME/anaconda3/bin/asciinema" ]] && echo "$HOME/anaconda3/bin/asciinema" || command -v asciinema )"
|
||||
[[ -x "$PY" ]] || { echo "venv python missing: $PY"; exit 2; }
|
||||
docker info >/dev/null 2>&1 || { echo "docker daemon down"; exit 2; }
|
||||
ollama list 2>/dev/null | grep -q "$CODER" || { echo "coder model $CODER not pulled"; exit 2; }
|
||||
docker image inspect "$IMG" >/dev/null 2>&1 || { echo "pulling $IMG..."; docker pull "$IMG"; }
|
||||
[[ -x "$BIN" ]] || { step "building client"; ( cd "$REPO/hh" && cargo build ) || exit 2; }
|
||||
mkdir -p "$OUTDIR"
|
||||
ok "tools present, docker up, $CODER ready"
|
||||
|
||||
# clear stale state
|
||||
tmux kill-session -t "$REC_SESS" 2>/dev/null; tmux kill-session -t "$RUN_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$SRV_SESS" 2>/dev/null
|
||||
docker rm -f "$CTR" >/dev/null 2>&1; docker rmi -f "$SNAP" >/dev/null 2>&1
|
||||
rm -f "$CAST"
|
||||
|
||||
# ---- 0b. pre-warm the coder so first-token latency on camera is short -------
|
||||
step "pre-warm $CODER (off camera)"
|
||||
"$PY" - "$CODER" <<'PY' 2>/dev/null || true
|
||||
import sys, json, urllib.request
|
||||
m = sys.argv[1]
|
||||
req = urllib.request.Request("http://127.0.0.1:11434/api/generate",
|
||||
data=json.dumps({"model": m, "prompt": "ok", "stream": False}).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=120).read()
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
ok "model warmed"
|
||||
|
||||
# ---- 1. server (not recorded) ----------------------------------------------
|
||||
step "boot server :$PORT"
|
||||
tmux new-session -d -s "$SRV_SESS" -x 200 -y 50 \
|
||||
"cd '$REPO' && '$PY' cmd_chat.py serve 127.0.0.1 $PORT --password '$PW' --no-tls 2>&1 | tee /tmp/hhfilm-server.log"
|
||||
WT=20 wait_cmd bash -c "grep -qiE 'listening|running|serving|started|websocket' /tmp/hhfilm-server.log" || sleep 3
|
||||
ok "server up"
|
||||
|
||||
# ---- 2. inner demo pane + recorder -----------------------------------------
|
||||
step "open recorded pane (${COLS}x${ROWS}) and start asciinema"
|
||||
# inner demo session we drive (bash, sized for the cast)
|
||||
tmux new-session -d -s "$RUN_SESS" -x "$COLS" -y "$ROWS" "bash --noprofile --norc"
|
||||
sleep 0.5
|
||||
tmux send-keys -t "$RUN_SESS" -l "cd '$REPO'"; tmux send-keys -t "$RUN_SESS" Enter
|
||||
tmux send-keys -t "$RUN_SESS" -l "clear"; tmux send-keys -t "$RUN_SESS" Enter
|
||||
sleep 0.5
|
||||
# recorder session: same size, just attaches to the demo session and records it
|
||||
tmux new-session -d -s "$REC_SESS" -x "$COLS" -y "$ROWS" \
|
||||
"'$ASCIINEMA' rec --overwrite -c 'tmux attach -t $RUN_SESS' '$CAST'"
|
||||
sleep 2
|
||||
ok "recording → $CAST"
|
||||
|
||||
# ---- 3. title + join --------------------------------------------------------
|
||||
say "echo '⛧ hack-house — ephemeral by default, persistent on demand'"
|
||||
sleep 1.2
|
||||
say "$BIN connect 127.0.0.1 $PORT alice --password '$PW' --no-tls"
|
||||
wait_for 'alice|roster|hack-house|owner' 20 && ok "alice joined" || fail "alice never joined"
|
||||
sleep 1.5
|
||||
|
||||
# ---- 4. launch docker sandbox ----------------------------------------------
|
||||
step "launch docker sandbox"
|
||||
say "/sbx launch docker $IMG"
|
||||
WT=60 wait_cmd docker ps --format '{{.Names}}' --filter "name=^${CTR}$" && ok "container up" || fail "sandbox never came up"
|
||||
wait_for 'summoned|sandbox|ready|online' 60 >/dev/null
|
||||
sleep 1.5
|
||||
|
||||
# ---- 5. spawn fast qwen agent (auto-grant drive) ---------------------------
|
||||
step "spawn agent (auto-grant sandbox drive)"
|
||||
say "/ai start $CODER allow"
|
||||
wait_for 'online|ollama|qwen' 45 && ok "agent online" || note "no online line yet"
|
||||
sleep 1.5
|
||||
|
||||
# ---- 6. the fast model builds code in the sandbox --------------------------
|
||||
# Transcription-only, ZERO-indentation task so the 1.5B coder can't break it
|
||||
# through the PTY. Validate-by-running; retry once; abort before save if it
|
||||
# still fails (no silent fallback in a film).
|
||||
step "fast qwen writes /root/fib.py and runs it"
|
||||
TASK="/ai $CODER !create /root/fib.py with exactly two lines and nothing else: line 1 is nums = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] and line 2 is print(*nums) then run it with: python3 /root/fib.py"
|
||||
BUILT=0
|
||||
for attempt in 1 2; do
|
||||
note "build attempt $attempt"
|
||||
say "$TASK"
|
||||
WT=180 wait_cmd docker exec "$CTR" test -s /root/fib.py
|
||||
if docker exec "$CTR" test -s /root/fib.py 2>/dev/null && runout | grep -qE "$NEED"; then
|
||||
BUILT=1; break
|
||||
fi
|
||||
note "output not yet correct; re-prompting"
|
||||
sleep 2
|
||||
done
|
||||
if [[ $BUILT -eq 1 ]]; then
|
||||
ok "model wrote a working /root/fib.py"
|
||||
else
|
||||
fail "model never produced runnable fib.py after retries — aborting before save"
|
||||
exit $FAIL
|
||||
fi
|
||||
ORIG_SHA="$(docker exec "$CTR" sha256sum /root/fib.py | awk '{print $1}')"
|
||||
note "fib output: $(runout)"
|
||||
sleep 1.5
|
||||
|
||||
# ---- 7. snapshot to an image -----------------------------------------------
|
||||
step "/sbx save $LABEL"
|
||||
say "/sbx save $LABEL"
|
||||
WT=40 wait_cmd bash -c "docker images $SNAP --format '{{.Tag}}' | grep -qx '$LABEL'" && ok "image $SNAP created" || fail "snapshot not found"
|
||||
wait_for "saved|hh-snap|$LABEL" 10 >/dev/null
|
||||
sleep 2
|
||||
|
||||
# ---- 8. close the session (Ctrl-Q purges the container) --------------------
|
||||
step "quit client (Ctrl-Q → teardown purges container)"
|
||||
key C-q
|
||||
sleep 3
|
||||
WT=20 wait_cmd bash -c "! docker ps -a --format '{{.Names}}' | grep -qx '$CTR'" && ok "container purged" || fail "container still present"
|
||||
docker images "$SNAP" --format '{{.Tag}}' | grep -qx "$LABEL" && ok "image survived purge" || fail "image missing"
|
||||
# prove it on camera
|
||||
sleep 1
|
||||
say "docker ps -a --format '{{.Names}}' | grep hack-house || echo '(no hack-house container — purged)'"
|
||||
sleep 1.5
|
||||
say "docker images hh-snap --format '⛧ {{.Repository}}:{{.Tag}}'"
|
||||
sleep 2
|
||||
|
||||
# ---- 9. fresh client → load -------------------------------------------------
|
||||
step "fresh session → /sbx load $LABEL"
|
||||
say "$BIN connect 127.0.0.1 $PORT alice --password '$PW' --no-tls"
|
||||
wait_for 'alice|roster|hack-house|owner' 20 && ok "alice re-joined" || fail "alice never re-joined"
|
||||
sleep 1.5
|
||||
say "/sbx load $LABEL"
|
||||
WT=60 wait_cmd docker ps --format '{{.Names}}' --filter "name=^${CTR}$" && ok "container relaunched" || fail "load never started"
|
||||
wait_for 'summoned|sandbox|ready|loading|online' 60 >/dev/null
|
||||
sleep 1.5
|
||||
|
||||
# ---- 10. the reveal ---------------------------------------------------------
|
||||
step "reveal: the model's code is intact"
|
||||
WT=30 wait_cmd docker exec "$CTR" test -s /root/fib.py
|
||||
NEW_SHA="$(docker exec "$CTR" sha256sum /root/fib.py 2>/dev/null | awk '{print $1}')"
|
||||
note "original sha: $ORIG_SHA"
|
||||
note "loaded sha: $NEW_SHA"
|
||||
[[ -n "$NEW_SHA" && "$NEW_SHA" == "$ORIG_SHA" ]] && ok "byte-for-byte identical — PERSISTENCE PROVEN" || fail "differs/missing after reload"
|
||||
# show it on the TUI for the camera
|
||||
key F2; sleep 1
|
||||
say "cat /root/fib.py && echo '---' && python3 /root/fib.py"
|
||||
sleep 3
|
||||
|
||||
# ---- 11. stop recording -----------------------------------------------------
|
||||
step "stop recording"
|
||||
tmux kill-session -t "$RUN_SESS" 2>/dev/null # attach exits → asciinema writes the cast
|
||||
sleep 2
|
||||
[[ -s "$CAST" ]] && ok "cast written: $CAST ($(du -h "$CAST" | cut -f1))" || fail "cast not written"
|
||||
|
||||
# ---- 12. render -------------------------------------------------------------
|
||||
if [[ $RENDER -eq 1 && $FAIL -eq 0 && -s "$CAST" ]]; then
|
||||
step "render mp4"
|
||||
"$REPO/../../video-toolkit/bin/cast2mp4.sh" "$CAST" "$MP4" --font-size 28 --theme dracula \
|
||||
|| bash ~/coding/video-toolkit/bin/cast2mp4.sh "$CAST" "$MP4" --font-size 28 --theme dracula
|
||||
[[ -s "$MP4" ]] && ok "mp4: $MP4 ($(du -h "$MP4" | cut -f1))" || fail "render produced no mp4"
|
||||
fi
|
||||
|
||||
# ---- summary ----------------------------------------------------------------
|
||||
step "result"
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
printf '%sFILM OK%s — cast=%s%s\n' "$GREEN" "$RST" "$CAST" "$( [[ -s "$MP4" ]] && echo " mp4=$MP4" )"
|
||||
else
|
||||
printf '%sFILM FAIL%s — inspect %s\n' "$RED" "$RST" "$CAST"
|
||||
fi
|
||||
exit $FAIL
|
||||
Executable
+297
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env bash
|
||||
# film-virtualbox.sh — RECORD the "VirtualBox, local GUI VM" beat and compose a
|
||||
# narrated, subtitled demo. Sibling of film-save-load.sh.
|
||||
#
|
||||
# connect alice → /sbx vms (detect VirtualBox + list VMs)
|
||||
# → /sbx gui NE-Lab-Win11 (boots the real Windows VM locally, in its GUI)
|
||||
# → capture the VM window coming up → title/result cards, TTS, subtitles
|
||||
#
|
||||
# Two captures: a clean terminal asciinema cast (the commands) and an x11grab of
|
||||
# *only* the VirtualBox window region (not the whole desktop). Then video-forge
|
||||
# stitches title→terminal→gui→result; edge-tts narration + an SRT are muxed/burned
|
||||
# on top.
|
||||
#
|
||||
# Usage: hh/scripts/film-virtualbox.sh [--keep] [--no-render] [--no-vm]
|
||||
# --keep leave server/sessions up; don't power off the VM
|
||||
# --no-render stop after the captures (skip compose)
|
||||
# --no-vm skip booting the VM (reuse an already-running one for GUI grab)
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
VTK="$HOME/coding/video-toolkit"
|
||||
FORGE_PY="$HOME/anaconda3/bin/python3"
|
||||
pick_port() { local p; for p in $(seq 4240 4290); do ss -ltn 2>/dev/null | grep -q ":$p " || { echo "$p"; return; }; done; echo 4173; }
|
||||
PORT="${PORT:-$(pick_port)}"
|
||||
PW="${PW:-malware-bless}"
|
||||
VM="${VM:-NE-Lab-Win11}"
|
||||
PY="$REPO/.venv/bin/python"
|
||||
BIN="$REPO/hh/target/debug/hack-house"
|
||||
# Two side-by-side client panes (alice | bob) in one recorded window, so the
|
||||
# cast shows BOTH parties of the collaboration at once.
|
||||
WIN_W=190; WIN_H=42
|
||||
SRV_SESS="vbxfilm-srv"
|
||||
RUN_SESS="vbxfilm"
|
||||
REC_SESS="vbxfilm-rec"
|
||||
OUTDIR="$REPO/docs/demo"
|
||||
CAST="$OUTDIR/virtualbox.cast"
|
||||
TERM_MP4="$OUTDIR/vbx-term.mp4"
|
||||
GUI_MP4="$OUTDIR/vbx-gui.mp4"
|
||||
CUT_MP4="$OUTDIR/vbx-cut.mp4"
|
||||
NARR_MP3="$OUTDIR/vbx-narration.mp3"
|
||||
SRT="$OUTDIR/vbx.srt"
|
||||
FINAL_MP4="$OUTDIR/virtualbox.mp4"
|
||||
MANIFEST="$OUTDIR/vbx-forge.json"
|
||||
ACCENT="#39ff14"
|
||||
GUI_SECS="${GUI_SECS:-18}"
|
||||
|
||||
KEEP=0; RENDER=1; BOOT_VM=1
|
||||
for a in "$@"; do case "$a" in
|
||||
--keep) KEEP=1 ;; --no-render) RENDER=0 ;; --no-vm) BOOT_VM=0 ;;
|
||||
esac; done
|
||||
|
||||
GREEN=$'\e[32m'; RED=$'\e[31m'; YEL=$'\e[33m'; DIM=$'\e[2m'; RST=$'\e[0m'
|
||||
step() { printf '\n%s== %s ==%s\n' "$YEL" "$*" "$RST"; }
|
||||
ok() { printf '%s ok %s%s\n' "$GREEN" "$*" "$RST"; }
|
||||
bad() { printf '%s XX %s%s\n' "$RED" "$*" "$RST"; }
|
||||
note() { printf '%s %s%s\n' "$DIM" "$*" "$RST"; }
|
||||
FAIL=0; fail() { bad "$*"; FAIL=1; }
|
||||
|
||||
cleanup() {
|
||||
if [[ $KEEP -eq 1 ]]; then note "--keep: leaving sessions + VM up."; return; fi
|
||||
step "cleanup"
|
||||
tmux kill-session -t "$REC_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$RUN_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$SRV_SESS" 2>/dev/null
|
||||
if [[ $BOOT_VM -eq 1 ]]; then
|
||||
VBoxManage controlvm "$VM" poweroff >/dev/null 2>&1 && note "powered off $VM"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Pane-targeted drivers. We resolve real pane IDs (APANE/BPANE) at split time
|
||||
# rather than hardcoding .0/.1 — the user's tmux may set pane-base-index 1, which
|
||||
# would shift the indices and silently drop sends to a nonexistent ".0".
|
||||
APANE=""; BPANE=""
|
||||
asay() { tmux send-keys -t "$APANE" -l "$*"; sleep 0.5; tmux send-keys -t "$APANE" Enter; sleep 0.8; }
|
||||
bsay() { tmux send-keys -t "$BPANE" -l "$*"; sleep 0.5; tmux send-keys -t "$BPANE" Enter; sleep 0.8; }
|
||||
acap() { tmux capture-pane -t "$APANE" -p 2>/dev/null; }
|
||||
bcap() { tmux capture-pane -t "$BPANE" -p 2>/dev/null; }
|
||||
await_for() { local re="$1" t="${2:-30}" i=0; while (( i < t*2 )); do acap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; }
|
||||
bwait_for() { local re="$1" t="${2:-30}" i=0; while (( i < t*2 )); do bcap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; }
|
||||
|
||||
# ---- 0. preflight -----------------------------------------------------------
|
||||
step "preflight"
|
||||
command -v tmux >/dev/null || { echo "tmux required"; exit 2; }
|
||||
command -v VBoxManage >/dev/null || { echo "VirtualBox required"; exit 2; }
|
||||
command -v xdotool >/dev/null || { echo "xdotool required"; exit 2; }
|
||||
ASCIINEMA="$( [[ -x "$HOME/anaconda3/bin/asciinema" ]] && echo "$HOME/anaconda3/bin/asciinema" || command -v asciinema )"
|
||||
[[ -n "$ASCIINEMA" ]] || { echo "asciinema required"; exit 2; }
|
||||
[[ -x "$PY" ]] || { echo "venv python missing: $PY"; exit 2; }
|
||||
VBoxManage list vms | grep -q "\"$VM\"" || { echo "VM '$VM' not registered"; exit 2; }
|
||||
[[ -x "$BIN" ]] || { step "building client"; ( cd "$REPO/hh" && cargo build ) || exit 2; }
|
||||
mkdir -p "$OUTDIR"
|
||||
ok "tools present; VM '$VM' registered"
|
||||
|
||||
tmux kill-session -t "$REC_SESS" 2>/dev/null; tmux kill-session -t "$RUN_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$SRV_SESS" 2>/dev/null
|
||||
VBoxManage controlvm "$VM" poweroff >/dev/null 2>&1 || true
|
||||
rm -f "$CAST"
|
||||
|
||||
# ---- 1. server (not recorded) ----------------------------------------------
|
||||
step "boot server :$PORT"
|
||||
tmux new-session -d -s "$SRV_SESS" -x 200 -y 50 \
|
||||
"cd '$REPO' && '$PY' cmd_chat.py serve 127.0.0.1 $PORT --password '$PW' --no-tls 2>&1 | tee /tmp/vbxfilm-srv.log"
|
||||
sleep 3
|
||||
ok "server up"
|
||||
|
||||
# ---- 2. two-party demo window + recorder -----------------------------------
|
||||
step "open recorded window (${WIN_W}x${WIN_H}, two panes) and start asciinema"
|
||||
tmux new-session -d -s "$RUN_SESS" -x "$WIN_W" -y "$WIN_H" "bash --noprofile --norc"
|
||||
APANE="$(tmux list-panes -t "$RUN_SESS" -F '#{pane_id}' | head -1)" # alice
|
||||
BPANE="$(tmux split-window -h -t "$APANE" -P -F '#{pane_id}' "bash --noprofile --norc")" # bob
|
||||
tmux select-layout -t "$RUN_SESS" even-horizontal
|
||||
sleep 0.5
|
||||
for pane in "$APANE" "$BPANE"; do
|
||||
tmux send-keys -t "$pane" -l "cd '$REPO'"; tmux send-keys -t "$pane" Enter
|
||||
tmux send-keys -t "$pane" -l "clear"; tmux send-keys -t "$pane" Enter
|
||||
done
|
||||
sleep 0.5
|
||||
tmux new-session -d -s "$REC_SESS" -x "$WIN_W" -y "$WIN_H" \
|
||||
"'$ASCIINEMA' rec --overwrite -c 'tmux attach -t $RUN_SESS' '$CAST'"
|
||||
sleep 2
|
||||
ok "recording → $CAST"
|
||||
|
||||
# ---- 3. both parties join (alice = owner-side, bob = guest/client) ----------
|
||||
asay "echo '⛧ alice — host'"; bsay "echo '⛧ bob — guest'"
|
||||
sleep 0.8
|
||||
asay "$BIN connect 127.0.0.1 $PORT alice --password '$PW' --no-tls"
|
||||
await_for 'alice|roster|hack-house|owner' 20 && ok "alice joined" || fail "alice never joined"
|
||||
bsay "$BIN connect 127.0.0.1 $PORT bob --password '$PW' --no-tls"
|
||||
bwait_for 'bob|roster|hack-house' 20 && ok "bob joined" || fail "bob never joined"
|
||||
sleep 1.5
|
||||
|
||||
# ---- 4. detect + list VMs (alice asks the room what's on the metal) ---------
|
||||
step "/sbx vms — detect VirtualBox, list VMs"
|
||||
asay "/sbx vms"
|
||||
await_for "VirtualBox .* detected|$VM" 15 && ok "detected + listed" || fail "no detect line"
|
||||
sleep 2.5
|
||||
|
||||
# ---- 5. the GUEST launches the shared VM through the consent gate -----------
|
||||
# /sbx gui is open to ANY member (not owner-gated) — the per-client consent gate
|
||||
# IS the client's permission. Bob (guest) opens it: gate → `/sbx gui yes` → boot.
|
||||
# A second gate would appear if a hypervisor held VT-x; we pre-freed it so only
|
||||
# the open-locally consent shows. On success bob's client broadcasts to the room.
|
||||
step "/sbx gui $VM — guest (bob) consents, then boots the real VM locally"
|
||||
if [[ $BOOT_VM -eq 1 ]]; then
|
||||
bsay "/sbx gui $VM"
|
||||
bwait_for "open .*locally|continue|cancel" 15 && ok "consent gate shown to guest" || note "no consent prompt (already running?)"
|
||||
sleep 2.0
|
||||
bsay "/sbx gui yes"
|
||||
bwait_for "launched $VM|already running|freeing VT-x" 25 && ok "guest launch acked" || fail "no launch ack"
|
||||
# The room learns the shared appliance is live: alice should see the notice.
|
||||
await_for "bob opened .*$VM|open your own copy" 15 && ok "host saw the room notice" || note "host notice not seen"
|
||||
sleep 1.5
|
||||
# And the host can open her own copy with one command (same host → already up).
|
||||
step "/sbx gui $VM — host (alice) opens her own copy"
|
||||
asay "/sbx gui $VM"
|
||||
await_for "already running|open .*locally|launched $VM" 15 && ok "host can open too" || note "host open not acked"
|
||||
sleep 2.0
|
||||
else
|
||||
note "--no-vm: skipping actual boot"
|
||||
fi
|
||||
sleep 2
|
||||
|
||||
# stop the terminal recording now (the rest is the GUI grab + compose)
|
||||
step "stop terminal recording"
|
||||
tmux kill-session -t "$RUN_SESS" 2>/dev/null
|
||||
sleep 2
|
||||
[[ -s "$CAST" ]] && ok "cast: $CAST ($(du -h "$CAST" | cut -f1))" || fail "cast not written"
|
||||
|
||||
# ---- 6. render the terminal cast → mp4 -------------------------------------
|
||||
step "render terminal cast → mp4"
|
||||
bash "$VTK/bin/cast2mp4.sh" "$CAST" "$TERM_MP4" --font-size 28 --theme dracula \
|
||||
&& ok "term mp4: $TERM_MP4" || fail "term render failed"
|
||||
|
||||
# ---- 7. grab ONLY the VirtualBox window region -----------------------------
|
||||
if [[ $BOOT_VM -eq 1 ]]; then
|
||||
step "capture the $VM window (${GUI_SECS}s)"
|
||||
WIN=""; for i in $(seq 1 20); do
|
||||
WIN="$(xdotool search --name "$VM" 2>/dev/null | tail -1)"
|
||||
[[ -n "$WIN" ]] && break; sleep 1
|
||||
done
|
||||
if [[ -n "$WIN" ]]; then
|
||||
xdotool windowactivate "$WIN" 2>/dev/null; sleep 1
|
||||
# Park the window at the top-left so a tall VM window can't run off the
|
||||
# bottom of the screen (x11grab refuses a capture area outside the display).
|
||||
xdotool windowmove "$WIN" 0 0 2>/dev/null; sleep 0.6
|
||||
eval "$(xdotool getwindowgeometry --shell "$WIN")" # sets X Y WIDTH HEIGHT
|
||||
read -r SCRW SCRH < <(xdotool getdisplaygeometry)
|
||||
(( X < 0 )) && X=0; (( Y < 0 )) && Y=0
|
||||
MAXW=$(( SCRW - X )); MAXH=$(( SCRH - Y ))
|
||||
(( WIDTH > MAXW )) && WIDTH=$MAXW
|
||||
(( HEIGHT > MAXH )) && HEIGHT=$MAXH
|
||||
EW=$(( WIDTH - WIDTH % 2 )); EH=$(( HEIGHT - HEIGHT % 2 ))
|
||||
note "window $WIN @ ${EW}x${EH}+${X},${Y} (screen ${SCRW}x${SCRH})"
|
||||
GEOM="${EW}x${EH}" OFF="${X},${Y}" bash "$VTK/bin/screen-rec.sh" "$GUI_MP4" "$GUI_SECS" \
|
||||
&& ok "gui mp4: $GUI_MP4" || fail "gui capture failed"
|
||||
else
|
||||
fail "could not find the $VM window"
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ $RENDER -eq 0 ]] && { step "result (--no-render)"; exit $FAIL; }
|
||||
|
||||
# ---- 8. narration (edge-tts) + subtitles -----------------------------------
|
||||
step "generate TTS narration + SRT"
|
||||
"$FORGE_PY" - "$NARR_MP3" "$SRT" <<'PY'
|
||||
import sys, asyncio, edge_tts
|
||||
from edge_tts import SubMaker
|
||||
out_mp3, out_srt = sys.argv[1], sys.argv[2]
|
||||
VOICE = "en-US-GuyNeural"
|
||||
script = (
|
||||
"Hack house now speaks VirtualBox. "
|
||||
"Ask the room what's on the metal: it detects your install and lists every VM. "
|
||||
"Any member can summon one — host or guest. Bob, the guest, opens it, and a consent gate asks first, because the window lands on his own machine. "
|
||||
"He confirms, and a real Windows eleven lab boots locally. "
|
||||
"The room sees it go live, so Alice can open her own copy with a single command. "
|
||||
"One shared appliance, each party running it locally. No pixels cross the wire — same zero knowledge trust, now with a full graphical VM."
|
||||
)
|
||||
async def main():
|
||||
sm = SubMaker()
|
||||
with open(out_mp3, "wb") as f:
|
||||
async for chunk in edge_tts.Communicate(script, VOICE, rate="-4%", boundary="WordBoundary").stream():
|
||||
if chunk["type"] == "audio":
|
||||
f.write(chunk["data"])
|
||||
elif chunk["type"] == "WordBoundary":
|
||||
sm.feed(chunk)
|
||||
open(out_srt, "w").write(sm.get_srt()) # edge-tts 7.x emits SRT directly
|
||||
asyncio.run(main())
|
||||
print("narration written")
|
||||
PY
|
||||
[[ -s "$NARR_MP3" ]] && ok "narration: $NARR_MP3 ($(du -h "$NARR_MP3" | cut -f1))" || fail "tts failed"
|
||||
[[ -s "$SRT" ]] && ok "srt: $SRT" || note "no SRT produced"
|
||||
|
||||
# ---- 9. compose the visual cut (video-forge) -------------------------------
|
||||
step "compose visual cut (forge)"
|
||||
TERM_VID="$TERM_MP4"; GUI_VID="$GUI_MP4"
|
||||
cat > "$MANIFEST" <<JSON
|
||||
{
|
||||
"meta": {"resolution": [1920, 1080], "fps": 30, "style": "technical", "crossfade": 0.4},
|
||||
"scenes": [
|
||||
{"type": "title_card", "duration": 4.0,
|
||||
"headline": "VIRTUALBOX", "subhead": "share a VM — run it locally",
|
||||
"accent": "$ACCENT", "transition_out": "fade"},
|
||||
{"type": "video", "source": "$TERM_VID",
|
||||
"transition_in": "fade", "transition_out": "fade"},
|
||||
{"type": "video", "source": "$GUI_VID",
|
||||
"transition_in": "fade", "transition_out": "fade"},
|
||||
{"type": "result_card", "duration": 6.5,
|
||||
"headline": "WHAT HAPPENED", "subhead": "both parties · client consent · zero-knowledge",
|
||||
"accent": "$ACCENT",
|
||||
"items": [
|
||||
"detected the local VirtualBox install + listed VMs",
|
||||
"any member can launch — not owner-gated",
|
||||
"the guest consented (a gate, on his own machine)",
|
||||
"a real Windows 11 VM booted locally in its GUI",
|
||||
"the room was notified — the host can open hers too",
|
||||
"one shared appliance, each party runs it locally"
|
||||
],
|
||||
"transition_in": "fade"}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
( cd "$VTK/tools/video-forge" && "$FORGE_PY" forge.py render "$MANIFEST" --output "$CUT_MP4" ) \
|
||||
&& ok "cut: $CUT_MP4" || fail "forge render failed"
|
||||
|
||||
# ---- 10. mux narration + burn subtitles → final ----------------------------
|
||||
step "mux narration + burn subtitles → final"
|
||||
if [[ -s "$CUT_MP4" && -s "$NARR_MP3" ]]; then
|
||||
TMP_AV="$OUTDIR/.vbx-av.mp4"
|
||||
# add the voiceover (the cut may be silent or have music; keep it low if present)
|
||||
if ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "$CUT_MP4" | grep -q .; then
|
||||
ffmpeg -y -loglevel error -i "$CUT_MP4" -i "$NARR_MP3" \
|
||||
-filter_complex "[0:a]volume=0.18[bg];[bg][1:a]amix=inputs=2:duration=first:dropout_transition=0[a]" \
|
||||
-map 0:v -map "[a]" -c:v copy -c:a aac -movflags +faststart "$TMP_AV"
|
||||
else
|
||||
ffmpeg -y -loglevel error -i "$CUT_MP4" -i "$NARR_MP3" \
|
||||
-map 0:v -map 1:a -c:v copy -c:a aac -shortest -movflags +faststart "$TMP_AV"
|
||||
fi
|
||||
if [[ -s "$SRT" ]]; then
|
||||
bash "$VTK/bin/captions.sh" "$TMP_AV" "$FINAL_MP4" --srt "$SRT" --accent "$ACCENT" \
|
||||
&& ok "final (subtitled): $FINAL_MP4" || { cp "$TMP_AV" "$FINAL_MP4"; note "caption burn failed; final without subs"; }
|
||||
else
|
||||
cp "$TMP_AV" "$FINAL_MP4"; note "no SRT; final without subs"
|
||||
fi
|
||||
rm -f "$TMP_AV"
|
||||
else
|
||||
fail "missing cut or narration; cannot finalize"
|
||||
fi
|
||||
|
||||
# ---- summary ----------------------------------------------------------------
|
||||
step "result"
|
||||
if [[ $FAIL -eq 0 && -s "$FINAL_MP4" ]]; then
|
||||
printf '%sFILM OK%s — %s (%s)\n' "$GREEN" "$RST" "$FINAL_MP4" "$(du -h "$FINAL_MP4" | cut -f1)"
|
||||
else
|
||||
printf '%sFILM FAIL%s — inspect %s\n' "$RED" "$RST" "$OUTDIR"
|
||||
fi
|
||||
exit $FAIL
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# host-room.sh — host a hack-house room (the server) for others to join.
|
||||
#
|
||||
# Thin, friendly wrapper around `cmd_chat.py serve`: picks the project venv,
|
||||
# mints a room password if you don't supply one, prints the exact join command
|
||||
# (preferring your Tailscale IP), and runs the server in the foreground until
|
||||
# you Ctrl-C it.
|
||||
#
|
||||
# usage:
|
||||
# ./host-room.sh # 0.0.0.0:4173, --no-tls, random password
|
||||
# ./host-room.sh 4200 # custom port
|
||||
# ./host-room.sh --host 100.x.y.z # bind a specific address
|
||||
# PW=hunter2 ./host-room.sh # pin a known password (or --password)
|
||||
# ./host-room.sh --tls --cert c.pem --key k.pem # real TLS instead of --no-tls
|
||||
# ./host-room.sh -h # full usage
|
||||
#
|
||||
# The password lives in memory only (passed via $CMD_CHAT_PASSWORD, never on the
|
||||
# command line, so it won't show up in `ps`). Reveal/share it in-app with /pw.
|
||||
set -uo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
host-room.sh — host a hack-house room (the server)
|
||||
|
||||
usage:
|
||||
./host-room.sh [PORT] [--host ADDR] [--password PW]
|
||||
./host-room.sh --tls --cert CERT --key KEY [PORT]
|
||||
./host-room.sh -h | --help
|
||||
|
||||
flags:
|
||||
--host ADDR bind address (default: 0.0.0.0 — reachable on all NICs)
|
||||
--port PORT listen port (default: 4173; also accepted positionally)
|
||||
--password PW room password (default: random; or set PW=… / --password)
|
||||
--tls serve real TLS (requires --cert and --key)
|
||||
--cert CERT TLS certificate path (implies --tls)
|
||||
--key KEY TLS private key path (implies --tls)
|
||||
-h, --help show this help and exit
|
||||
|
||||
environment (override any default):
|
||||
HOST bind address (default: 0.0.0.0)
|
||||
PORT listen port (default: 4173)
|
||||
PW room password (default: random, openssl-generated)
|
||||
|
||||
notes:
|
||||
- Default transport is --no-tls — the norm over a Tailscale tunnel. Pass --tls
|
||||
with a cert/key for a public/untrusted network.
|
||||
- Runs in the foreground; press Ctrl-C to stop the room.
|
||||
- Missing Python deps? run ./bootstrap.sh first.
|
||||
|
||||
examples:
|
||||
./host-room.sh # quick room on 0.0.0.0:4173 (--no-tls)
|
||||
PW=hunter2 ./host-room.sh 4200 # known password, port 4200
|
||||
./host-room.sh --tls --cert fullchain.pem --key privkey.pem 443
|
||||
EOF
|
||||
}
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # repo root
|
||||
PY="$ROOT/.venv/bin/python"
|
||||
[[ -x "$PY" ]] || PY="$(command -v python3 || command -v python)"
|
||||
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
PORT="${PORT:-4173}"
|
||||
PW="${PW:-}"
|
||||
USE_TLS=0
|
||||
CERT=""
|
||||
KEY=""
|
||||
|
||||
# Parse flags (a lone number is taken as the port, for `./host-room.sh 4200`).
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help|-help) usage; exit 0 ;;
|
||||
--host) HOST="$2"; shift 2 ;;
|
||||
--host=*) HOST="${1#--host=}"; shift ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--port=*) PORT="${1#--port=}"; shift ;;
|
||||
--password) PW="$2"; shift 2 ;;
|
||||
--password=*) PW="${1#--password=}"; shift ;;
|
||||
--tls) USE_TLS=1; shift ;;
|
||||
--cert) CERT="$2"; USE_TLS=1; shift 2 ;;
|
||||
--cert=*) CERT="${1#--cert=}"; USE_TLS=1; shift ;;
|
||||
--key) KEY="$2"; USE_TLS=1; shift 2 ;;
|
||||
--key=*) KEY="${1#--key=}"; USE_TLS=1; shift ;;
|
||||
[0-9]*) PORT="$1"; shift ;;
|
||||
*) echo "✖ unknown argument: $1 (try --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Mint a strong password if none was supplied — random per run, in memory only.
|
||||
if [[ -z "$PW" ]]; then
|
||||
if command -v openssl >/dev/null 2>&1; then PW="$(openssl rand -hex 12)"
|
||||
else PW="$(head -c 18 /dev/urandom | base64)"; fi
|
||||
fi
|
||||
|
||||
# Resolve transport: --no-tls by default; --tls needs both cert and key.
|
||||
if [[ $USE_TLS -eq 1 ]]; then
|
||||
[[ -n "$CERT" && -n "$KEY" ]] || { echo "✖ --tls needs both --cert and --key" >&2; exit 2; }
|
||||
[[ -f "$CERT" ]] || { echo "✖ cert not found: $CERT" >&2; exit 2; }
|
||||
[[ -f "$KEY" ]] || { echo "✖ key not found: $KEY" >&2; exit 2; }
|
||||
SERVE_FLAGS=(--cert "$CERT" --key "$KEY")
|
||||
PROTO="https"; CLIENT_FLAG="--insecure"
|
||||
else
|
||||
SERVE_FLAGS=(--no-tls)
|
||||
PROTO="http"; CLIENT_FLAG="--no-tls"
|
||||
fi
|
||||
|
||||
# Sanity-check deps so failures are an actionable hint, not a stack trace.
|
||||
if ! "$PY" -c "import sanic" >/dev/null 2>&1; then
|
||||
echo "✖ Python deps missing (sanic not importable with $PY)." >&2
|
||||
echo " run ./bootstrap.sh to set up the .venv, then retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Best IP to hand a joiner: Tailscale first (encrypted tunnel), else a global LAN IP.
|
||||
TS_IP="$(tailscale ip -4 2>/dev/null | head -1 || true)"
|
||||
LAN_IP="$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)"
|
||||
JOIN_IP="${TS_IP:-${LAN_IP:-$HOST}}"
|
||||
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo " ⛧ hack-house room — $PROTO://$HOST:$PORT"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
[[ -n "$TS_IP" ]] && echo " tailscale : $TS_IP (recommended — encrypted)"
|
||||
[[ -n "$LAN_IP" ]] && echo " lan : $LAN_IP"
|
||||
echo " password : $PW (share it; reveal in-app with /pw)"
|
||||
echo
|
||||
echo " others join with:"
|
||||
echo " python cmd_chat.py connect $JOIN_IP $PORT <name> $CLIENT_FLAG"
|
||||
echo " or, with the TUI client:"
|
||||
echo " hh/target/debug/hack-house connect $JOIN_IP $PORT <name> $CLIENT_FLAG"
|
||||
echo
|
||||
echo " Ctrl-C to stop the room."
|
||||
echo "═══════════════════════════════════════════════"
|
||||
|
||||
# Hand the password to the server via env (resolve_password reads it), so it
|
||||
# never appears in the process list. Foreground exec: Ctrl-C stops the room.
|
||||
cd "$ROOT"
|
||||
exec env CMD_CHAT_PASSWORD="$PW" "$PY" cmd_chat.py serve "$HOST" "$PORT" "${SERVE_FLAGS[@]}"
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Join the live hack-house room. Usage: ./join.sh <yourname> [host]
|
||||
# local: ./join.sh alice
|
||||
# remote: ./join.sh alice 100.117.177.50
|
||||
# The room password (a shared secret) comes from $HH_PASSWORD, else a no-echo
|
||||
# prompt — never hardcoded, so it can't drift from the room's random password.
|
||||
NAME="${1:-guest}"
|
||||
HOST="${2:-127.0.0.1}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Sync the latest code before joining, then rebuild so it takes effect. Pulls
|
||||
# are best-effort and fast-forward only: an unreachable remote or diverged
|
||||
# history just warns and is skipped — it never blocks you from joining.
|
||||
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
|
||||
for remote in gitea origin; do
|
||||
if git remote get-url "$remote" >/dev/null 2>&1; then
|
||||
echo "⛧ syncing $BRANCH from $remote…"
|
||||
git pull --ff-only "$remote" "$BRANCH" 2>&1 \
|
||||
|| echo " (skipped — couldn't fast-forward from $remote/$BRANCH)"
|
||||
fi
|
||||
done
|
||||
echo "⛧ building client…"
|
||||
cargo build --quiet 2>&1 || echo "✖ build failed — joining with the existing binary"
|
||||
|
||||
PASSWORD="${HH_PASSWORD:-}"
|
||||
if [[ -z "$PASSWORD" ]]; then
|
||||
read -rsp "⛧ room password: " PASSWORD < /dev/tty
|
||||
echo
|
||||
fi
|
||||
[[ -n "$PASSWORD" ]] || { echo "✖ a password is required" >&2; exit 1; }
|
||||
exec ./target/debug/hack-house connect "$HOST" 4173 "$NAME" --password "$PASSWORD" --no-tls
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env bash
|
||||
# lets-hack.sh — spin up a local hack-house test clergy in tmux
|
||||
#
|
||||
# Builds the client, makes sure a --no-tls server is running, then opens one
|
||||
# tmux pane per user (default: alice bob) and attaches. Each pane is a real
|
||||
# TTY, so the ratatui UI is fully interactive.
|
||||
#
|
||||
# usage:
|
||||
# ./lets-hack.sh # alice + bob on 127.0.0.1:4173
|
||||
# ./lets-hack.sh neo trinity dozer # one pane per name (tiled)
|
||||
# ./lets-hack.sh --reuse # keep an already-live server (don't reboot)
|
||||
# ./lets-hack.sh --theme neon # dress every pane in themes/neon.toml
|
||||
# THEME=crypt ./lets-hack.sh # same, via env (church | neon | crypt)
|
||||
# PORT=4200 PW=hunter2 ./lets-hack.sh # throwaway server you can kill/restart
|
||||
# ./lets-hack.sh --kill # tear the test setup down
|
||||
# ./lets-hack.sh -h # full usage (also --help / -help)
|
||||
#
|
||||
# A fresh server is booted on every run by default (an existing one on PORT is
|
||||
# stopped first), so you never inherit stale message history. Pass --reuse to
|
||||
# keep a live server — useful for reconnect tests (Ctrl-R rejoins after --kill).
|
||||
#
|
||||
# Run from inside tmux: the clergy is built in its own session and your client is
|
||||
# switched to it (switch-client). Run from a plain shell: the script attaches.
|
||||
# Either way you land in the clergy — no manual `tmux attach` needed.
|
||||
#
|
||||
# Reconnect test: use a dedicated port (e.g. PORT=4200) so `--kill` can stop
|
||||
# the server; press Ctrl-R in a pane after the drop to rejoin.
|
||||
set -uo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
lets-hack.sh — spin up a local hack-house test clergy in tmux
|
||||
|
||||
usage:
|
||||
./lets-hack.sh [USERS...] [--theme NAME] [--fresh]
|
||||
./lets-hack.sh --kill
|
||||
./lets-hack.sh -h | --help
|
||||
|
||||
arguments:
|
||||
USERS... one tmux pane per name (default: alice bob)
|
||||
|
||||
flags:
|
||||
--theme NAME dress every pane in themes/NAME.toml (church | neon | crypt)
|
||||
--reuse keep an already-live server instead of booting a fresh one
|
||||
--fresh force a fresh server (the default — kept for clarity)
|
||||
--kill tear down the tmux session and the server we started
|
||||
-h, --help show this help and exit
|
||||
|
||||
note: by default a *fresh* server is booted every run; an existing one on PORT
|
||||
is stopped first so you never inherit stale history. Use --reuse to keep it.
|
||||
|
||||
environment (override any default):
|
||||
SESSION tmux session name (default: hh-test)
|
||||
HOST server bind host (default: 127.0.0.1)
|
||||
PORT server port (default: 4173)
|
||||
PW shared room password (default: random, openssl-generated)
|
||||
THEME theme name (church | neon | crypt)
|
||||
|
||||
examples:
|
||||
./lets-hack.sh # alice + bob on 127.0.0.1:4173
|
||||
./lets-hack.sh neo trinity dozer # one pane per name (tiled)
|
||||
./lets-hack.sh --theme neon # neon vestments for every pane
|
||||
PORT=4200 PW=hunter2 ./lets-hack.sh # throwaway server you can kill/restart
|
||||
./lets-hack.sh --kill # tear the test setup down
|
||||
EOF
|
||||
}
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)" # .../hh
|
||||
ROOT="$(cd "$HERE/.." && pwd)" # repo root
|
||||
PY="$ROOT/.venv/bin/python" # venv always from the real checkout
|
||||
BIN="$HERE/target/debug/hack-house"
|
||||
|
||||
# The demo always runs a *stable* branch (default: main), never your in-progress
|
||||
# working branch. If you're on another branch (e.g. dev), the client + server are
|
||||
# built from a detached git worktree of $DEMO_BRANCH so your checkout is untouched.
|
||||
DEMO_BRANCH="${BRANCH:-main}"
|
||||
DEMO_WT="${HH_DEMO_WORKTREE:-/tmp/hh-demo-$DEMO_BRANCH}"
|
||||
|
||||
SESSION="${SESSION:-hh-test}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
PORT="${PORT:-4173}"
|
||||
# Default room password: a fresh random secret per run (openssl), not a
|
||||
# well-known hardcoded one. Set PW=... to pin a chosen/reproducible password.
|
||||
PW="${PW:-$(openssl rand -hex 12)}"
|
||||
SRV_LOG="/tmp/hh-${SESSION}-server.log"
|
||||
SRV_PIDFILE="/tmp/hh-${SESSION}-server.pid"
|
||||
THEMES_DIR="$HERE/themes"
|
||||
THEME="${THEME:-}" # name (church|neon|crypt) or empty for built-in default
|
||||
|
||||
is_up() { curl -s --max-time 2 "http://$HOST:$PORT/health" 2>/dev/null | grep -q '"status":"ok"'; }
|
||||
|
||||
# Stop the server on $PORT: the one we started (pidfile) and any process still
|
||||
# holding the port (covers an untracked/stale server). Used by --kill / --fresh.
|
||||
stop_server() {
|
||||
if [[ -f "$SRV_PIDFILE" ]]; then
|
||||
kill "$(cat "$SRV_PIDFILE")" 2>/dev/null && echo "stopped tracked server (pid $(cat "$SRV_PIDFILE"))"
|
||||
rm -f "$SRV_PIDFILE"
|
||||
fi
|
||||
local pid
|
||||
pid="$(ss -ltnpH "sport = :$PORT" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | head -1)"
|
||||
if [[ -n "$pid" ]]; then
|
||||
kill "$pid" 2>/dev/null && echo "stopped server holding :$PORT (pid $pid)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse flags and collect usernames (flags may appear anywhere).
|
||||
# REUSE=1 keeps an already-live server; the default is to boot a fresh one.
|
||||
REUSE="${REUSE:-0}"
|
||||
DO_KILL=0
|
||||
USERS=()
|
||||
want_theme=0 # set when --theme consumed the next arg as its value
|
||||
for a in "$@"; do
|
||||
if [[ $want_theme -eq 1 ]]; then THEME="$a"; want_theme=0; continue; fi
|
||||
case "$a" in
|
||||
-h|--help|-help) usage; exit 0 ;;
|
||||
--kill) DO_KILL=1 ;;
|
||||
--reuse) REUSE=1 ;;
|
||||
--fresh) REUSE=0 ;; # explicit fresh server (this is the default)
|
||||
--theme) want_theme=1 ;;
|
||||
--theme=*) THEME="${a#--theme=}" ;;
|
||||
-*) echo "✖ unknown flag: $a (try --reuse / --kill / --theme / --help)" >&2; exit 2 ;;
|
||||
*) USERS+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
[[ $want_theme -eq 1 ]] && { echo "✖ --theme needs a name (e.g. --theme neon)" >&2; exit 2; }
|
||||
|
||||
# --kill: tear down the tmux session and the server we started. (Done before
|
||||
# theme resolution so teardown never depends on a valid --theme.)
|
||||
if [[ $DO_KILL -eq 1 ]]; then
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null && echo "killed tmux session $SESSION"
|
||||
stop_server
|
||||
if [[ -e "$DEMO_WT/.git" ]]; then
|
||||
git -C "$ROOT" worktree remove --force "$DEMO_WT" 2>/dev/null && echo "removed demo worktree $DEMO_WT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Pin the demo to $DEMO_BRANCH (default: main) when we're on another branch, so
|
||||
# e.g. running this from `dev` still demos `main`. A *detached* worktree is used
|
||||
# so the branch isn't locked and your real checkout is never modified.
|
||||
cur_branch="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
|
||||
if [[ "$cur_branch" != "$DEMO_BRANCH" ]]; then
|
||||
if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$DEMO_BRANCH"; then
|
||||
if [[ -e "$DEMO_WT/.git" ]]; then
|
||||
git -C "$DEMO_WT" checkout -f --detach "$DEMO_BRANCH" >/dev/null 2>&1
|
||||
else
|
||||
git -C "$ROOT" worktree add --detach --force "$DEMO_WT" "$DEMO_BRANCH" >/dev/null 2>&1 \
|
||||
|| { echo "✖ could not create '$DEMO_BRANCH' worktree at $DEMO_WT" >&2; exit 1; }
|
||||
fi
|
||||
echo "demo pinned to '$DEMO_BRANCH' (you're on '$cur_branch') — building from $DEMO_WT"
|
||||
ROOT="$DEMO_WT"; HERE="$DEMO_WT/hh"
|
||||
BIN="$HERE/target/debug/hack-house"; THEMES_DIR="$HERE/themes"
|
||||
else
|
||||
echo "note: no local '$DEMO_BRANCH' branch — demoing current branch '$cur_branch'" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve a theme name → TOML path, or empty for the client's built-in default.
|
||||
THEME_PATH=""
|
||||
if [[ -n "$THEME" ]]; then
|
||||
THEME_PATH="$THEMES_DIR/$THEME.toml"
|
||||
if [[ ! -f "$THEME_PATH" ]]; then
|
||||
avail="$(cd "$THEMES_DIR" 2>/dev/null && ls -1 *.toml 2>/dev/null | sed 's/\.toml$//' | paste -sd' ' -)"
|
||||
echo "✖ no theme '$THEME' in $THEMES_DIR (available: ${avail:-none})" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ ${#USERS[@]} -eq 0 ]] && USERS=(alice bob)
|
||||
|
||||
# 1. build the client
|
||||
echo "building client…"
|
||||
( cd "$HERE" && cargo build --quiet ) || { echo "✖ build failed"; exit 1; }
|
||||
|
||||
# 2. (re)boot the server on $PORT. Default behaviour: always create a *fresh*
|
||||
# server — if one is already live we stop it first so we never inherit stale
|
||||
# message history / ghost users. Pass --reuse to keep an existing live server
|
||||
# (handy for reconnect tests where the server must outlive a client restart).
|
||||
if [[ $REUSE -eq 1 ]] && is_up; then
|
||||
echo "--reuse: keeping server already live on $HOST:$PORT"
|
||||
else
|
||||
if is_up; then
|
||||
echo "replacing server already live on $HOST:$PORT…"
|
||||
stop_server
|
||||
for _ in $(seq 1 20); do is_up || break; sleep 0.5; done
|
||||
is_up && { echo "✖ could not free port $PORT — is another process holding it?"; exit 1; }
|
||||
fi
|
||||
echo "booting fresh server on $HOST:$PORT…"
|
||||
"$PY" "$ROOT/cmd_chat.py" serve "$HOST" "$PORT" --password "$PW" --no-tls >"$SRV_LOG" 2>&1 &
|
||||
echo $! > "$SRV_PIDFILE"
|
||||
for _ in $(seq 1 20); do is_up && break; sleep 1; done
|
||||
is_up || { echo "✖ server did not come up — see $SRV_LOG"; exit 1; }
|
||||
echo "server up (pid $(cat "$SRV_PIDFILE"), log $SRV_LOG)"
|
||||
fi
|
||||
|
||||
# 3. (re)build the tmux session: one pane per user, tiled.
|
||||
#
|
||||
# The client is launched as each pane's OWN command (run by tmux via `sh -c`),
|
||||
# never with `send-keys`. send-keys raced the interactive shell's startup — on a
|
||||
# shell with heavy rc init (conda/pyenv/nvm) the keystrokes land mid-init and get
|
||||
# swallowed, leaving blank panes. Running it as the pane command sidesteps the
|
||||
# shell entirely; the trailing `read` keeps the pane open so an auth error (e.g.
|
||||
# name already taken) stays on screen instead of the pane vanishing.
|
||||
launch_cmd() { # $1 = username → command string for the pane's `sh -c`
|
||||
local theme_arg=""
|
||||
[[ -n "$THEME_PATH" ]] && theme_arg="$(printf -- '--theme %q' "$THEME_PATH")"
|
||||
printf '%q connect %q %q %q --password %q --no-tls %s; ec=$?; printf "\n%s left the house (exit %%s) — press enter to close\n" "$ec"; read _' \
|
||||
"$BIN" "$HOST" "$PORT" "$1" "$PW" "$theme_arg" "$1"
|
||||
}
|
||||
|
||||
# Guard: if we're attached to a tmux session with this exact name, the
|
||||
# kill-session below would tear down the session we're sitting in and drop us to
|
||||
# a bare shell ("closing" tmux). Refuse with a clear fix instead of yanking it.
|
||||
if [[ -n "${TMUX:-}" && "$(tmux display-message -p '#S' 2>/dev/null)" == "$SESSION" ]]; then
|
||||
echo "✖ you're inside the tmux session '$SESSION' — rebuilding it would close it." >&2
|
||||
echo " use a different name (e.g. SESSION=hh-test $0 ${USERS[*]}) or run --kill from another window." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null
|
||||
tmux new-session -d -s "$SESSION" -x 220 -y 50 -c "$HERE" "$(launch_cmd "${USERS[0]}")"
|
||||
for ((i = 1; i < ${#USERS[@]}; i++)); do
|
||||
tmux split-window -h -t "$SESSION" -c "$HERE" "$(launch_cmd "${USERS[i]}")"
|
||||
tmux select-layout -t "$SESSION" tiled >/dev/null
|
||||
done
|
||||
tmux select-layout -t "$SESSION" tiled >/dev/null
|
||||
|
||||
echo "clergy: ${USERS[*]} · session: $SESSION · $HOST:$PORT · vestments: ${THEME:-church (default)}"
|
||||
echo "room password: $PW (share with anyone joining; pin it with PW=... to reuse)"
|
||||
echo "tear down later with: $0 --kill"
|
||||
|
||||
# 4. land in the clergy. From a plain shell we replace this process with `attach`.
|
||||
# From inside tmux we can't nest an attach, so switch the current client to
|
||||
# the new session — that actually drops you into the clergy instead of leaving
|
||||
# it orphaned with a hint you have to act on yourself.
|
||||
if [[ -z "${TMUX:-}" ]]; then
|
||||
exec tmux attach -t "$SESSION"
|
||||
else
|
||||
echo "inside tmux — switching this client to '$SESSION' (detach with Ctrl-b d)"
|
||||
tmux switch-client -t "$SESSION"
|
||||
fi
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# smoke-e2e.sh — headless cross-stack smoke test (CI-runnable, no GUI/X needed).
|
||||
#
|
||||
# Boots the Python relay server + two real Rust TUI clients (alice = host/owner,
|
||||
# bob = guest) inside tmux panes, then asserts the things that actually exercise
|
||||
# the client<->server protocol:
|
||||
# 1. both clients complete the SRP handshake and appear in the roster
|
||||
# 2. a chat message from alice is relayed + decrypted into bob's pane
|
||||
# (proves Fernet round-trip through the zero-knowledge relay)
|
||||
# 3. `/sbx vms` dispatches in the client command parser
|
||||
#
|
||||
# Deterministic and self-contained: picks a free port, cleans up sessions + the
|
||||
# server on exit, and prints a clear PASS/FAIL. No asciinema, VirtualBox, or X.
|
||||
#
|
||||
# Env overrides: PY=<python> BIN=<client binary> PORT=<port> PW=<password>
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
# Python: prefer the repo venv locally, fall back to PATH (CI installs into the
|
||||
# job's own interpreter).
|
||||
if [[ -z "${PY:-}" ]]; then
|
||||
if [[ -x "$REPO/.venv/bin/python" ]]; then PY="$REPO/.venv/bin/python"; else PY="$(command -v python3 || command -v python)"; fi
|
||||
fi
|
||||
BIN="${BIN:-$REPO/hh/target/debug/hack-house}"
|
||||
PW="${PW:-smoke-pass}"
|
||||
pick_port() { local p; for p in $(seq 4300 4360); do ss -ltn 2>/dev/null | grep -q ":$p " || { echo "$p"; return; }; done; echo 4399; }
|
||||
PORT="${PORT:-$(pick_port)}"
|
||||
|
||||
SRV_SESS="hh-smoke-srv"
|
||||
RUN_SESS="hh-smoke"
|
||||
MARKER="HELLO_FROM_ALICE_$$"
|
||||
|
||||
GREEN=$'\e[32m'; RED=$'\e[31m'; YEL=$'\e[33m'; RST=$'\e[0m'
|
||||
step() { printf '\n%s== %s ==%s\n' "$YEL" "$*" "$RST"; }
|
||||
ok() { printf '%s ok %s%s\n' "$GREEN" "$*" "$RST"; }
|
||||
FAIL=0; fail() { printf '%s XX %s%s\n' "$RED" "$*" "$RST"; FAIL=1; }
|
||||
|
||||
cleanup() {
|
||||
tmux kill-session -t "$RUN_SESS" 2>/dev/null
|
||||
tmux kill-session -t "$SRV_SESS" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Pane-id-safe drivers (don't assume pane-base-index 0 — it may be 1).
|
||||
APANE=""; BPANE=""
|
||||
asay() { tmux send-keys -t "$APANE" -l "$*"; sleep 0.4; tmux send-keys -t "$APANE" Enter; sleep 0.6; }
|
||||
bsay() { tmux send-keys -t "$BPANE" -l "$*"; sleep 0.4; tmux send-keys -t "$BPANE" Enter; sleep 0.6; }
|
||||
acap() { tmux capture-pane -t "$APANE" -p 2>/dev/null; }
|
||||
bcap() { tmux capture-pane -t "$BPANE" -p 2>/dev/null; }
|
||||
await_a() { local re="$1" t="${2:-25}" i=0; while (( i < t*2 )); do acap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; }
|
||||
await_b() { local re="$1" t="${2:-25}" i=0; while (( i < t*2 )); do bcap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; }
|
||||
|
||||
# ---- preflight --------------------------------------------------------------
|
||||
step "preflight"
|
||||
command -v tmux >/dev/null || { echo "tmux required"; exit 2; }
|
||||
[[ -n "$PY" && -x "$(command -v "$PY" 2>/dev/null || echo "$PY")" ]] || { echo "python missing: $PY"; exit 2; }
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
step "building client"; ( cd "$REPO/hh" && cargo build ) || exit 2
|
||||
fi
|
||||
ok "python=$PY client=$BIN port=$PORT"
|
||||
|
||||
# ---- server (not asserted directly) -----------------------------------------
|
||||
step "boot relay server :$PORT"
|
||||
tmux new-session -d -s "$SRV_SESS" -x 200 -y 50 \
|
||||
"cd '$REPO' && '$PY' cmd_chat.py serve 127.0.0.1 $PORT --password '$PW' --no-tls 2>&1 | tee /tmp/hh-smoke-srv.log"
|
||||
for i in $(seq 1 30); do ss -ltn 2>/dev/null | grep -q ":$PORT " && break; sleep 0.5; done
|
||||
ss -ltn 2>/dev/null | grep -q ":$PORT " && ok "server listening" || { fail "server never bound"; exit 1; }
|
||||
|
||||
# ---- two clients in side-by-side panes ---------------------------------------
|
||||
step "launch alice (host) + bob (guest)"
|
||||
tmux new-session -d -s "$RUN_SESS" -x 200 -y 50 "bash --noprofile --norc"
|
||||
APANE="$(tmux list-panes -t "$RUN_SESS" -F '#{pane_id}' | head -1)"
|
||||
BPANE="$(tmux split-window -h -t "$APANE" -P -F '#{pane_id}' "bash --noprofile --norc")"
|
||||
sleep 0.5
|
||||
asay "$BIN connect 127.0.0.1 $PORT alice --password '$PW' --no-tls"
|
||||
await_a 'joined as alice|alice|roster|hack-house' 25 && ok "alice joined (SRP ok)" || fail "alice never joined"
|
||||
bsay "$BIN connect 127.0.0.1 $PORT bob --password '$PW' --no-tls"
|
||||
await_b 'joined as bob|bob|roster|hack-house' 25 && ok "bob joined (SRP ok)" || fail "bob never joined"
|
||||
|
||||
# ---- chat round-trip (the real protocol assertion) --------------------------
|
||||
step "chat relay + Fernet round-trip"
|
||||
sleep 1.0
|
||||
asay "$MARKER"
|
||||
await_b "$MARKER" 20 && ok "bob received alice's encrypted message" || fail "message never relayed to bob"
|
||||
|
||||
# ---- command parser dispatch ------------------------------------------------
|
||||
step "/sbx vms dispatches"
|
||||
asay "/sbx vms"
|
||||
# In CI VirtualBox won't be installed; either response mentions VirtualBox, which
|
||||
# proves the command parsed and ran (this is a local command, not a round-trip).
|
||||
await_a "VirtualBox" 15 && ok "/sbx vms handled" || fail "/sbx vms produced no response"
|
||||
|
||||
# ---- result -----------------------------------------------------------------
|
||||
step "result"
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
printf '%sSMOKE OK%s\n' "$GREEN" "$RST"
|
||||
else
|
||||
printf '%sSMOKE FAIL%s — server log: /tmp/hh-smoke-srv.log\n' "$RED" "$RST"
|
||||
fi
|
||||
exit $FAIL
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# hack-house smoke test
|
||||
# Exercises the full use-case path end to end against a live server:
|
||||
# rust unit tests → SRP self-test → boot server → rust client handshake +
|
||||
# round-trip → cross-language (python decrypts what the rust client sent).
|
||||
# Run from anywhere: hh/scripts/smoke.sh
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)" # .../hh
|
||||
ROOT="$(cd "$HERE/.." && pwd)" # repo root
|
||||
PY="$ROOT/.venv/bin/python"
|
||||
BIN="$HERE/target/debug/hack-house"
|
||||
PORT="${PORT:-4199}"
|
||||
PW="${PW:-labtest}"
|
||||
|
||||
fail() { echo "✖ SMOKE FAIL: $1"; exit 1; }
|
||||
|
||||
echo "── 1/5 rust unit tests (srp vectors + fernet interop) ──"
|
||||
( cd "$HERE" && cargo test --quiet ) || fail "cargo test"
|
||||
|
||||
echo "── 2/5 build + SRP self-test (Rust SRP ≡ Python srp) ──"
|
||||
( cd "$HERE" && cargo build --quiet ) || fail "cargo build"
|
||||
"$BIN" selftest | grep -q "selftest passed" || fail "selftest"
|
||||
|
||||
echo "── 3/5 boot server on :$PORT ──"
|
||||
"$PY" "$ROOT/cmd_chat.py" serve 127.0.0.1 "$PORT" --password "$PW" --no-tls \
|
||||
>/tmp/hh-smoke-srv.log 2>&1 &
|
||||
SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null' EXIT
|
||||
for _ in $(seq 1 20); do
|
||||
curl -s --max-time 2 "http://127.0.0.1:$PORT/health" 2>/dev/null | grep -q '"status":"ok"' && break
|
||||
sleep 1
|
||||
done
|
||||
curl -s "http://127.0.0.1:$PORT/health" 2>/dev/null | grep -q '"status":"ok"' || fail "server did not come up"
|
||||
|
||||
echo "── 4/5 rust client: SRP auth + encrypted round-trip ──"
|
||||
"$BIN" handshake 127.0.0.1 "$PORT" smoke-rust --password "$PW" --no-tls \
|
||||
| tee /tmp/hh-smoke-rust.log | grep -q "round-trip ✓" || fail "rust handshake/round-trip"
|
||||
|
||||
echo "── 5/5 cross-language: python decrypts the rust-sent message ──"
|
||||
"$PY" - "$PORT" "$PW" <<'PYEOF' || fail "python cross-language read"
|
||||
import sys, asyncio, base64, json
|
||||
import srp, requests, websockets
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
srp.rfc5054_enable()
|
||||
port, pw = sys.argv[1], sys.argv[2].encode()
|
||||
base, wsb = f"http://127.0.0.1:{port}", f"ws://127.0.0.1:{port}"
|
||||
|
||||
async def main():
|
||||
usr = srp.User(b"chat", pw, hash_alg=srp.SHA256)
|
||||
_, A = usr.start_authentication()
|
||||
r = requests.post(f"{base}/srp/init",
|
||||
json={"username": "smoke-py", "A": base64.b64encode(A).decode()}).json()
|
||||
uid = r["user_id"]
|
||||
B, salt, rs = (base64.b64decode(r[k]) for k in ("B", "salt", "room_salt"))
|
||||
M = usr.process_challenge(salt, B)
|
||||
v = requests.post(f"{base}/srp/verify",
|
||||
json={"user_id": uid, "username": "smoke-py",
|
||||
"M": base64.b64encode(M).decode()}).json()
|
||||
room = Fernet(base64.urlsafe_b64encode(
|
||||
HKDF(algorithm=hashes.SHA256(), length=32, salt=rs,
|
||||
info=b"cmd-chat-room-key").derive(pw)))
|
||||
async with websockets.connect(f"{wsb}/ws/chat?user_id={uid}&ws_token={v['ws_token']}") as ws:
|
||||
data = json.loads(await asyncio.wait_for(ws.recv(), 5))
|
||||
msgs = [room.decrypt(m["text"].encode()).decode()
|
||||
for m in data.get("messages", []) if m.get("text")]
|
||||
hits = [m for m in msgs if "house is open" in m]
|
||||
assert hits, f"rust-sent message not found/decryptable: {msgs!r}"
|
||||
print(" ✓ python decrypted rust-sent message:", hits)
|
||||
|
||||
asyncio.run(main())
|
||||
PYEOF
|
||||
|
||||
echo
|
||||
echo "✓ SMOKE PASS — crypto · SRP · fernet · cross-language relay all green"
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-features.sh — drive a headless hack-house clergy via `tmux send-keys` and
|
||||
# assert each feature by scraping the rendered TUI with `tmux capture-pane`.
|
||||
#
|
||||
# It boots a --no-tls server, opens an OWNER pane and a MEMBER (bob) pane in a
|
||||
# detached tmux session, fires deterministic keystrokes, and greps the captured
|
||||
# screen for the state markers the UI paints (DRIVING, scrollback, chat ↑, …).
|
||||
#
|
||||
# ./test-features.sh # run the suite, leave the session up to inspect
|
||||
# ./test-features.sh --kill # tear the session + server down
|
||||
# PORT=4199 PW=test-bless ./test-features.sh
|
||||
#
|
||||
# While it runs you can watch live in another terminal:
|
||||
# tmux attach -t hh-autotest
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)" # .../hh
|
||||
ROOT="$(cd "$HERE/.." && pwd)" # repo root
|
||||
PY="$ROOT/.venv/bin/python"
|
||||
BIN="$HERE/target/debug/hack-house"
|
||||
|
||||
SESSION="${SESSION:-hh-autotest}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
PORT="${PORT:-4199}"
|
||||
PW="${PW:-test-bless}"
|
||||
SRV_LOG="/tmp/hh-${SESSION}-server.log"
|
||||
SRV_PIDFILE="/tmp/hh-${SESSION}-server.pid"
|
||||
|
||||
is_up() { curl -s --max-time 2 "http://$HOST:$PORT/health" 2>/dev/null | grep -q '"status":"ok"'; }
|
||||
|
||||
stop_server() {
|
||||
if [[ -f "$SRV_PIDFILE" ]]; then
|
||||
kill "$(cat "$SRV_PIDFILE")" 2>/dev/null && echo "stopped server (pid $(cat "$SRV_PIDFILE"))"
|
||||
rm -f "$SRV_PIDFILE"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--kill" || "${1:-}" == "--teardown" ]]; then
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null && echo "killed tmux $SESSION" || echo "no tmux session"
|
||||
stop_server
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── result tracking ──────────────────────────────────────────────────────────
|
||||
PASS=0; FAIL=0
|
||||
declare -a RESULTS=()
|
||||
|
||||
cap() { tmux capture-pane -pt "$1" 2>/dev/null; }
|
||||
|
||||
# want <pane> <fixed-substring> <label>
|
||||
want() {
|
||||
local pane="$1" needle="$2" label="$3"
|
||||
if cap "$pane" | grep -qF -- "$needle"; then
|
||||
echo " ✓ PASS — $label"; PASS=$((PASS+1)); RESULTS+=("PASS $label")
|
||||
else
|
||||
echo " ✗ FAIL — $label (looked for: '$needle')"; FAIL=$((FAIL+1)); RESULTS+=("FAIL $label")
|
||||
fi
|
||||
}
|
||||
# wantnot <pane> <fixed-substring> <label>
|
||||
wantnot() {
|
||||
local pane="$1" needle="$2" label="$3"
|
||||
if cap "$pane" | grep -qF -- "$needle"; then
|
||||
echo " ✗ FAIL — $label (did NOT want: '$needle')"; FAIL=$((FAIL+1)); RESULTS+=("FAIL $label")
|
||||
else
|
||||
echo " ✓ PASS — $label"; PASS=$((PASS+1)); RESULTS+=("PASS $label")
|
||||
fi
|
||||
}
|
||||
|
||||
# input helpers (type into the TUI input box, then Enter)
|
||||
o() { tmux send-keys -t "$OWNER" -- "$*"; tmux send-keys -t "$OWNER" Enter; sleep "${TYPE_PAUSE:-0.5}"; }
|
||||
m() { tmux send-keys -t "$MEMBER" -- "$*"; tmux send-keys -t "$MEMBER" Enter; sleep "${TYPE_PAUSE:-0.5}"; }
|
||||
key() { tmux send-keys -t "$1" "$2"; sleep "${KEY_PAUSE:-0.7}"; } # named key (PageUp, F2, …)
|
||||
raw() { tmux send-keys -t "$1" -l "$2"; sleep "${KEY_PAUSE:-0.7}"; } # literal bytes (mouse SGR)
|
||||
|
||||
# ─── 1. build ───────────────────────────────────────────────────────────────
|
||||
echo "building client…"
|
||||
( cd "$HERE" && cargo build --quiet ) || { echo "✖ build failed"; exit 1; }
|
||||
[[ -x "$PY" ]] || { echo "✖ no python at $PY"; exit 1; }
|
||||
|
||||
# ─── 2. server ───────────────────────────────────────────────────────────────
|
||||
if is_up; then
|
||||
echo "reusing server on $HOST:$PORT"
|
||||
else
|
||||
echo "booting server on $HOST:$PORT…"
|
||||
"$PY" "$ROOT/cmd_chat.py" serve "$HOST" "$PORT" --password "$PW" --no-tls >"$SRV_LOG" 2>&1 &
|
||||
echo $! > "$SRV_PIDFILE"
|
||||
for _ in $(seq 1 20); do is_up && break; sleep 1; done
|
||||
is_up || { echo "✖ server did not come up — see $SRV_LOG"; exit 1; }
|
||||
echo "server up (pid $(cat "$SRV_PIDFILE"))"
|
||||
fi
|
||||
|
||||
# ─── 3. tmux session: OWNER | bob ─────────────────────────────────────────────
|
||||
CONNECT="$BIN connect $HOST $PORT"
|
||||
FLAGS="--password $PW --no-tls"
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
||||
# Big window so the TUI never clips; owner connects first → becomes room owner.
|
||||
tmux new-session -d -s "$SESSION" -x 200 -y 50 -c "$HERE" "$CONNECT owner $FLAGS; read _"
|
||||
sleep 2
|
||||
tmux split-window -h -t "$SESSION" -c "$HERE" "$CONNECT bob $FLAGS; read _"
|
||||
sleep 2
|
||||
tmux select-layout -t "$SESSION" tiled >/dev/null
|
||||
tmux set -t "$SESSION" pane-border-status top >/dev/null
|
||||
tmux set -t "$SESSION" pane-border-format " #{pane_index} " >/dev/null
|
||||
|
||||
mapfile -t PANES < <(tmux list-panes -t "$SESSION" -F '#{pane_id}')
|
||||
OWNER="${PANES[0]}"; MEMBER="${PANES[1]}"
|
||||
echo "panes: OWNER=$OWNER MEMBER=$MEMBER (attach: tmux attach -t $SESSION)"
|
||||
sleep 2
|
||||
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " RUNNING FEATURE SUITE"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
|
||||
# ─── T1. connect + roster ─────────────────────────────────────────────────────
|
||||
echo "[T1] connect + e2e + roster"
|
||||
want "$OWNER" "hack-house" "owner UI is up"
|
||||
want "$OWNER" "e2e" "owner shows e2e-encrypted status"
|
||||
want "$OWNER" "house 2/" "roster shows 2 members"
|
||||
|
||||
# ─── T2. chat round-trip (owner → bob) ────────────────────────────────────────
|
||||
echo "[T2] chat message round-trip"
|
||||
o "MARKER_CHAT_OWNER_42"
|
||||
sleep 0.6
|
||||
want "$MEMBER" "MARKER_CHAT_OWNER_42" "bob received owner's chat message"
|
||||
m "MARKER_CHAT_BOB_99"
|
||||
sleep 0.6
|
||||
want "$OWNER" "MARKER_CHAT_BOB_99" "owner received bob's chat message"
|
||||
|
||||
# a few more lines so there's chat history to scroll
|
||||
for i in 1 2 3 4 5; do o "chat-history-line-$i"; done
|
||||
|
||||
# ─── T3. chat scrollback (PgUp/PgDn/Home/End) ─────────────────────────────────
|
||||
echo "[T3] chat scrollback via PgUp/PgDn"
|
||||
key "$OWNER" PageUp
|
||||
want "$OWNER" "End=live" "PgUp scrolled chat (shows 'End=live' hint)"
|
||||
wantnot "$OWNER" "scrollback" "PgUp did NOT touch sandbox (no sandbox yet)"
|
||||
key "$OWNER" End
|
||||
wantnot "$OWNER" "End=live" "End returned chat to live"
|
||||
|
||||
# ─── T4. help overlay (F1 open / any-key close) ───────────────────────────────
|
||||
echo "[T4] help overlay F1"
|
||||
key "$OWNER" F1
|
||||
want "$OWNER" "hack-house — help" "F1 opened the help overlay"
|
||||
key "$OWNER" Escape
|
||||
wantnot "$OWNER" "hack-house — help" "a keypress closed the help overlay"
|
||||
|
||||
# ─── T5. live theme switch ────────────────────────────────────────────────────
|
||||
echo "[T5] /theme switch stays alive"
|
||||
o "/theme neon"
|
||||
o "/theme church"
|
||||
want "$OWNER" "hack-house" "UI still alive after theme swap"
|
||||
|
||||
# ─── T6. sandbox launch (local backend = no docker) ───────────────────────────
|
||||
echo "[T6] /sbx launch local (allowing time to summon)"
|
||||
o "/sbx launch local"
|
||||
echo " …waiting for the sandbox to summon"
|
||||
sleep 5
|
||||
want "$OWNER" "local-shell" "owner shows the sandbox pane (local-shell)"
|
||||
|
||||
# ─── T7. drive the shell (F2) + run a command ─────────────────────────────────
|
||||
echo "[T7] drive shell + command output"
|
||||
key "$OWNER" F2
|
||||
want "$OWNER" "DRIVING" "F2 entered DRIVING mode"
|
||||
o "echo HELLOSBX_OK_7"
|
||||
sleep 1
|
||||
want "$OWNER" "HELLOSBX_OK_7" "command output rendered in the sandbox terminal"
|
||||
o "seq 1 60"
|
||||
sleep 1
|
||||
|
||||
# ─── T8. NEW: PgUp scrolls sandbox scrollback WHILE DRIVING ────────────────────
|
||||
echo "[T8] PgUp scrolls sandbox scrollback while driving (the new wiring)"
|
||||
key "$OWNER" PageUp
|
||||
key "$OWNER" PageUp
|
||||
want "$OWNER" "DRIVING" "still driving after PgUp (drive not released)"
|
||||
want "$OWNER" "scrollback" "PgUp scrolled the sandbox scrollback while driving"
|
||||
key "$OWNER" PageDown
|
||||
key "$OWNER" PageDown
|
||||
key "$OWNER" PageDown
|
||||
|
||||
# ─── T9. release drive (Esc) ──────────────────────────────────────────────────
|
||||
echo "[T9] Esc releases the drive"
|
||||
key "$OWNER" Escape
|
||||
wantnot "$OWNER" "DRIVING" "Esc released DRIVING mode"
|
||||
|
||||
# ─── T10. arrow keys scroll sandbox when NOT driving ──────────────────────────
|
||||
echo "[T10] Up arrow scrolls sandbox (not driving)"
|
||||
key "$OWNER" Up
|
||||
key "$OWNER" Up
|
||||
want "$OWNER" "scrollback" "Up arrow scrolled the sandbox scrollback"
|
||||
key "$OWNER" End
|
||||
|
||||
# ─── T11. PgUp scrolls CHAT (not sandbox) when not driving ────────────────────
|
||||
echo "[T11] PgUp scrolls chat (not sandbox) when not driving"
|
||||
key "$OWNER" PageUp
|
||||
want "$OWNER" "End=live" "PgUp scrolled chat while a sandbox is up"
|
||||
wantnot "$OWNER" "scrollback" "PgUp left the sandbox at live (no 'scrollback')"
|
||||
key "$OWNER" End
|
||||
|
||||
# ─── T12. mouse wheel (best-effort: raw SGR sequence) ─────────────────────────
|
||||
echo "[T12] mouse wheel scroll (best-effort SGR injection)"
|
||||
raw "$OWNER" $'\033[<64;20;20M' # SGR wheel-up at (20,20)
|
||||
raw "$OWNER" $'\033[<64;20;20M'
|
||||
if cap "$OWNER" | grep -qF "scrollback"; then
|
||||
echo " ✓ PASS — wheel-up scrolled the sandbox (SGR mouse recognised)"; PASS=$((PASS+1)); RESULTS+=("PASS mouse wheel scroll")
|
||||
else
|
||||
echo " ⚠ INFO — wheel-up did not register via send-keys (mouse wheel needs a real terminal; verify by hand)"; RESULTS+=("INFO mouse wheel scroll (manual)")
|
||||
fi
|
||||
key "$OWNER" End
|
||||
|
||||
# ─── T13. grant a member drive permission ─────────────────────────────────────
|
||||
echo "[T13] /grant lets bob drive"
|
||||
o "/grant bob"
|
||||
sleep 0.6
|
||||
key "$MEMBER" F2
|
||||
want "$MEMBER" "DRIVING" "bob can drive after /grant"
|
||||
key "$MEMBER" Escape
|
||||
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " RESULTS"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
for r in "${RESULTS[@]}"; do echo " $r"; done
|
||||
echo "----------------------------------------------------------------"
|
||||
echo " PASS=$PASS FAIL=$FAIL"
|
||||
echo
|
||||
echo "session left up for inspection: tmux attach -t $SESSION"
|
||||
echo "tear down with: $0 --kill"
|
||||
|
||||
exit $(( FAIL > 0 ? 1 : 0 ))
|
||||
Reference in New Issue
Block a user