feat(hh): /pw command, RAM-only direnv autostart, robust lets-hack; coven→clergy

- add /pw (alias /password): reveal this room's password locally (never
  broadcast); surfaced in the F1 help overlay and the join hint
- direnv-autostart/: cd-to-launch a single real-user session via direnv;
  password is minted in memory at launch (never written to disk, matching the
  RAM-only model) and scoped to the child process. setup.sh installs direnv,
  hooks bash/zsh, and `direnv allow`s the dir
- lets-hack.sh: boot a FRESH server by default (replacing any live one) with a
  --reuse opt-out; add -h/--help/-help; guard against killing the tmux session
  you're attached to; switch-client into the coven when run inside tmux
- rename coven→clergy across rust/python/scripts; tests/test_coven.py→test_clergy.py
- snapshots in-progress hack-house client work (sandbox, themes, net, ui)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-05-31 22:29:17 -07:00
parent 8e6365a649
commit 5de493e895
21 changed files with 1058 additions and 91 deletions

View File

@ -24,7 +24,7 @@ def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic:
app.ctx.ws_secret = os.urandom(32) app.ctx.ws_secret = os.urandom(32)
app.ctx.admin_token = secrets.token_hex(16) app.ctx.admin_token = secrets.token_hex(16)
app.ctx.rate_limiter = RateLimiter(max_requests=10, window_seconds=60) app.ctx.rate_limiter = RateLimiter(max_requests=10, window_seconds=60)
# Coven capacity. 4 by default; raise via CMD_CHAT_MAX_USERS — infra-for-more, # Clergy capacity. 4 by default; raise via CMD_CHAT_MAX_USERS — infra-for-more,
# the cap is data not architecture (broadcast fan-out is O(N)). # the cap is data not architecture (broadcast fan-out is O(N)).
app.ctx.max_users = int(os.environ.get("CMD_CHAT_MAX_USERS", "4")) app.ctx.max_users = int(os.environ.get("CMD_CHAT_MAX_USERS", "4"))
app.ctx.cleanup_task = None app.ctx.cleanup_task = None

View File

@ -16,7 +16,7 @@ def generate_ws_token(user_id: str, secret: bytes) -> str:
def _roster_frame(app: Sanic) -> str: def _roster_frame(app: Sanic) -> str:
"""Authoritative presence snapshot — all coven members converge on this.""" """Authoritative presence snapshot — all clergy members converge on this."""
users = app.ctx.session_store.get_all() users = app.ctx.session_store.get_all()
return json.dumps( return json.dumps(
{ {
@ -46,7 +46,7 @@ async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
return response.json({"error": "Username taken"}, status=409) return response.json({"error": "Username taken"}, status=409)
if app.ctx.session_store.count() >= app.ctx.max_users: if app.ctx.session_store.count() >= app.ctx.max_users:
return response.json({"error": "Coven full"}, status=409) return response.json({"error": "Clergy full"}, status=409)
user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public) user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public)
@ -82,7 +82,7 @@ async def srp_verify(request: Request, app: Sanic) -> HTTPResponse:
# Authoritative capacity gate — the slot is only consumed once a session # Authoritative capacity gate — the slot is only consumed once a session
# is actually added here (init is best-effort / racy). # is actually added here (init is best-effort / racy).
if app.ctx.session_store.count() >= app.ctx.max_users: if app.ctx.session_store.count() >= app.ctx.max_users:
return response.json({"error": "Coven full"}, status=409) return response.json({"error": "Clergy full"}, status=409)
H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof) H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof)
@ -173,7 +173,7 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
pass pass
finally: finally:
await manager.disconnect(user_id) await manager.disconnect(user_id)
# Free the slot + username so the coven can be rejoined (was previously # Free the slot + username so the clergy can be rejoined (was previously
# held until the 1h stale sweep, which also blocked the name). # held until the 1h stale sweep, which also blocked the name).
app.ctx.session_store.remove(user_id) app.ctx.session_store.remove(user_id)
await manager.broadcast( await manager.broadcast(

View File

@ -37,7 +37,7 @@ tar = "0.4"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3" futures-util = "0.3"
ratatui = { version = "0.29", features = ["serde"] } ratatui = { version = "0.29", features = ["serde", "unstable-rendered-line-info"] }
crossterm = { version = "0.28", features = ["event-stream"] } crossterm = { version = "0.28", features = ["event-stream"] }
toml = "0.8" toml = "0.8"

View File

@ -0,0 +1,35 @@
# .envrc — cd into this directory to summon your hack-house ⛧
#
# Powered by direnv (https://direnv.net). Run ./setup.sh once to install direnv,
# hook your shell, and `direnv allow` this file.
#
# Paths resolve RELATIVE TO THIS FILE — no hardcoded paths, so it works wherever
# the repo is cloned (your own $universal/path/hackerhouse).
# hh/ lives one level up from this autostart directory.
export HH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$PWD/.envrc}")/.." && pwd)"
# A single session, joined as the ACTUAL logged-in user (not the alice/bob demo).
export SESSION="${HH_SESSION:-hackerhouse}"
HH_USER="${HH_USER:-${USER:-$(id -un)}}"
# Mint a strong room password — in MEMORY ONLY, at launch time. Nothing is ever
# written to disk (matches the project's RAM-only secret model) and it is not
# left in your shell environment: it is scoped to the lets-hack child process,
# which boots the server and your client together so they share it. Reveal or
# share it in-app with /pw. Honors a PW you export yourself.
gen_pw() {
if command -v openssl >/dev/null 2>&1; then openssl rand -base64 24
else head -c 18 /dev/urandom | base64; fi
}
# Autostart needs tmux. lets-hack.sh boots a fresh server and a single pane for
# $HH_USER, then (inside tmux) switches your client into it. Guard against
# stacking: if the session is already live, just point at it.
if ! command -v tmux >/dev/null 2>&1; then
echo "⛧ install tmux to autostart your hack-house"
elif tmux has-session -t "$SESSION" 2>/dev/null; then
echo "⛧ '$SESSION' already live — tmux attach -t $SESSION (reveal its password in-app with /pw)"
else
PW="${PW:-$(gen_pw)}" "$HH_DIR/lets-hack.sh" "$HH_USER"
fi

69
hh/direnv-autostart/setup.sh Executable file
View File

@ -0,0 +1,69 @@
#!/usr/bin/env bash
# setup.sh — one-time setup for the hack-house direnv autostart ⛧
#
# Installs direnv (if missing), hooks your shell, and allows the .envrc in this
# directory so that simply `cd`-ing here launches your hack-house session.
#
# usage:
# ./setup.sh # set up autostart for THIS directory
# ./setup.sh --help
#
# After it finishes: open a new shell (or `source ~/.bashrc`), then `cd` into
# this directory — your single-user clergy boots automatically.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)" # the autostart dir (holds .envrc)
case "${1:-}" in
-h|--help|-help)
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
"") : ;;
*) echo "✖ unknown argument: $1 (try --help)" >&2; exit 2 ;;
esac
# 1. ensure direnv is installed
if command -v direnv >/dev/null 2>&1; then
echo "⛧ direnv already installed ($(direnv version 2>/dev/null))"
elif command -v apt-get >/dev/null 2>&1; then
echo "⛧ installing direnv via apt…"
sudo apt-get update -qq && sudo apt-get install -y direnv
elif command -v brew >/dev/null 2>&1; then
echo "⛧ installing direnv via brew…"
brew install direnv
else
echo "⛧ installing direnv via the official script (→ ~/.local/bin)…"
mkdir -p "$HOME/.local/bin"
export bin_path="$HOME/.local/bin"
curl -sfL https://direnv.net/install.sh | bash
fi
command -v direnv >/dev/null 2>&1 || { echo "✖ direnv install failed — see https://direnv.net/docs/installation.html" >&2; exit 1; }
# 2. hook direnv into the shell rc (idempotent). Covers bash and zsh.
hook_line_bash='eval "$(direnv hook bash)"'
hook_line_zsh='eval "$(direnv hook zsh)"'
add_hook() { # $1 = rc file, $2 = hook line
local rc="$1" line="$2"
[[ -f "$rc" ]] || return 0
if grep -qF 'direnv hook' "$rc"; then
echo "⛧ shell hook already present in $rc"
else
printf '\n# direnv (hack-house autostart)\n%s\n' "$line" >> "$rc"
echo "⛧ added direnv hook to $rc"
fi
}
add_hook "$HOME/.bashrc" "$hook_line_bash"
[[ -n "${ZDOTDIR:-}" && -f "$ZDOTDIR/.zshrc" ]] && add_hook "$ZDOTDIR/.zshrc" "$hook_line_zsh"
[[ -f "$HOME/.zshrc" ]] && add_hook "$HOME/.zshrc" "$hook_line_zsh"
# 3. allow this directory's .envrc
if [[ ! -f "$HERE/.envrc" ]]; then
echo "✖ no .envrc in $HERE — is this the autostart directory?" >&2
exit 1
fi
direnv allow "$HERE"
echo "⛧ allowed $HERE/.envrc"
echo
echo "⛧ done. open a new shell (or: source ~/.bashrc), then: cd $HERE"
echo " your single-user hack-house boots automatically (fresh server, generated password)."

View File

@ -1 +1 @@
secret offering to the coven — marker XYZ123 secret offering to the clergy — marker XYZ123

82
hh/ensure-docker.sh Executable file
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

209
hh/lets-hack.sh Executable file
View File

@ -0,0 +1,209 @@
#!/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: malware-bless)
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"
BIN="$HERE/target/debug/hack-house"
SESSION="${SESSION:-hh-test}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-4173}"
PW="${PW:-malware-bless}"
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
exit 0
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 "⛧ 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

View File

@ -8,7 +8,10 @@ use crate::ui;
use anyhow::Result; use anyhow::Result;
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use base64::Engine; use base64::Engine;
use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
KeyModifiers, MouseEventKind,
};
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{ use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@ -67,7 +70,7 @@ pub enum Net {
SbxInput { from: String, bytes: Vec<u8> }, SbxInput { from: String, bytes: Vec<u8> },
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> }, Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
Ft(ft::Ft), Ft(ft::Ft),
Sys(String), Err(String),
Closed, Closed,
} }
@ -97,6 +100,12 @@ pub struct App {
pub sbx_scroll: usize, pub sbx_scroll: usize,
/// Whether the help overlay is showing. /// Whether the help overlay is showing.
pub show_help: bool, pub show_help: bool,
/// A reconnect handshake is in flight (Ctrl-R after a disconnect).
pub reconnecting: bool,
/// Transient error shown as a popup over the clergy (cleared on next keypress).
pub error: Option<String>,
/// The room password this client authenticated with (shown by `/pw`).
pub password: String,
} }
impl App { impl App {
@ -118,6 +127,9 @@ impl App {
chat_scroll: 0, chat_scroll: 0,
sbx_scroll: 0, sbx_scroll: 0,
show_help: false, show_help: false,
reconnecting: false,
error: None,
password: String::new(),
} }
} }
@ -152,6 +164,15 @@ impl App {
}); });
} }
/// Surface an error: kept in chat scrollback for history AND shown as a
/// popup over the clergy so it can't bleed onto / be overwritten at the
/// input box. Dismissed by the next keypress.
fn err(&mut self, text: impl Into<String>) {
let t = text.into();
self.sys(format!("{t}"));
self.error = Some(t);
}
fn apply(&mut self, n: Net) { fn apply(&mut self, n: Net) {
match n { match n {
Net::Init { lines, users } => { Net::Init { lines, users } => {
@ -160,7 +181,7 @@ impl App {
self.connected = true; self.connected = true;
self.chat_scroll = 0; self.chat_scroll = 0;
self.sys(format!("joined as {}", self.me)); self.sys(format!("joined as {}", self.me));
self.sys("/sbx launch · /drive (Esc releases) · /send <file> · PgUp/PgDn scroll chat · ctrl-q quit"); self.sys("/sbx launch · /drive (Esc releases) · /send <file> · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit");
} }
Net::Message(l) => self.push_line(l), Net::Message(l) => self.push_line(l),
Net::Roster { users, capacity } => { Net::Roster { users, capacity } => {
@ -222,10 +243,10 @@ impl App {
self.sudoers = sudo; self.sudoers = sudo;
} }
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk) Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
Net::Sys(t) => self.sys(t), Net::Err(t) => self.err(t),
Net::Closed => { Net::Closed => {
self.connected = false; self.connected = false;
self.sys("connection closed"); self.sys("connection closed — press Ctrl-R to reconnect");
} }
} }
} }
@ -237,6 +258,16 @@ fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
(sbx_h.saturating_sub(2).max(1), term_w.saturating_sub(2).max(1)) (sbx_h.saturating_sub(2).max(1), term_w.saturating_sub(2).max(1))
} }
/// One page of sandbox scrollback = the visible grid height (defaults to 10 if
/// no sandbox is up). The run loop clamps `sbx_scroll` to the grid height anyway.
fn sbx_page(app: &App) -> usize {
app.sandbox
.as_ref()
.map(|v| v.parser.screen().size().0 as usize)
.unwrap_or(10)
.max(1)
}
fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> { fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
match code { match code {
KeyCode::Char(c) => { KeyCode::Char(c) => {
@ -271,7 +302,7 @@ fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App)
})); }));
} }
/// Stream a payload to the coven as `_ft` chunks (background, paced). /// Stream a payload to the clergy as `_ft` chunks (background, paced).
fn spawn_send(id: String, payload: Arc<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) { fn spawn_send(id: String, payload: Arc<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) {
tokio::spawn(async move { tokio::spawn(async move {
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() { for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
@ -332,11 +363,11 @@ fn handle_ft(
if let Some(t) = app.transfers.remove(&id) { if let Some(t) = app.transfers.remove(&id) {
if t.accepted { if t.accepted {
if ft::sha256_hex(&t.buf) != t.meta.sha256 { if ft::sha256_hex(&t.buf) != t.meta.sha256 {
app.sys(format!("{} — SHA-256 mismatch, discarded", t.meta.name)); app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
} else { } else {
match ft::save(downloads, &t.meta, &t.buf) { match ft::save(downloads, &t.meta, &t.buf) {
Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))), Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))),
Err(e) => app.sys(format!("save failed: {e}")), Err(e) => app.err(format!("save failed: {e}")),
} }
} }
} }
@ -348,12 +379,12 @@ fn handle_ft(
} }
} }
pub async fn run(session: Session, theme: Theme) -> Result<()> { pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> {
let ws = net::connect(&session).await?;
let (mut write, read) = ws.split();
let (tx, mut rx) = unbounded_channel::<Net>(); let (tx, mut rx) = unbounded_channel::<Net>();
let app_tx = tx.clone(); let app_tx = tx.clone();
tokio::spawn(net::reader(read, session.room.clone(), tx)); let mut write = net::open(&session, tx.clone()).await?;
// Carries the result of a background reconnect handshake back to the loop.
let (recon_tx, mut recon_rx) = unbounded_channel::<std::result::Result<Session, String>>();
// All outgoing frames funnel through here so background tasks (file chunks, // All outgoing frames funnel through here so background tasks (file chunks,
// PTY relay) can transmit without owning the socket. // PTY relay) can transmit without owning the socket.
@ -370,18 +401,25 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = std::io::stdout(); let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let mut term = Terminal::new(CrosstermBackend::new(stdout))?; let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
let mut app = App::new(session.username.clone()); let mut app = App::new(session.username.clone());
app.password = params.password.clone();
let mut events = EventStream::new(); let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(50)); let mut tick = tokio::time::interval(Duration::from_millis(50));
let result = loop { let result = loop {
// Apply the sandbox scrollback offset (0 = follow live). // Apply the sandbox scrollback offset (0 = follow live).
let sbs = app.sbx_scroll; //
// vt100 0.15.2 panics ("subtract with overflow" in grid::visible_rows)
// if the scrollback offset ever exceeds the visible grid height: it does
// `rows_len - offset` on usize without clamping. Cap our offset to the
// grid height so a fast scroll can never cross that line and crash us.
if let Some(v) = &mut app.sandbox { if let Some(v) = &mut app.sandbox {
v.parser.set_scrollback(sbs); let rows = v.parser.screen().size().0 as usize;
app.sbx_scroll = app.sbx_scroll.min(rows);
v.parser.set_scrollback(app.sbx_scroll);
} }
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) { if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
break Err(e.into()); break Err(e.into());
@ -405,10 +443,30 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
maybe = events.next() => { maybe = events.next() => {
match maybe { match maybe {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
app.error = None; // any keypress dismisses the error popup
if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) { if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) {
break Ok(()); break Ok(());
} }
if app.show_help { if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('r'))
&& !app.connected
{
// Reconnect: re-run the SRP handshake off-thread so the UI
// stays responsive, then re-attach the websocket on success.
if !app.reconnecting {
app.reconnecting = true;
app.sys("⛧ reconnecting…");
let p = params.clone();
let rtx = recon_tx.clone();
tokio::task::spawn_blocking(move || {
let r = net::authenticate(
&p.ip, p.port, &p.user, &p.password, p.no_tls, p.insecure,
)
.map_err(|e| e.to_string());
let _ = rtx.send(r);
});
}
} else if app.show_help {
app.show_help = false; // any key dismisses the overlay app.show_help = false; // any key dismisses the overlay
} else if k.code == KeyCode::F(1) { } else if k.code == KeyCode::F(1) {
app.show_help = true; // F1 from any mode app.show_help = true; // F1 from any mode
@ -422,6 +480,12 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
} else if app.driving { } else if app.driving {
if k.code == KeyCode::Esc { if k.code == KeyCode::Esc {
app.driving = false; app.driving = false;
} else if k.code == KeyCode::PageUp {
// Scroll the shared shell's scrollback without releasing the
// drive: PgUp/PgDn aren't forwarded to the PTY anyway.
app.sbx_scroll = (app.sbx_scroll + sbx_page(&app)).min(2000);
} else if k.code == KeyCode::PageDown {
app.sbx_scroll = app.sbx_scroll.saturating_sub(sbx_page(&app));
} else if let Some(bytes) = key_to_pty(k.code, k.modifiers) { } else if let Some(bytes) = key_to_pty(k.code, k.modifiers) {
if let Some(sb) = &mut broker { if let Some(sb) = &mut broker {
// I own the sandbox: write straight to the PTY — instant, // I own the sandbox: write straight to the PTY — instant,
@ -437,7 +501,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
let line = app.input.trim().to_string(); let line = app.input.trim().to_string();
app.input.clear(); app.input.clear();
app.chat_scroll = 0; // jump back to live on send app.chat_scroll = 0; // jump back to live on send
handle_command(&line, &mut app, &mut active_send, &mut send_seq, handle_command(&line, &mut app, &mut theme, &mut active_send, &mut send_seq,
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims, &mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term); &out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term);
} }
@ -476,6 +540,27 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
} }
} }
} }
Some(Ok(Event::Mouse(m))) => {
// Mouse wheel scrolls the sandbox terminal if one is up
// (incl. while driving), otherwise the chat — mirrors ↑/↓.
match m.kind {
MouseEventKind::ScrollUp => {
if app.sandbox.is_some() {
app.sbx_scroll = (app.sbx_scroll + 3).min(2000);
} else {
app.chat_scroll = (app.chat_scroll + 3).min(app.lines.len().saturating_sub(1));
}
}
MouseEventKind::ScrollDown => {
if app.sandbox.is_some() {
app.sbx_scroll = app.sbx_scroll.saturating_sub(3);
} else {
app.chat_scroll = app.chat_scroll.saturating_sub(3);
}
}
_ => {}
}
}
Some(Err(e)) => break Err(e.into()), Some(Err(e)) => break Err(e.into()),
_ => {} _ => {}
} }
@ -529,6 +614,36 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
None => {} None => {}
} }
} }
recon = recon_rx.recv() => {
if let Some(result) = recon {
app.reconnecting = false;
match result {
Ok(s) => {
session = s;
match net::open(&session, tx.clone()).await {
Ok(w) => {
write = w;
app.sys("⛧ websocket re-attached — syncing…");
// If we host the sandbox, re-announce it so the
// rest of the house re-syncs the shared shell.
if let Some((be, _)) = &broker_meta {
if let Some(v) = &app.sandbox {
let (rows, cols) = v.parser.screen().size();
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready",
"backend": be.label(),"rows": rows,"cols": cols
}));
broadcast_acl(&out_tx, &session.room, &app);
}
}
}
Err(e) => app.err(format!("reconnect failed: {e}")),
}
}
Err(e) => app.sys(format!("reconnect failed: {e}")),
}
}
}
pty = pty_rx.recv() => { pty = pty_rx.recv() => {
if let Some(mut bytes) = pty { if let Some(mut bytes) = pty {
// Coalesce a burst (e.g. `tree`) into one frame: fewer round-trips, // Coalesce a burst (e.g. `tree`) into one frame: fewer round-trips,
@ -571,7 +686,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
} }
} }
disable_raw_mode()?; disable_raw_mode()?;
execute!(term.backend_mut(), LeaveAlternateScreen)?; execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
term.show_cursor()?; term.show_cursor()?;
result result
} }
@ -585,6 +700,7 @@ enum BrokerMsg {
fn handle_command( fn handle_command(
line: &str, line: &str,
app: &mut App, app: &mut App,
theme: &mut Theme,
active_send: &mut Option<ActiveSend>, active_send: &mut Option<ActiveSend>,
send_seq: &mut u64, send_seq: &mut u64,
broker: &mut Option<sbx::Sandbox>, broker: &mut Option<sbx::Sandbox>,
@ -601,6 +717,32 @@ fn handle_command(
let room = &session.room; let room = &session.room;
if line == "/help" || line == "/?" { if line == "/help" || line == "/?" {
app.show_help = true; app.show_help = true;
} else if line == "/pw" || line == "/password" {
// Show the room password locally (never broadcast). Handy when the
// server's password was autogenerated and you need to read it off / share
// it out-of-band to invite someone into the room.
if app.password.is_empty() {
app.sys("⛧ no room password (joined without one)");
} else {
app.sys(format!("⛧ room password: {}", app.password));
}
} else if let Some(rest) = line.strip_prefix("/theme") {
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
let name = rest.trim();
if name.is_empty() {
app.sys(format!("vestments: {} — /theme <name>", Theme::available().join(" · ")));
} else {
match Theme::by_name(name) {
Ok(t) => {
*theme = t;
app.sys(format!("donned the {name} vestments"));
}
Err(_) => app.err(format!(
"no theme '{name}' — try: {}",
Theme::available().join(" · ")
)),
}
}
} else if line == "/drive" { } else if line == "/drive" {
// Mobile-friendly alternative to F2 (no function key needed). // Mobile-friendly alternative to F2 (no function key needed).
if app.sandbox.is_none() { if app.sandbox.is_none() {
@ -624,7 +766,7 @@ fn handle_command(
})); }));
app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size))); app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size)));
} }
Err(e) => app.sys(format!("send failed: {e}")), Err(e) => app.err(format!("send failed: {e}")),
} }
} else if line == "/accept" { } else if line == "/accept" {
if let Some(o) = app.pending_offer.take() { if let Some(o) = app.pending_offer.take() {
@ -651,17 +793,27 @@ fn handle_command(
if app.sandbox.is_some() || broker.is_some() || *launching { if app.sandbox.is_some() || broker.is_some() || *launching {
app.sys("a sandbox is already running"); app.sys("a sandbox is already running");
} else { } else {
let backend = p.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local); // `--start` (alias `--start-daemon` / `-y`) opts in to booting
let image = p.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string()); // a stopped Docker daemon; everything else is positional.
let args: Vec<&str> = p.collect();
let start_daemon = args.iter().any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
let backend = pos.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() {
app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./ensure-docker.sh in a terminal first");
} else {
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24)); let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1); let (rows, cols) = sbx_dims(sz.0, sz.1);
*launching = true; *launching = true;
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect(); let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect();
app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label())); app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label()));
spawn_launch(backend, image, app.me.clone(), members, rows, cols, spawn_launch(backend, image, app.me.clone(), members, rows, cols, start_daemon,
pty_tx.clone(), broker_tx.clone(), app_tx.clone()); pty_tx.clone(), broker_tx.clone(), app_tx.clone());
} }
} }
}
Some("stop") => { Some("stop") => {
if let Some(mut sb) = broker.take() { if let Some(mut sb) = broker.take() {
sb.stop(); sb.stop();
@ -743,6 +895,7 @@ fn spawn_launch(
members: Vec<String>, members: Vec<String>,
rows: u16, rows: u16,
cols: u16, cols: u16,
start_daemon: bool,
pty_tx: UnboundedSender<Vec<u8>>, pty_tx: UnboundedSender<Vec<u8>>,
broker_tx: UnboundedSender<BrokerMsg>, broker_tx: UnboundedSender<BrokerMsg>,
app_tx: UnboundedSender<Net>, app_tx: UnboundedSender<Net>,
@ -751,10 +904,10 @@ fn spawn_launch(
let name = SBX_NAME.to_string(); let name = SBX_NAME.to_string();
let prep = { let prep = {
let (n, img) = (name.clone(), image.clone()); let (n, img) = (name.clone(), image.clone());
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img)).await tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon)).await
}; };
if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) { if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
let _ = app_tx.send(Net::Sys(format!("sandbox prepare failed: {e}"))); let _ = app_tx.send(Net::Err(format!("sandbox prepare failed: {e}")));
let _ = broker_tx.send(BrokerMsg::Failed); let _ = broker_tx.send(BrokerMsg::Failed);
return; return;
} }
@ -778,7 +931,7 @@ fn spawn_launch(
let _ = broker_tx.send(BrokerMsg::Ready { sb, backend, name, rows, cols }); let _ = broker_tx.send(BrokerMsg::Ready { sb, backend, name, rows, cols });
} }
Err(e) => { Err(e) => {
let _ = app_tx.send(Net::Sys(format!("sandbox launch failed: {e}"))); let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}")));
let _ = broker_tx.send(BrokerMsg::Failed); let _ = broker_tx.send(BrokerMsg::Failed);
} }
} }

View File

@ -28,7 +28,7 @@ A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF60\
60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\ 60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\
FBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73"; FBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73";
/// The SRP identity used by every cmd-chat / coven room (server hardcodes this). /// The SRP identity used by every cmd-chat / clergy room (server hardcodes this).
/// The user's chosen display name is independent of this value. /// The user's chosen display name is independent of this value.
pub const SRP_IDENTITY: &[u8] = b"chat"; pub const SRP_IDENTITY: &[u8] = b"chat";

View File

@ -152,7 +152,7 @@ mod tests {
let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("note.txt"); let src = dir.join("note.txt");
std::fs::write(&src, b"offering to the coven").unwrap(); std::fs::write(&src, b"offering to the clergy").unwrap();
let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap(); let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
assert_eq!(name, "note.txt"); assert_eq!(name, "note.txt");
@ -161,7 +161,7 @@ mod tests {
sha256: sha256_hex(&bytes), dir: false, from: "x".into() }; sha256: sha256_hex(&bytes), dir: false, from: "x".into() };
let dl = dir.join("dl"); let dl = dir.join("dl");
let out = save(&dl, &offer, &bytes).unwrap(); let out = save(&dl, &offer, &bytes).unwrap();
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the coven"); assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy");
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }

View File

@ -84,11 +84,19 @@ fn main() -> Result<()> {
theme, theme,
} => { } => {
let session = net::authenticate(&ip, port, &user, &password, no_tls, insecure)?; let session = net::authenticate(&ip, port, &user, &password, no_tls, insecure)?;
let params = net::ConnParams {
ip,
port,
user,
password,
no_tls,
insecure,
};
let theme = match theme { let theme = match theme {
Some(p) => theme::Theme::load(&p)?, Some(p) => theme::Theme::load(&p)?,
None => theme::Theme::default(), None => theme::Theme::default(),
}; };
tokio::runtime::Runtime::new()?.block_on(app::run(session, theme)) tokio::runtime::Runtime::new()?.block_on(app::run(params, session, theme))
} }
Cmd::Roomkey { password, room_salt_hex } => { Cmd::Roomkey { password, room_salt_hex } => {
let salt = hex::decode(room_salt_hex)?; let salt = hex::decode(room_salt_hex)?;

View File

@ -24,6 +24,21 @@ pub struct Session {
pub insecure: bool, pub insecure: bool,
} }
/// The credentials needed to (re)authenticate a Session — kept so the UI can
/// re-run the SRP handshake and rejoin after a disconnect (AFK / server blip).
#[derive(Clone)]
pub struct ConnParams {
pub ip: String,
pub port: u16,
pub user: String,
pub password: String,
pub no_tls: bool,
pub insecure: bool,
}
/// The write half of a split websocket; outgoing frames are sent here.
pub type WsSink = futures_util::stream::SplitSink<Ws, WsMsg>;
/// Full SRP handshake against the Sanic server. Returns a ready Session /// Full SRP handshake against the Sanic server. Returns a ready Session
/// (room key derived, ws url built) but does not open the websocket. /// (room key derived, ws url built) but does not open the websocket.
pub fn authenticate( pub fn authenticate(
@ -99,6 +114,15 @@ pub async fn connect(session: &Session) -> Result<Ws> {
Ok(ws) Ok(ws)
} }
/// Open the websocket for a session, spawn the reader task feeding `tx`, and
/// hand back the write half. Used for the initial connect and every reconnect.
pub async fn open(session: &Session, tx: UnboundedSender<Net>) -> Result<WsSink> {
let ws = connect(session).await?;
let (write, read) = ws.split();
tokio::spawn(reader(read, session.room.clone(), tx));
Ok(write)
}
fn parse_users(v: &Value) -> Vec<User> { fn parse_users(v: &Value) -> Vec<User> {
v.as_array() v.as_array()
.into_iter() .into_iter()

View File

@ -2,16 +2,46 @@
//! //!
//! The broker (owner's client) spawns a sandbox shell inside a PTY. Output bytes //! The broker (owner's client) spawns a sandbox shell inside a PTY. Output bytes
//! are pumped out of a reader thread onto an mpsc channel; the broker encrypts //! are pumped out of a reader thread onto an mpsc channel; the broker encrypts
//! them with the room key and relays them to the coven as `sbx pty_data` frames. //! them with the room key and relays them to the clergy as `sbx pty_data` frames.
//! Input frames (`sbx pty_input`) are written back into the PTY. The server only //! Input frames (`sbx pty_input`) are written back into the PTY. The server only
//! ever sees ciphertext — identical trust model to chat/file transfer. //! ever sees ciphertext — identical trust model to chat/file transfer.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::process::Command; use std::process::{Command, Stdio};
use std::sync::mpsc; use std::sync::mpsc;
/// Helper that ensures the Docker daemon is running (ships beside this source).
const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ensure-docker.sh");
/// Is the Docker daemon accepting connections? (`docker info` succeeds.)
pub fn docker_daemon_up() -> bool {
Command::new("docker")
.arg("info")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Start the Docker daemon via `ensure-docker.sh --yes`, waiting until it's
/// ready. Returns the script's last error line on failure (e.g. needs sudo).
fn start_docker_daemon() -> Result<()> {
let out = Command::new("bash")
.arg(ENSURE_DOCKER)
.arg("--yes")
.output()
.context("running ensure-docker.sh")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let last = err.lines().last().unwrap_or("could not start the docker daemon");
anyhow::bail!("{last}");
}
Ok(())
}
/// Which sandbox to summon. Multipass = strong isolation (default for real use), /// Which sandbox to summon. Multipass = strong isolation (default for real use),
/// Docker = fast, Local = no isolation (dev/testing only). /// Docker = fast, Local = no isolation (dev/testing only).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -50,7 +80,7 @@ impl Backend {
/// One-time setup before the PTY shell is spawned. Blocking — run off the UI /// One-time setup before the PTY shell is spawned. Blocking — run off the UI
/// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance /// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance
/// that already exists. /// that already exists.
pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> { pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> Result<()> {
match backend { match backend {
Backend::Local => Ok(()), Backend::Local => Ok(()),
Backend::Multipass => { Backend::Multipass => {
@ -60,24 +90,48 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
.map(|o| o.status.success()) .map(|o| o.status.success())
.unwrap_or(false); .unwrap_or(false);
if !exists { if !exists {
let st = Command::new("multipass") // Capture output so it can't bleed onto the TUI surface; surface
// the failure reason through the returned error instead.
let out = Command::new("multipass")
.args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image]) .args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image])
.status() .output()
.context("multipass launch (is multipass installed?)")?; .context("multipass launch (is multipass installed?)")?;
anyhow::ensure!(st.success(), "multipass launch failed"); if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("multipass launch failed: {}", err.lines().last().unwrap_or("").trim());
}
} else { } else {
let _ = Command::new("multipass").args(["start", name]).status(); let _ = Command::new("multipass").args(["start", name])
.stdout(Stdio::null()).stderr(Stdio::null()).status();
} }
Ok(()) Ok(())
} }
Backend::Docker => { Backend::Docker => {
// The daemon must be up before any `docker` call. Rather than fail
// with a raw connection error, start it (the caller confirmed via
// `/sbx launch docker --start`).
if !docker_daemon_up() {
if start_daemon {
start_docker_daemon().context("starting docker daemon")?;
} else {
anyhow::bail!(
"docker daemon is not running — retry with `/sbx launch docker --start`"
);
}
}
// Persistent container so we can exec in to provision users + shells. // Persistent container so we can exec in to provision users + shells.
let _ = Command::new("docker").args(["rm", "-f", name]).status(); let _ = Command::new("docker").args(["rm", "-f", name])
let st = Command::new("docker") .stdout(Stdio::null()).stderr(Stdio::null()).status();
// Capture output so a failure can't paint over the TUI; the reason is
// surfaced through the returned error (shown in the error popup).
let out = Command::new("docker")
.args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"]) .args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"])
.status() .output()
.context("docker run (is docker installed?)")?; .context("docker run (is docker installed?)")?;
anyhow::ensure!(st.success(), "docker run failed"); if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("docker run failed: {}", err.lines().last().unwrap_or("").trim());
}
Ok(()) Ok(())
} }
} }
@ -88,10 +142,12 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
pub fn teardown(backend: Backend, name: &str) { pub fn teardown(backend: Backend, name: &str) {
match backend { match backend {
Backend::Multipass => { Backend::Multipass => {
let _ = Command::new("multipass").args(["delete", name, "--purge"]).status(); let _ = Command::new("multipass").args(["delete", name, "--purge"])
.stdout(Stdio::null()).stderr(Stdio::null()).status();
} }
Backend::Docker => { Backend::Docker => {
let _ = Command::new("docker").args(["rm", "-f", name]).status(); let _ = Command::new("docker").args(["rm", "-f", name])
.stdout(Stdio::null()).stderr(Stdio::null()).status();
} }
Backend::Local => {} Backend::Local => {}
} }
@ -125,7 +181,7 @@ fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
} }
} }
/// Sanitize a coven display name into a safe unix username. /// Sanitize a clergy display name into a safe unix username.
pub fn unix_name(name: &str) -> String { pub fn unix_name(name: &str) -> String {
let s: String = name let s: String = name
.to_lowercase() .to_lowercase()
@ -139,12 +195,15 @@ pub fn unix_name(name: &str) -> String {
fn mp(name: &str, args: &[&str]) { fn mp(name: &str, args: &[&str]) {
let mut a = vec!["exec", name, "--"]; let mut a = vec!["exec", name, "--"];
a.extend_from_slice(args); a.extend_from_slice(args);
let _ = Command::new("multipass").args(a).status(); // Null stdio so provisioning chatter never bleeds onto the TUI surface.
let _ = Command::new("multipass").args(a)
.stdout(Stdio::null()).stderr(Stdio::null()).status();
} }
fn dk(name: &str, args: &[&str]) { fn dk(name: &str, args: &[&str]) {
let mut a = vec!["exec", name]; let mut a = vec!["exec", name];
a.extend_from_slice(args); a.extend_from_slice(args);
let _ = Command::new("docker").args(a).status(); let _ = Command::new("docker").args(a)
.stdout(Stdio::null()).stderr(Stdio::null()).status();
} }
/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in) /// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in)
@ -160,7 +219,7 @@ fn mp_revoke_sudo(name: &str, u: &str) {
mp(name, &["sudo", "bash", "-c", &script]); mp(name, &["sudo", "bash", "-c", &script]);
} }
/// Provision a real unix account per coven member inside the VM/container and /// Provision a real unix account per clergy member inside the VM/container and
/// make the owner a superuser (sudoer). Returns the unix user the shared shell /// make the owner a superuser (sudoer). Returns the unix user the shared shell
/// should run as. Blocking — call off the UI thread. /// should run as. Blocking — call off the UI thread.
pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String { pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String {
@ -219,7 +278,7 @@ pub struct Sandbox {
impl Sandbox { impl Sandbox {
/// Spawn the backend in a PTY. A reader thread pushes raw output bytes onto /// Spawn the backend in a PTY. A reader thread pushes raw output bytes onto
/// `out`; the caller relays them (encrypted) to the coven. /// `out`; the caller relays them (encrypted) to the clergy.
pub fn launch( pub fn launch(
backend: Backend, backend: Backend,
name: &str, name: &str,

View File

@ -2,9 +2,14 @@
//! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a //! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a
//! TOML file via `--theme <path>`. //! TOML file via `--theme <path>`.
use anyhow::Context;
use ratatui::style::Color; use ratatui::style::Color;
use serde::Deserialize; use serde::Deserialize;
/// Where the bundled `*.toml` vestments live, so `/theme <name>` can resolve a
/// bare name to a file at runtime (mirrors sbx.rs's script path const).
pub const THEMES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/themes");
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Theme { pub struct Theme {
@ -18,8 +23,14 @@ pub struct Theme {
pub system: Color, pub system: Color,
pub input: Color, pub input: Color,
pub roster_me: Color, pub roster_me: Color,
/// Panel/background fill. `Reset` = the terminal's own background (no fill);
/// a solid colour lifts the text onto a contrasting surface for legibility.
pub bg: Color,
/// Width of the roster column. /// Width of the roster column.
pub roster_width: u16, pub roster_width: u16,
/// Glyph flanking the "hack-house" title (and used for occult accents).
/// Defaults to ⛧ (inverted pentagram); themes can pick their own sigil.
pub sigil: String,
} }
impl Default for Theme { impl Default for Theme {
@ -37,7 +48,9 @@ impl Default for Theme {
system: Color::Rgb(0xb4, 0x6c, 0xff), // system / occult = purple system: Color::Rgb(0xb4, 0x6c, 0xff), // system / occult = purple
input: Color::Rgb(0x39, 0xff, 0x14), input: Color::Rgb(0x39, 0xff, 0x14),
roster_me: Color::Rgb(0xff, 0x39, 0xc0), // you / owner = hot magenta roster_me: Color::Rgb(0xff, 0x39, 0xc0), // you / owner = hot magenta
bg: Color::Reset, // ride the terminal's own black
roster_width: 22, roster_width: 22,
sigil: "".into(), // inverted pentagram
} }
} }
} }
@ -47,6 +60,30 @@ impl Theme {
let s = std::fs::read_to_string(path)?; let s = std::fs::read_to_string(path)?;
Ok(toml::from_str(&s)?) Ok(toml::from_str(&s)?)
} }
/// Resolve a bare vestment name (e.g. "neon") to a bundled `themes/<name>.toml`.
/// Used by the in-session `/theme` command for live switching.
pub fn by_name(name: &str) -> anyhow::Result<Self> {
let path = format!("{THEMES_DIR}/{name}.toml");
Self::load(&path).with_context(|| format!("theme '{name}' ({path})"))
}
/// Names of the bundled vestments, sorted, for `/theme` with no argument.
pub fn available() -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(THEMES_DIR)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
e.file_name()
.to_str()
.and_then(|f| f.strip_suffix(".toml"))
.map(str::to_string)
})
.collect();
names.sort();
names
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -9,6 +9,11 @@ use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
// Paint the whole frame in the theme background first so every panel (and the
// gaps between them) sits on the same surface. With bg = Reset this is a no-op
// and we ride the terminal's own colour.
f.render_widget(Block::default().style(Style::default().bg(theme.bg)), f.area());
let rows = Layout::vertical([ let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(1),
Constraint::Min(1), Constraint::Min(1),
@ -39,6 +44,35 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
if app.show_help { if app.show_help {
draw_help(f, f.area(), theme); draw_help(f, f.area(), theme);
} }
if let Some(msg) = &app.error {
draw_error(f, f.area(), theme, msg);
}
}
/// Transient error popup, anchored top-right over the clergy so it never bleeds
/// onto the input box. Cleared by the next keypress (see the run loop).
fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
let text = format!("{msg}");
let w = area.width.saturating_sub(2).min(48).max(16);
let inner = w.saturating_sub(2).max(1);
let rows = (text.chars().count() as u16).div_ceil(inner) + 2;
let h = rows.min(area.height.saturating_sub(2)).max(3);
let x = area.x + area.width.saturating_sub(w + 1); // hug the right edge
let y = area.y + 1; // just under the top bar, over the clergy
let rect = Rect { x, y, width: w, height: h };
f.render_widget(Clear, rect);
let popup = Paragraph::new(text)
.style(Style::default().fg(theme.title).bg(theme.bg))
.block(
Block::bordered()
.border_style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD))
.title(Span::styled(
format!(" {} error · any key ", theme.sigil),
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
)),
)
.wrap(Wrap { trim: false });
f.render_widget(popup, rect);
} }
fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect { fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
@ -68,9 +102,10 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
Span::styled(v.to_string(), dim), Span::styled(v.to_string(), dim),
]) ])
}; };
let head = |s: &str| Line::from(Span::styled(s.to_string(), acc)); let sig = &theme.sigil;
let head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc));
let lines = vec![ let lines = vec![
head("COMMANDS (type in the input bar)"), head("COMMANDS (type in the input bar)"),
kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"), kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"),
kv("/sbx stop", "tear down the sandbox (purges the VM)"), kv("/sbx stop", "tear down the sandbox (purges the VM)"),
kv("/drive", "type into the shared shell (Esc releases)"), kv("/drive", "type into the shared shell (Esc releases)"),
@ -81,18 +116,22 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
kv("/send <file>", "offer a file to the room"), kv("/send <file>", "offer a file to the room"),
kv("/sendd <dir>", "offer a directory (sent as a tar)"), kv("/sendd <dir>", "offer a directory (sent as a tar)"),
kv("/accept · /reject", "respond to an incoming file offer"), kv("/accept · /reject", "respond to an incoming file offer"),
kv("/theme [name]", "change vestments live: church | neon | crypt"),
kv("/pw", "show this room's password (local only)"),
kv("/help", "show / hide this menu"), kv("/help", "show / hide this menu"),
Line::from(""), Line::from(""),
head("KEYS"), head("KEYS"),
kv("Enter", "send chat message"), kv("Enter", "send chat message"),
kv("F1 · /help", "toggle this help (any key closes it)"), kv("F1 · /help", "toggle this help (any key closes it)"),
kv("F2 · /drive", "take the shell · Esc releases it"), kv("F2 · /drive", "take the shell · Esc releases it"),
kv("Ctrl-C (while driving)", "interrupt the running command"), kv("Ctrl-C (while driving)", "interrupt the running command"),
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"), kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
kv("Up / Down", "scroll the sandbox terminal (when not driving)"), kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"),
kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"),
kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"),
kv("Ctrl-Q", "quit hack-house"), kv("Ctrl-Q", "quit hack-house"),
Line::from(""), Line::from(""),
head("ROSTER GLYPHS"), head("ROSTER GLYPHS"),
kv("⛧ owner ⚡ sudoer", "◆ may drive • member"), kv("⛧ owner ⚡ sudoer", "◆ may drive • member"),
Line::from(""), Line::from(""),
Line::from(Span::styled( Line::from(Span::styled(
@ -103,11 +142,12 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
let w = centered(78, 90, area); let w = centered(78, 90, area);
f.render_widget(Clear, w); f.render_widget(Clear, w);
let help = Paragraph::new(lines) let help = Paragraph::new(lines)
.style(Style::default().bg(theme.bg)) // fill the popup with the theme surface
.block( .block(
Block::bordered() Block::bordered()
.border_style(Style::default().fg(theme.accent)) .border_style(Style::default().fg(theme.accent))
.title(Span::styled( .title(Span::styled(
" ⛧ hack-house — help ⛧ ", format!(" {0} hack-house — help {0} ", theme.sigil),
Style::default().fg(theme.title).add_modifier(Modifier::BOLD), Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
)), )),
) )
@ -123,12 +163,14 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
.rows(0, cols) .rows(0, cols)
.map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title)))) .map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title))))
.collect(); .collect();
let drive = if app.driving { let drive = if app.driving && app.sbx_scroll > 0 {
" · DRIVING — type here · Esc to release".to_string() format!(" · DRIVING · ↑{} scrollback (PgDn=live)", app.sbx_scroll)
} else if app.driving {
" · DRIVING — type here · Esc · PgUp/wheel scroll".to_string()
} else if app.sbx_scroll > 0 { } else if app.sbx_scroll > 0 {
format!(" · ↑{} scrollback (↓/End=live)", app.sbx_scroll) format!(" · ↑{} scrollback (↓/End=live)", app.sbx_scroll)
} else { } else {
" · /drive (or F2) · ↑/↓ scroll".to_string() " · /drive (or F2) · ↑/↓/wheel scroll".to_string()
}; };
let title = format!(" sandbox · {}{} ", sv.backend, drive); let title = format!(" sandbox · {}{} ", sv.backend, drive);
let border = if app.driving { theme.accent } else { theme.border }; let border = if app.driving { theme.accent } else { theme.border };
@ -142,10 +184,16 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
let cap = if app.capacity > 0 { app.capacity } else { app.users.len() }; let cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
let status = if app.connected { "🔒 e2e" } else { "✖ closed" }; let status = if app.connected {
"🔒 e2e"
} else if app.reconnecting {
"… reconnecting"
} else {
"✖ closed · Ctrl-R to reconnect"
};
let bar = Line::from(vec![ let bar = Line::from(vec![
Span::styled( Span::styled(
" ⛧ hack-house ⛧ ", format!(" {0} hack-house {0} ", theme.sigil),
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
), ),
Span::styled(format!("· {status} "), Style::default().fg(theme.dim)), Span::styled(format!("· {status} "), Style::default().fg(theme.dim)),
@ -177,12 +225,20 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
} }
fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
let visible = area.height.saturating_sub(2) as usize; let inner_h = area.height.saturating_sub(2) as usize; // rows inside the border
let len = app.lines.len(); let text_w = area.width.saturating_sub(2).max(1); // wrap width inside the border
// Window ends `chat_scroll` lines above the live bottom. let lines: Vec<Line> = app.lines.iter().map(|l| fmt_line(l, app, theme)).collect();
let end = len.saturating_sub(app.chat_scroll);
let start = end.saturating_sub(visible); // Measure the TRUE wrapped height and scroll to the bottom. Selecting N
let lines: Vec<Line> = app.lines[start..end].iter().map(|l| fmt_line(l, app, theme)).collect(); // logical lines and letting the Paragraph top-anchor them clips any wrapped
// line off the bottom — so the newest messages stay hidden until later ones
// push them up into view (worse when the sandbox shrinks the chat pane).
let total_rows = Paragraph::new(lines.clone())
.wrap(Wrap { trim: false })
.line_count(text_w);
let max_scroll = total_rows.saturating_sub(inner_h);
let scroll = max_scroll.saturating_sub(app.chat_scroll) as u16;
let title = if app.chat_scroll > 0 { let title = if app.chat_scroll > 0 {
format!(" chat ↑{} (End=live) ", app.chat_scroll) format!(" chat ↑{} (End=live) ", app.chat_scroll)
} else { } else {
@ -194,7 +250,8 @@ fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Them
.border_style(Style::default().fg(theme.border)) .border_style(Style::default().fg(theme.border))
.title(Span::styled(title, Style::default().fg(theme.title))), .title(Span::styled(title, Style::default().fg(theme.title))),
) )
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: false })
.scroll((scroll, 0));
f.render_widget(chat, area); f.render_widget(chat, area);
} }
@ -225,7 +282,7 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
let roster = List::new(items).block( let roster = List::new(items).block(
Block::bordered() Block::bordered()
.border_style(Style::default().fg(theme.border)) .border_style(Style::default().fg(theme.border))
.title(Span::styled(" coven ", Style::default().fg(theme.title))), .title(Span::styled(" clergy ", Style::default().fg(theme.title))),
); );
f.render_widget(roster, area); f.render_widget(roster, area);
} }

230
hh/test-features.sh Executable file
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 ))

View File

@ -10,3 +10,4 @@ system = "#b46cff" # system / occult lines (purple)
input = "#39ff14" input = "#39ff14"
roster_me = "#ff39c0" # you / owner (hot magenta) roster_me = "#ff39c0" # you / owner (hot magenta)
roster_width = 22 roster_width = 22
sigil = "⛧" # inverted pentagram

View File

@ -1,12 +1,14 @@
# crypt — churchofmalware occult monochrome (default) # crypt — occult monochrome: white ink on a lifted slate surface
name = "crypt" name = "crypt"
border = "darkgray" bg = "#1c1c22" # slate panel — lifts the text off pure-black for legibility
title = "white" border = "#6e6e7a" # gray chrome, defined against the surface
accent = "white" title = "#ffffff"
dim = "darkgray" accent = "#ffffff"
me = "white" dim = "#9a9aa6" # timestamps — readable mid-gray (was near-invisible darkgray)
other = "gray" me = "#ffffff"
system = "darkgray" other = "#c8c8d0" # others' messages — bright gray
input = "white" system = "#b0b0bc" # system / occult lines — legible, still muted
roster_me = "white" input = "#ffffff"
roster_me = "#ffffff"
roster_width = 22 roster_width = 22
sigil = "✝" # inverted cross / crypt

View File

@ -10,3 +10,4 @@ system = "#7a5cff"
input = "#39ff14" input = "#39ff14"
roster_me = "#ff2fd0" roster_me = "#ff2fd0"
roster_width = 24 roster_width = 24
sigil = "⚡" # cyberpunk bolt

View File

@ -1,4 +1,4 @@
"""Use-case tests for the hack-house multi-user coven features (capacity cap, """Use-case tests for the hack-house multi-user clergy features (capacity cap,
roster, username + slot lifecycle). In-process via sanic-testing. roster, username + slot lifecycle). In-process via sanic-testing.
""" """
import base64 import base64
@ -27,7 +27,7 @@ def _init(test_client, name):
) )
class TestCovenCapacity: class TestClergyCapacity:
def test_accepts_up_to_capacity(self, app, test_client): def test_accepts_up_to_capacity(self, app, test_client):
app.ctx.max_users = 4 app.ctx.max_users = 4
for n in ("a", "b", "c"): for n in ("a", "b", "c"):