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

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:
leetcrypt
2026-06-06 17:49:32 -07:00
parent 9f7ab92270
commit 1fa8c332ed
21 changed files with 57 additions and 57 deletions
+134
View File
@@ -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"
+105
View File
@@ -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
+71
View File
@@ -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[@]}"
+240
View File
@@ -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
+82
View File
@@ -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
+131
View File
@@ -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
+245
View File
@@ -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
+297
View File
@@ -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
+136
View File
@@ -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[@]}"
+31
View File
@@ -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
+241
View File
@@ -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
+100
View File
@@ -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
+77
View File
@@ -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"
+230
View File
@@ -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 ))