6 Commits

Author SHA1 Message Date
leetcrypt 9f7ab92270 feat(scripts): add host-room.sh to host a room (server wrapper)
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
Friendly wrapper around `cmd_chat.py serve`: uses the project venv, mints a
random room password passed via $CMD_CHAT_PASSWORD (kept out of the process
list), prints the join command preferring the Tailscale IP, defaults to
--no-tls with an opt-in --tls (cert/key) path, and runs in the foreground.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 17:34:30 -07:00
leetcrypt 9222fa8ad7 feat(ai): name agents after their model, not hardcoded "oracle"
Agents now join under their model tag (e.g. "qwen2.5:3b" — model name +
parameter size) or profile label, derived at /ai start and tracked on
App.agent_name, so the roster shows what's actually answering. Updates the
broker re-grant + /ai stop revoke paths, the Python --name default
(falls back to provider.model), and the demo/bootstrap/README/docs refs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 17:26:17 -07:00
leetcrypt 3a54578f0a perf(net): near-real-time sandbox for non-host viewers
Two causes made a shared shell's PTY stream lag for non-host viewers and
then arrive "all at once":

1. Nagle's algorithm — PTY echo/keystrokes/chat are tiny frames; Nagle +
   delayed-ACK (amplified by Tailscale RTT) coalesced them into bursts.
   Set TCP_NODELAY on the client websocket (Plain/--no-tls path) and on
   each server connection's socket (via ws.io_proto.transport).

2. Head-of-line blocking — ConnectionManager.broadcast awaited each
   send() serially under a global lock, so the slowest viewer gated
   delivery to everyone AND stalled the server's read of the broker's
   next frame, backing the stream up. Give each connection its own
   bounded outbound queue + writer task; broadcast now only enqueues and
   never waits on a socket. A peer that overflows its backlog (4096) is
   evicted (close 1013) and resyncs on reconnect rather than buffering
   without bound or being fed a gapped terminal stream. Init snapshot is
   enqueued as the guaranteed-first frame (send_state -> state_frame).

Adds tests/test_manager.py (FIFO, slow-client isolation, wedged
eviction, reconnect replacement). Also fmt-clean sbx.rs/theme.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 17:11:51 -07:00
leetcrypt c552ece6b1 feat(theme): WCAG-legible backgrounds + oc-demo theme & reaction assets
Derive each rolled palette's surface from its ink via legible_bg so the
background is as expressive as it can be while keeping chat text above a
contrast floor. Adds the oc-demo theme and reaction images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 16:47:57 -07:00
leetcrypt d6d44128c0 feat(sbx,ai): snapshot all backends w/ local export; robust AI + rejoin
- /sbx save [--local] and new /sbx vmsave/vmsnaps: snapshot Docker,
  Multipass, and VirtualBox; --local also writes a portable artifact
  (docker .tar / VBox .ova) under hh-snapshots/ that survives pruning.
- sandbox reappears for anyone who leaves and rejoins (host replays
  status + screen snapshot + ACL on Joined); SbxStatus ready handler is
  now idempotent so it never wipes scrollback.
- received files auto-bridge into the hosted sandbox (ft::tar_path).
- AI agent: translate Ollama's cryptic 404 into model/host/fix guidance.
- bootstrap installs the AI layer (Ollama + default model) by default,
  with consent gates; --no-ai opts out, --yes skips prompts.
- help menu lists the new save/vm flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 16:47:57 -07:00
leetcrypt 70245cbd9d feat(hh): random room password by default; join.sh syncs before connect
- lets-hack.sh: default PW is now `openssl rand -hex 12` (printed in the
  summary for sharing) instead of the well-known hardcoded `malware-bless`
- join.sh: drop the hardcoded password (uses $HH_PASSWORD or a no-echo
  prompt), and fast-forward pull from gitea+origin then `cargo build`
  before connecting so joiners run the latest code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 14:42:12 -07:00
25 changed files with 1100 additions and 117 deletions
+2 -1
View File
@@ -275,7 +275,8 @@ directly:
```bash
.venv/bin/python -m cmd_chat.agent <server_ip> <port> \
--name oracle --password <room-pw> --provider ollama --model qwen2.5:3b
--password <room-pw> --provider ollama --model qwen2.5:3b
# joins as its model tag ("qwen2.5:3b") unless you override with --name
# cloud (opt-in): --provider anthropic --model claude-opus-4-6 (needs ANTHROPIC_API_KEY)
```
+30 -8
View File
@@ -12,7 +12,8 @@
# ./bootstrap-ai.sh # baseline setup + Ollama + default model
# ./bootstrap-ai.sh --release # ...and build the client in release mode
# ./bootstrap-ai.sh --check # report only; install/pull nothing
# ./bootstrap-ai.sh --yes # don't prompt before installing Ollama
# ./bootstrap-ai.sh --yes # don't prompt before installing Ollama / pulling the model
# ./bootstrap-ai.sh --ai-only # only the AI layer; skip the baseline ./bootstrap.sh
# ./bootstrap-ai.sh -h | --help # this help
#
# environment:
@@ -28,13 +29,15 @@ INSTALLER_URL="https://ollama.com/install.sh"
RELEASE_ARGS=()
CHECK_ONLY=0
ASSUME_YES=0
AI_ONLY=0
for arg in "$@"; do
case "$arg" in
--release) RELEASE_ARGS+=(--release) ;;
--check) CHECK_ONLY=1 ;;
--yes|-y) ASSUME_YES=1 ;;
--ai-only) AI_ONLY=1 ;;
-h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "✖ unknown arg: $arg (try --release / --check / --yes / --help)" >&2; exit 2 ;;
*) echo "✖ unknown arg: $arg (try --release / --check / --yes / --ai-only / --help)" >&2; exit 2 ;;
esac
done
@@ -44,10 +47,14 @@ ollama_up() { curl -s --max-time 3 "$OLLAMA_HOST/api/tags" >/dev/null 2>&1; }
# 0. Baseline setup (venv + server/agent deps + client build). The agent's own
# runtime deps (requests, websockets) are already in requirements.txt, so the
# baseline install covers them — this script adds only the model runtime.
if [[ $CHECK_ONLY -eq 1 ]]; then
"$ROOT/bootstrap.sh" --check || exit $?
else
"$ROOT/bootstrap.sh" "${RELEASE_ARGS[@]}" || exit $?
# --no-ai stops bootstrap.sh re-entering this script (it now runs the AI layer
# by default). --ai-only skips the baseline entirely (caller already ran it).
if [[ $AI_ONLY -ne 1 ]]; then
if [[ $CHECK_ONLY -eq 1 ]]; then
"$ROOT/bootstrap.sh" --no-ai --check || exit $?
else
"$ROOT/bootstrap.sh" --no-ai "${RELEASE_ARGS[@]}" || exit $?
fi
fi
echo
@@ -95,10 +102,25 @@ if ! ollama_up; then
echo " ✓ daemon up"
fi
# 4. Pull the default model (idempotent).
# 4. Pull the default model (idempotent). Pulling downloads several GB onto THIS
# machine, so ask first on an interactive terminal (skip with --yes) — never
# pull a model onto someone's box without their say-so.
if ollama list 2>/dev/null | awk 'NR>1{print $1}' | grep -Fxq "$MODEL"; then
echo " ✓ model '$MODEL' already present"
else
echo " model '$MODEL' is not present — pulling it downloads several GB to THIS machine."
if [[ $ASSUME_YES -ne 1 ]]; then
if [[ -t 0 ]]; then
read -r -p " pull '$MODEL' now? [y/N] " ans
[[ "$ans" == [yY]* ]] || { echo " aborted — no model pulled. pull it yourself with: ollama pull $MODEL" >&2; exit 1; }
else
# No terminal to confirm on — never pull onto someone's machine
# unasked. Require explicit --yes (or an interactive yes) instead.
echo " ✖ not pulling '$MODEL' without confirmation (no terminal to ask)." >&2
echo " re-run with --yes to allow it, or pull manually: ollama pull $MODEL" >&2
exit 1
fi
fi
echo " pulling model '$MODEL' (first pull can take a while)…"
ollama pull "$MODEL" || { echo " ✖ failed to pull '$MODEL'" >&2; exit 1; }
echo " ✓ model '$MODEL' ready"
@@ -107,6 +129,6 @@ 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 " --name oracle --password <room-pw> --provider ollama --model $MODEL --no-tls"
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"
+28 -3
View File
@@ -5,11 +5,18 @@
# for the server, installs its dependencies, and builds the Rust client.
#
# usage:
# ./bootstrap.sh # full setup (venv + deps + cargo build)
# ./bootstrap.sh # full setup (venv + deps + client + AI layer)
# ./bootstrap.sh --release # build the client in release mode
# ./bootstrap.sh --no-ai # skip the AI layer (no Ollama install / model pull)
# ./bootstrap.sh --yes # don't prompt during the AI layer (install / pull)
# ./bootstrap.sh --check # only report what's installed; change nothing
# ./bootstrap.sh -h | --help # this help
#
# The AI layer (local Ollama + a default model for the /ai agent) is set up by
# default so the agent works out of the box and nobody hits "model not found".
# It still prompts before installing/pulling on an interactive terminal — skip
# the whole thing with --no-ai, or skip the prompts with --yes.
#
# After it finishes, spin up a local test session with: cd hh && ./lets-hack.sh
set -uo pipefail
@@ -19,12 +26,16 @@ HH_DIR="$ROOT/hh"
RELEASE=0
CHECK_ONLY=0
DO_AI=1
ASSUME_YES=0
for arg in "$@"; do
case "$arg" in
--release) RELEASE=1 ;;
--check) CHECK_ONLY=1 ;;
--no-ai) DO_AI=0 ;;
--yes|-y) ASSUME_YES=1 ;;
-h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "✖ unknown arg: $arg (try --release / --check / --help)" >&2; exit 2 ;;
*) echo "✖ unknown arg: $arg (try --release / --no-ai / --yes / --check / --help)" >&2; exit 2 ;;
esac
done
@@ -73,8 +84,22 @@ else
echo " ✓ client built: hh/target/debug/hack-house"
fi
# 4. AI layer (on by default): install Ollama + pull the default model so the
# /ai agent works out of the box and nobody hits "model not found" later. The
# logic lives in bootstrap-ai.sh; --ai-only skips its baseline re-run (we just
# did it). Declining/failing here is non-fatal — the baseline setup still
# stands and the AI layer can be added later with ./bootstrap-ai.sh.
if [[ $DO_AI -eq 1 ]]; then
ai_args=(--ai-only)
[[ $ASSUME_YES -eq 1 ]] && ai_args+=(--yes)
"$ROOT/bootstrap-ai.sh" "${ai_args[@]}" \
|| echo "⚠ AI layer not completed — re-run ./bootstrap-ai.sh when ready" >&2
fi
echo
echo "ready. next steps:"
echo " cd hh && ./lets-hack.sh # local test session (server + clients in tmux)"
echo " # or run the server + client by hand — see README.MD"
echo " # want the local AI agent? ./bootstrap-ai.sh (installs Ollama + a model)"
if [[ $DO_AI -eq 0 ]]; then
echo " # AI layer skipped (--no-ai); add it later with ./bootstrap-ai.sh"
fi
+8 -4
View File
@@ -2,8 +2,8 @@
Examples
--------
# local Ollama (default, recommended)
python -m cmd_chat.agent 127.0.0.1 3000 --name oracle \
# local Ollama (default, recommended) — joins as "qwen2.5:3b"
python -m cmd_chat.agent 127.0.0.1 3000 \
--password hunter2 --model qwen2.5:3b --no-tls
# cloud, opt-in
@@ -102,7 +102,8 @@ def main() -> None:
)
ap.add_argument("server", nargs="?", help="room host (omit with --list-models/--check)")
ap.add_argument("port", type=int, nargs="?", help="room port")
ap.add_argument("--name", default="oracle", help="agent's room display name")
ap.add_argument("--name", default=None,
help="agent's room display name (default: the model tag, e.g. qwen2.5:3b)")
ap.add_argument("--password", default=None, help="room password")
ap.add_argument("--provider", default="ollama",
help="ollama | anthropic | openai | module:Class")
@@ -184,8 +185,11 @@ def main() -> None:
if code_provider is not None:
print(f"sandbox/code path → {code_provider.name}/{code_provider.model}", file=sys.stderr)
# Default the room handle to the model tag (model name + parameter size,
# e.g. "qwen2.5:3b") so the roster shows what's actually answering.
name = args.name or provider.model
bridge = AgentBridge(
args.server, args.port, name=args.name, provider=provider,
args.server, args.port, name=name, provider=provider,
password=args.password, insecure=args.insecure, no_tls=args.no_tls,
system_prompt=args.system, context_window=args.context_window,
token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k,
+31 -2
View File
@@ -63,6 +63,35 @@ class OllamaProvider:
opts["num_thread"] = self.num_thread
return opts
def _raise_for_status(self, r: requests.Response) -> None:
"""Turn an Ollama HTTP error into an actionable message.
Ollama answers /api/chat with 404 + ``{"error": "model ... not found"}``
when the model isn't pulled on the box running this agent. Because the
agent talks to *its own* localhost:11434, a teammate who summoned /ai
without that model pulled hits this even when the host has it. The bare
``raise_for_status`` only reports "404 Not Found for url", hiding the
cause — so name the model, the host, and the fix instead. This text is
what the bridge posts to the room as ``[ai error: …]``.
"""
if r.ok:
return
try:
detail = (r.json().get("error") or "").strip()
except ValueError:
detail = (r.text or "").strip()
if r.status_code == 404:
raise RuntimeError(
f"model '{self.model}' isn't pulled on the ollama at {self.host} "
f"(the agent uses ollama on the machine that ran /ai, not the host). "
f"fix: `ollama pull {self.model}` there, or `/ai start <profile>` "
f"for a cloud model. [{detail or 'model not found'}]"
)
raise RuntimeError(
f"ollama at {self.host} returned {r.status_code}"
+ (f": {detail}" if detail else "")
)
def complete(self, system: str, messages: list[Msg]) -> str:
payload = {
"model": self.model,
@@ -73,7 +102,7 @@ class OllamaProvider:
+ [{"role": m.role, "content": m.content} for m in messages],
}
r = requests.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
r.raise_for_status()
self._raise_for_status(r)
return (r.json().get("message", {}).get("content") or "").strip()
def stream(self, system: str, messages: list[Msg]):
@@ -89,7 +118,7 @@ class OllamaProvider:
}
with requests.post(f"{self.host}/api/chat", json=payload,
timeout=self.timeout, stream=True) as r:
r.raise_for_status()
self._raise_for_status(r)
for line in r.iter_lines():
if not line:
continue
+5 -4
View File
@@ -24,11 +24,13 @@ def get_client_ip(request: Request) -> str:
return request.ip
async def send_state(ws: Websocket, app: Sanic) -> None:
def state_frame(app: Sanic) -> str:
"""Build the per-client `init` snapshot (message backlog + roster) as a JSON
string. Returned (not sent) so it can be enqueued as the connection's first
outbound frame, guaranteeing it precedes any later broadcast."""
messages = app.ctx.message_store.get_all()
users = app.ctx.session_store.get_all()
await ws.send(
json.dumps(
return json.dumps(
{
"type": "init",
"messages": [asdict(m) for m in messages],
@@ -37,7 +39,6 @@ async def send_state(ws: Websocket, app: Sanic) -> None:
],
}
)
)
class RateLimiter:
+98 -22
View File
@@ -3,41 +3,117 @@ from typing import Optional
from sanic import Websocket
# Per-connection outbound backlog before a client is considered wedged.
#
# Every relayed frame is *enqueued* (never awaited) by the broadcaster, and a
# dedicated per-connection writer task drains the queue with `await ws.send`.
# This decouples the room's read loop from any single slow receiver: the old
# code awaited each `send` serially under a global lock, so the slowest viewer
# (e.g. a remote peer over a laggy Tailscale link) gated delivery to everyone
# *and* stalled the server's read of the next frame — so a sandbox owner's PTY
# stream backed up and then arrived "all at once".
#
# A backlog this deep absorbs a large PTY burst and transient network hiccups.
# A client that falls this far behind is effectively stuck (it would be closed
# by the websocket ping-timeout within ~40s anyway); we close it now so the room
# isn't holding unbounded memory for it. It reconnects (Ctrl-R) and the host
# replays a fresh sandbox snapshot, which is cleaner than feeding it a corrupt,
# partial terminal byte-stream with gaps.
SEND_QUEUE_MAX = 4096
class _Conn:
"""A live websocket plus its outbound queue and the task draining it."""
__slots__ = ("ws", "queue", "task")
def __init__(self, ws: Websocket):
self.ws = ws
self.queue: asyncio.Queue[str] = asyncio.Queue(maxsize=SEND_QUEUE_MAX)
self.task: Optional[asyncio.Task] = None
class ConnectionManager:
def __init__(self):
self.active_connections: dict[str, Websocket] = {}
self.active_connections: dict[str, _Conn] = {}
self._lock = asyncio.Lock()
async def connect(self, user_id: str, websocket: Websocket) -> None:
async def connect(
self, user_id: str, websocket: Websocket, initial: Optional[str] = None
) -> None:
"""Register a connection and start its writer task.
`initial` (the per-client init snapshot) is enqueued *before* the
connection becomes visible to broadcasters, guaranteeing it is the first
frame the client receives — no later broadcast can jump ahead of it.
"""
conn = _Conn(websocket)
if initial is not None:
conn.queue.put_nowait(initial)
old = None
async with self._lock:
self.active_connections[user_id] = websocket
old = self.active_connections.get(user_id)
self.active_connections[user_id] = conn
if old is not None and old.task is not None:
old.task.cancel()
conn.task = asyncio.create_task(self._writer(conn))
async def _writer(self, conn: _Conn) -> None:
"""Drain one connection's queue in FIFO order, one `send` at a time."""
try:
while True:
msg = await conn.queue.get()
await conn.ws.send(msg)
except asyncio.CancelledError:
raise
except Exception:
# Socket died mid-send; the handler's `finally` will disconnect us.
pass
async def disconnect(self, user_id: str) -> None:
async with self._lock:
if user_id in self.active_connections:
del self.active_connections[user_id]
conn = self.active_connections.pop(user_id, None)
if conn is not None and conn.task is not None:
conn.task.cancel()
@staticmethod
def _enqueue(conn: _Conn, message: str) -> bool:
"""Non-blocking enqueue. False means the backlog is full (wedged peer)."""
try:
conn.queue.put_nowait(message)
return True
except asyncio.QueueFull:
return False
async def _drop_wedged(self, user_id: str, conn: _Conn) -> None:
"""Evict a client whose backlog overflowed and nudge it to reconnect."""
await self.disconnect(user_id)
try:
# 1013 = "Try Again Later": the client surfaces a closed socket and
# can reconnect to resync from a fresh snapshot.
await conn.ws.close(code=1013)
except Exception:
pass
async def broadcast(self, message: str, exclude_user: Optional[str] = None) -> None:
async with self._lock:
disconnected = []
for user_id, connection in list(self.active_connections.items()):
if exclude_user and user_id == exclude_user:
continue
try:
await connection.send(message)
except Exception:
disconnected.append(user_id)
for user_id in disconnected:
if user_id in self.active_connections:
del self.active_connections[user_id]
targets = [
(uid, conn)
for uid, conn in self.active_connections.items()
if not (exclude_user and uid == exclude_user)
]
# Enqueue is synchronous and fast, so the broadcaster returns immediately
# — it never waits on any socket. Only genuinely wedged peers overflow.
wedged = [(uid, conn) for uid, conn in targets if not self._enqueue(conn, message)]
for uid, conn in wedged:
await self._drop_wedged(uid, conn)
async def send_personal(self, user_id: str, message: str) -> bool:
async with self._lock:
if connection := self.active_connections.get(user_id):
try:
await connection.send(message)
conn = self.active_connections.get(user_id)
if conn is None:
return False
if self._enqueue(conn, message):
return True
except Exception:
return False
await self._drop_wedged(user_id, conn)
return False
+19 -4
View File
@@ -2,13 +2,27 @@ import hashlib
import hmac
import json
import base64
import socket
from dataclasses import asdict
from sanic import Sanic, Request, response, Websocket
from sanic.response import HTTPResponse, json as json_response
from .models import Message, UserSession
from .helpers import get_client_ip, send_state, utcnow
from .helpers import get_client_ip, state_frame, utcnow
def _disable_nagle(ws: Websocket) -> None:
"""Set TCP_NODELAY on a websocket's underlying socket. Small interactive
frames (PTY echo, keystrokes, chat) must not wait on Nagle coalescing +
delayed-ACK before reaching a viewer. Best-effort: any transport without a
raw socket (or where the option can't be set) is simply left as-is."""
try:
sock = ws.io_proto.transport.get_extra_info("socket")
if sock is not None:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception:
pass
# Hard cap on a single relayed WS frame. The largest legitimate frame is one
@@ -137,11 +151,12 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
return
manager = app.ctx.connection_manager
await manager.connect(user_id, ws)
_disable_nagle(ws)
# Enqueue this client's init snapshot as its first outbound frame, then
# register it so broadcasts can target it — guaranteeing init arrives first.
await manager.connect(user_id, ws, initial=state_frame(app))
try:
await send_state(ws, app)
# Announce arrival to everyone already present, then a fresh roster.
await manager.broadcast(
json.dumps(
+3 -3
View File
@@ -34,10 +34,10 @@ the chat provider is Ollama and a `qwen2.5-coder` is present (it is — pulled).
1. **Title card** — "Ephemeral by default. Persistent on demand."
2. **Summon** — alice: `/sbx launch docker` → "summoned" sandbox bubble.
3. **Spawn the coder** — alice: `/ai start``oracle online — ollama/qwen2.5:3b`
3. **Spawn the coder** — alice: `/ai start``qwen2.5:3b (ai) online — ollama/qwen2.5:3b`
(the coder model rides along for `!task`).
4. **Build, by the fast model** — alice:
`/ai oracle !write /root/fib.py that prints the first 10 Fibonacci numbers, then run it`
`/ai qwen2.5:3b !write /root/fib.py that prints the first 10 Fibonacci numbers, then run it`
→ agent drives the shared shell; `fib.py` is written and executed; the
sandbox pane shows the Fibonacci output.
5. **Freeze it** — alice: `/sbx save buildbox`
@@ -74,7 +74,7 @@ render.
- TUI doesn't bind Ctrl-U (it inserts a literal `u`); clear input with `BSpace`.
Send text with `send-keys -l "<text>"` then a separate `Enter`; don't race renders.
- Agent name is hardcoded `oracle`; only one `/ai start` per room.
- Agent joins under its model tag (e.g. `qwen2.5:3b`); only one `/ai start` per client.
- Keep `!task` phrasing single-line; the agent's drive output lands in the sandbox
pane, not chat.
- `/sbx load` refuses if a sandbox is already running — stop first.
+7 -7
View File
@@ -2,8 +2,8 @@
# 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 oracle
# /ai oracle !build fib.py & run it → /sbx save buildbox → quit
# 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
#
@@ -131,17 +131,17 @@ 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 oracle (qwen2.5:3b chat, qwen2.5-coder:1.5b for !task)"
step "spawn agent (qwen2.5:3b chat, qwen2.5-coder:1.5b for !task)"
say "$A_SESS" "/ai start"
wait_for "$A_SESS" 'oracle|online|ollama' 45 && ok "oracle announced" \
|| note "no 'online' line yet - agent log: ${TMPDIR:-/tmp}/hh-agent-oracle.log"
say "$A_SESS" "/grant oracle"
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 oracle !create /root/fib.py that prints the first 10 fibonacci numbers space-separated on one line, then run it with python3"
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'
+3 -3
View File
@@ -147,9 +147,9 @@ wait_for 'summoned|sandbox|ready|online' 60 >/dev/null
sleep 1.5
# ---- 5. spawn fast qwen agent (auto-grant drive) ---------------------------
step "spawn oracle (auto-grant sandbox drive)"
step "spawn agent (auto-grant sandbox drive)"
say "/ai start $CODER allow"
wait_for 'oracle|online|ollama|qwen' 45 && ok "oracle online" || note "no online line yet"
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 --------------------------
@@ -157,7 +157,7 @@ sleep 1.5
# 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 oracle !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"
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"
+24 -1
View File
@@ -2,7 +2,30 @@
# 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")"
exec ./target/debug/hack-house connect "$HOST" 4173 "$NAME" --password malware-bless --no-tls
# 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
+5 -2
View File
@@ -53,7 +53,7 @@ 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)
PW shared room password (default: random, openssl-generated)
THEME theme name (church | neon | crypt)
examples:
@@ -79,7 +79,9 @@ DEMO_WT="${HH_DEMO_WORKTREE:-/tmp/hh-demo-$DEMO_BRANCH}"
SESSION="${SESSION:-hh-test}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-4173}"
PW="${PW:-malware-bless}"
# 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"
@@ -224,6 +226,7 @@ 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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+172 -26
View File
@@ -161,9 +161,6 @@ pub struct PendingVm {
pub needs_install: bool,
}
/// Display handle the summoned Python agent joins under (see `spawn_agent`).
const AGENT_NAME: &str = "oracle";
pub struct App {
pub me: String,
pub lines: Vec<ChatLine>,
@@ -212,6 +209,11 @@ pub struct App {
/// (`/ai start <name> allow`). Re-applied in the broker Ready handler, since
/// launching a sandbox resets the ACL back to just the owner.
pub agent_sbx_allow: bool,
/// Display name the summoned agent joined under — derived from its model or
/// profile at `/ai start` (e.g. "qwen2.5:3b", model name + parameter size).
/// Tracked so the broker Ready handler can re-grant its drive and `/ai stop`
/// can revoke the right ACL entry.
pub agent_name: Option<String>,
}
impl App {
@@ -244,6 +246,7 @@ impl App {
ai_stream: std::collections::HashMap::new(),
spin: 0,
agent_sbx_allow: false,
agent_name: None,
}
}
@@ -374,11 +377,25 @@ impl App {
cols,
} => {
if ready {
// Converge on the shared shell. If we already track this
// sandbox, just match its size — recreating the parser would
// wipe scrollback and re-announce on every re-broadcast. The
// owner re-sends `status:ready` whenever a member (re)joins, so
// this MUST be idempotent for everyone already in the room; only
// a genuinely new sandbox (none yet) is created and announced.
match &mut self.sandbox {
Some(v) => {
v.parser.set_size(rows.max(1), cols.max(1));
v.backend = backend.clone();
}
None => {
self.sandbox = Some(SbxView {
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
backend: backend.clone(),
});
self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive"));
}
}
} else {
self.sandbox = None;
self.driving = false;
@@ -534,11 +551,14 @@ fn handle_ft(
out: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
downloads: &std::path::Path,
) {
) -> Option<std::path::PathBuf> {
// Set to the saved path when a transfer completes & verifies, so the caller
// can auto-bridge it into a running sandbox.
let mut saved = None;
match f {
ft::Ft::Offer(o) => {
if o.from == app.me {
return; // our own offer echo
return None; // our own offer echo
}
app.sys(format!(
"{} offers {} ({}{}) — /accept or /reject",
@@ -604,11 +624,14 @@ fn handle_ft(
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
} else {
match ft::save(downloads, &t.meta, &t.buf) {
Ok(p) => app.sys(format!(
Ok(p) => {
app.sys(format!(
"⛧ saved {} ({}) — verified ✓",
p.display(),
ft::human(t.buf.len())
)),
));
saved = Some(p);
}
Err(e) => app.err(format!("save failed: {e}")),
}
}
@@ -624,6 +647,7 @@ fn handle_ft(
}
}
}
saved
}
/// Put the terminal back the way we found it: leave raw mode, leave the
@@ -970,7 +994,42 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
}
}
}
Net::Ft(f) => handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads),
Net::Ft(f) => {
// On a received, SHA-verified file, auto-bridge it into
// a sandbox we host so the whole clergy can use it from
// the shared shell. Runs off the UI thread (docker/mp
// exec + tar stream) and reports back via the app channel.
// A local backend already shares the host fs — nothing to do.
if let Some(path) =
handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads)
{
if let Some((be, name)) = &broker_meta {
if !matches!(be, sbx::Backend::Local) {
let (be, name) = (*be, name.clone());
let run_user = sbx::run_user_for(
be,
app.owner.as_deref().unwrap_or(app.me.as_str()),
);
let tx = app_tx.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::push(be, &name, &run_user, &path)
})
.await;
let _ = match res {
Ok(Ok(dest)) => tx.send(Net::Sys(format!(
"⛧ bridged into sandbox → {dest}"
))),
Ok(Err(e)) => tx.send(Net::Sys(format!(
"(received file not bridged into sandbox: {e})"
))),
Err(e) => tx.send(Net::Err(format!("bridge task: {e}"))),
};
});
}
}
}
}
// The broker renders its sandbox locally from the PTY, so it
// ignores its own echoed status/data; everyone else uses them.
Net::SbxData(b) => {
@@ -980,12 +1039,27 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
}
Net::SbxStatus { .. } if broker.is_some() => {}
ev @ Net::Joined(_) => {
// A late joiner (e.g. a just-summoned agent) missed any
// ACL broadcast sent before they connected. If we host the
// sandbox, re-broadcast so their local grant state syncs —
// this is what makes `/ai start <m> allow` actually reach
// the agent.
// Someone (re)entered and missed everything broadcast
// before they connected. If we host the sandbox, replay
// it so it reappears for them: `status:ready` makes the
// pane show up, the screen snapshot (`_sbx:data`) paints
// its current contents instead of a blank pane, and the
// ACL re-broadcast re-syncs grant state (also what makes
// `/ai start <m> allow` reach a freshly-joined agent).
// The status/data handlers are idempotent, so members
// already in the room aren't disturbed by the replay.
if broker.is_some() {
if let (Some(v), Some((be, _))) = (&app.sandbox, &broker_meta) {
let (rows, cols) = v.parser.screen().size();
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready",
"backend": be.label(),"rows": rows,"cols": cols
}));
let snap = v.parser.screen().contents_formatted();
send_frame(&out_tx, &session.room, json!({
"_sbx":"data","b64": STANDARD.encode(&snap)
}));
}
broadcast_acl(&out_tx, &session.room, &app);
}
app.apply(ev);
@@ -1014,7 +1088,9 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
app.sudoers.insert(app.me.clone()); // owner = superuser
if app.agent_sbx_allow {
// Re-apply a `/ai start … allow` grant the launch reset.
app.drivers.insert(AGENT_NAME.to_string());
if let Some(n) = &app.agent_name {
app.drivers.insert(n.clone());
}
}
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
@@ -1360,14 +1436,28 @@ fn handle_command(
}
}
Some("save") => {
let label = p.next().unwrap_or("snap").to_string();
// `--local` (alias `-l`) also writes a portable, standalone copy
// under hh-snapshots/ (a docker `.tar`) the saver fully owns; the
// default keeps just the in-backend snapshot. The label is the
// first non-flag arg.
let args: Vec<&str> = p.collect();
let local = args.iter().any(|a| matches!(*a, "--local" | "-l"));
let label = args
.iter()
.copied()
.find(|a| !a.starts_with('-'))
.unwrap_or("snap")
.to_string();
if !is_snap_label(&label) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else if let Some((be, name)) = broker_meta.clone() {
app.sys(format!("saving sandbox state as '{label}'…"));
app.sys(format!(
"saving sandbox state as '{label}'{}",
if local { " (+ local copy)" } else { "" }
));
let (tx, lbl) = (app_tx.clone(), label.clone());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label)).await;
let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label, local)).await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!(
"⛧ saved sandbox → {desc} · reload with `/sbx load {lbl}`"))),
@@ -1447,6 +1537,61 @@ fn handle_command(
};
});
}
Some("vmsave") => {
// Snapshot a local VirtualBox VM. `--local` (alias `-l`) also
// exports a portable `.ova` appliance under hh-snapshots/.
let args: Vec<&str> = p.collect();
let local = args.iter().any(|a| matches!(*a, "--local" | "-l"));
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
match pos.next() {
None => app.sys(
"usage: /sbx vmsave <vm> [label] [--local] (snapshot a VirtualBox VM; list VMs with /sbx vms)",
),
Some(vm) => {
let label = pos.next().unwrap_or("snap").to_string();
if !is_snap_label(&label) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else {
app.sys(format!(
"snapshotting VM '{vm}' as '{label}'{}",
if local { " (+ local .ova)" } else { "" }
));
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::vm_save_state(&vm, &label, local)
})
.await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ saved VM → {desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vmsave failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmsave task: {e}"))),
};
});
}
}
}
}
Some("vmsnaps") => {
match p.next() {
None => app.sys("usage: /sbx vmsnaps <vm> (list a VirtualBox VM's snapshots)"),
Some(vm) => {
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || sbx::vm_snapshots(&vm)).await;
let _ = match res {
Ok(Ok(v)) if !v.is_empty() => {
tx.send(Net::Sys(format!("VM snapshots: {}", v.join(", "))))
}
Ok(Ok(_)) => tx.send(Net::Sys(
"no VM snapshots yet — `/sbx vmsave <vm> [label]` to make one".into())),
Ok(Err(e)) => tx.send(Net::Err(format!("vmsnaps: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmsnaps task: {e}"))),
};
});
}
}
}
Some("gui") => {
let gargs: Vec<&str> = p.collect();
let first = gargs.iter().copied().find(|a| !a.starts_with('-'));
@@ -1511,7 +1656,7 @@ fn handle_command(
}
}
_ => app.sys(
"usage: /sbx launch [local|docker|multipass] [image] · gui <vm> · vms · stop · save [label] · load <label> · snaps",
"usage: /sbx launch [local|docker|multipass] [image] · gui <vm> · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmsnaps <vm>",
),
}
} else if let Some(rest) = line.strip_prefix("/unsudo") {
@@ -1582,7 +1727,10 @@ fn handle_command(
app.sys("⛧ dismissed the AI agent");
// Drop any sandbox drive the agent held so a dead handle can't act.
app.agent_sbx_allow = false;
let revoked = app.drivers.remove(AGENT_NAME) | app.sudoers.remove(AGENT_NAME);
let revoked = app
.agent_name
.take()
.is_some_and(|n| app.drivers.remove(&n) | app.sudoers.remove(&n));
if revoked && app.sandbox.is_some() {
broadcast_acl(out_tx, room, app);
}
@@ -1609,12 +1757,6 @@ fn handle_command(
Some(head) if head.is_empty() || head.ends_with(' ') => (head.trim(), true),
_ => (raw, false),
};
// Tolerate a leading agent-name token (`/ai start oracle allow`):
// there's only one agent, so treat it as addressing, not a profile.
let raw = match raw.strip_prefix(AGENT_NAME) {
Some(rest) if rest.is_empty() || rest.starts_with(' ') => rest.trim(),
_ => raw,
};
// A bare name (no ':' tag, no '/' path) is a models.toml profile;
// anything else is treated as a literal Ollama model tag.
let (profile, model): (Option<&str>, &str) = if raw.is_empty() {
@@ -1624,10 +1766,14 @@ fn handle_command(
} else {
(Some(raw), raw)
};
let name = AGENT_NAME;
// Name the agent after what it runs, for clarity in the roster: a
// profile keeps its label; a direct Ollama model uses its tag
// (e.g. "qwen2.5:3b" — model name + parameter size).
let name = profile.unwrap_or(model);
match spawn_agent(params, &app.password, name, profile, model) {
Ok(child) => {
*agent = Some(child);
app.agent_name = Some(name.to_string());
app.agent_sbx_allow = grant_sbx;
let desc = match profile {
Some(p) => format!("profile {p}"),
+25
View File
@@ -88,6 +88,31 @@ fn tar_dir(dir: &Path) -> Result<Vec<u8>> {
Ok(buf)
}
/// Tar any local path (file or directory) to bytes for injecting into a
/// sandbox — the archive's single top-level entry is named after the path's
/// basename, so it extracts as `<dest>/<base>`. Enforces `MAX_SIZE`. Returns
/// `(base_name, tar_bytes)`.
pub fn tar_path(path: &Path) -> Result<(String, Vec<u8>)> {
let meta = std::fs::metadata(path).with_context(|| format!("not found: {}", path.display()))?;
let base = path
.file_name()
.and_then(|s| s.to_str())
.context("path has no final component")?
.to_string();
let mut buf = Vec::new();
{
let mut tb = tar::Builder::new(&mut buf);
if meta.is_dir() {
tb.append_dir_all(&base, path).context("tar directory")?;
} else {
tb.append_path_with_name(path, &base).context("tar file")?;
}
tb.finish()?;
}
anyhow::ensure!(buf.len() <= MAX_SIZE, "too large ({})", human(buf.len()));
Ok((base, buf))
}
/// Persist received bytes under `downloads`. Directories (tar) are extracted
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
+9
View File
@@ -113,6 +113,15 @@ pub async fn connect(session: &Session) -> Result<Ws> {
let (ws, _) = tokio_tungstenite::connect_async(&session.ws_url)
.await
.context("websocket connect")?;
// Disable Nagle's algorithm. Sandbox PTY echo, keystrokes, and chat are all
// small frames; Nagle holds a small segment waiting for more data until the
// prior one is ACKed, and paired with delayed-ACK (~40 ms) — amplified by
// Tailscale's RTT — that is a classic cause of "type, pause, then a burst"
// lag for the non-host viewer. The default (and Tailscale) path is plaintext
// ws, a `Plain` TcpStream, so set TCP_NODELAY there.
if let MaybeTlsStream::Plain(s) = ws.get_ref() {
let _ = s.set_nodelay(true);
}
Ok(ws)
}
+193 -1
View File
@@ -428,6 +428,19 @@ pub fn teardown(backend: Backend, name: &str) {
/// (`hh-snap:<label>`). Owner-side only — never pushed anywhere.
pub const SNAP_REPO: &str = "hh-snap";
/// Directory (relative to the working dir) where portable snapshot artifacts
/// land when the saver opts into a local file copy (`/sbx save … --local`).
/// Created on demand. These are standalone files the owner keeps — they survive
/// even if the in-backend image/snapshot is later pruned.
pub const SNAP_DIR: &str = "hh-snapshots";
/// Ensure `hh-snapshots/` exists and return it. Blocking.
fn snap_dir() -> Result<std::path::PathBuf> {
let dir = std::path::PathBuf::from(SNAP_DIR);
std::fs::create_dir_all(&dir).with_context(|| format!("creating {SNAP_DIR}/"))?;
Ok(dir)
}
/// Snapshot the running sandbox's filesystem state to a named artifact on the
/// owner's machine. Blocking — run off the UI thread.
///
@@ -439,8 +452,15 @@ pub const SNAP_REPO: &str = "hh-snap";
/// error is surfaced verbatim.
/// - **Local:** no backing VM/container, so nothing to save.
///
/// `local` adds a portable, standalone copy under `hh-snapshots/` (a file the
/// owner fully controls) on top of the in-backend snapshot:
/// - **Docker:** `docker save` the committed image to a `.tar`.
/// - **Multipass:** snapshots already live on this host under multipass's own
/// data dir; the CLI has no single-file export, so we note that and skip the
/// extra file rather than fake one.
///
/// Returns a short human description of what was written on success.
pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
pub fn save_state(backend: Backend, name: &str, label: &str, local: bool) -> Result<String> {
match backend {
Backend::Docker => {
let tag = format!("{SNAP_REPO}:{label}");
@@ -455,6 +475,22 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
err.lines().last().unwrap_or("").trim()
);
}
if local {
let path = snap_dir()?.join(format!("hh-snap-{label}.tar"));
let out = Command::new("docker")
.args(["save", &tag, "-o"])
.arg(&path)
.output()
.context("docker save")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!(
"image {tag} committed, but local export failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
return Ok(format!("image {tag} + local file {}", path.display()));
}
Ok(format!("image {tag}"))
}
Backend::Multipass => {
@@ -469,6 +505,13 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
err.lines().last().unwrap_or("").trim()
);
}
if local {
// multipass stores snapshots on this host already; no portable
// single-file export exists in the CLI, so be honest about it.
return Ok(format!(
"snapshot {name}.{label} (already stored locally by multipass; no portable file)"
));
}
Ok(format!("snapshot {name}.{label}"))
}
Backend::Local => {
@@ -477,6 +520,77 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
}
}
/// Snapshot a VirtualBox VM's state. VirtualBox isn't a brokered `Backend` (its
/// GUI runs locally, never relayed), so it has its own save path. Blocking.
///
/// `VBoxManage snapshot <vm> take <label>` works whether the VM is running
/// (live snapshot) or powered off. `local` additionally exports the whole VM to
/// a portable `.ova` appliance under `hh-snapshots/` — the standalone artifact
/// you'd hand to someone else (or re-import yourself) via `/sbx gui`.
pub fn vm_save_state(vm: &str, label: &str, local: bool) -> Result<String> {
let out = Command::new("VBoxManage")
.args(["snapshot", vm, "take", label])
.output()
.context("VBoxManage snapshot take (is VirtualBox installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!(
"VBoxManage snapshot failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
if local {
let path = snap_dir()?.join(format!("{vm}-{label}.ova"));
let out = Command::new("VBoxManage")
.args(["export", vm, "-o"])
.arg(&path)
.output()
.context("VBoxManage export")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!(
"snapshot '{label}' taken, but OVA export failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
return Ok(format!(
"snapshot '{label}' + local appliance {}",
path.display()
));
}
Ok(format!("snapshot '{label}' of {vm}"))
}
/// Snapshot names for a VirtualBox VM (`VBoxManage snapshot <vm> list
/// --machinereadable` → `SnapshotName*="..."` lines). Blocking. An empty list
/// (exit 1 "does not have any snapshots") is reported as no snapshots, not an
/// error.
pub fn vm_snapshots(vm: &str) -> Result<Vec<String>> {
let out = Command::new("VBoxManage")
.args(["snapshot", vm, "list", "--machinereadable"])
.output()
.context("VBoxManage snapshot list (is VirtualBox installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
if err.contains("does not have any snapshots") {
return Ok(Vec::new());
}
anyhow::bail!(
"VBoxManage snapshot list failed: {}",
err.lines().last().unwrap_or("").trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let v = l.strip_prefix("SnapshotName")?;
let q = v.split_once('=')?.1.trim();
Some(q.trim_matches('"').to_string())
})
.filter(|s| !s.is_empty())
.collect())
}
/// List saved snapshot labels for a backend (Docker image tags under
/// `hh-snap`, or multipass snapshots of the instance). Blocking.
pub fn list_snapshots(backend: Backend, name: &str) -> Result<Vec<String>> {
@@ -637,6 +751,84 @@ pub fn set_sudo(backend: Backend, name: &str, user: &str, enable: bool) {
}
}
/// The unix user the shared shell runs as for a backend — mirrors `provision`'s
/// return (owner on Multipass, root on Docker, none on Local) but side-effect
/// free, so callers like file-push can resolve the destination home without
/// threading extra state through the broker.
pub fn run_user_for(backend: Backend, owner: &str) -> String {
match backend {
Backend::Multipass => unix_name(owner),
Backend::Docker => "root".to_string(),
Backend::Local => String::new(),
}
}
/// Inject a local file/dir into the running sandbox, dropping it under the
/// shared shell user's home and leaving it owned by that user. The path is
/// streamed in as a tar (built + size-capped by `ft::tar_path`) and unpacked by
/// `tar` inside the container/VM — uniform for files and directories, and no
/// shell interpolation of the path. Blocking — run off the UI thread. Returns
/// the in-sandbox destination path on success.
pub fn push(
backend: Backend,
name: &str,
run_user: &str,
local: &std::path::Path,
) -> Result<String> {
let (base, tar) = crate::ft::tar_path(local)?;
match backend {
Backend::Local => anyhow::bail!(
"local sandbox shares the host filesystem — {} is already reachable",
local.display()
),
Backend::Docker => {
// Shell runs as root with $HOME=/root (see `prepare`'s `-w /root`).
let mut c = Command::new("docker");
c.args(["exec", "-i", name, "tar", "-C", "/root", "-xf", "-"]);
extract_tar(c, &tar)?;
Ok(format!("/root/{base}"))
}
Backend::Multipass => {
let home = format!("/home/{run_user}");
let mut c = Command::new("multipass");
c.args(["exec", name, "--", "sudo", "tar", "-C", &home, "-xf", "-"]);
extract_tar(c, &tar)?;
// Hand ownership to the account whose login shell the clergy share
// (the tar unpacked as root via sudo).
let owns = format!("{run_user}:{run_user}");
let target = format!("{home}/{base}");
mp(name, &["sudo", "chown", "-R", &owns, &target]);
Ok(target)
}
}
}
/// Feed `tar` bytes to a `tar -x` process over stdin, surfacing the extractor's
/// own last error line on failure.
fn extract_tar(mut cmd: Command, tar: &[u8]) -> Result<()> {
cmd.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.context("spawn tar extractor (is the backend CLI installed?)")?;
{
let mut si = child.stdin.take().context("tar stdin")?;
si.write_all(tar).context("stream tar to sandbox")?;
} // drop closes stdin → tar sees EOF and finishes
let out = child.wait_with_output().context("await tar extractor")?;
anyhow::ensure!(
out.status.success(),
"extract failed: {}",
String::from_utf8_lossy(&out.stderr)
.lines()
.last()
.unwrap_or("")
.trim()
);
Ok(())
}
pub struct Sandbox {
// Held for the PTY's lifetime (dropping it closes the terminal) + resize.
#[allow(dead_code)]
+104 -7
View File
@@ -80,7 +80,6 @@ impl Theme {
// Accent sits at an analogous or complementary offset for contrast.
let accent_hue = (base + *r.pick(&[30.0, 150.0, 180.0, 210.0, 330.0_f32])) % 360.0;
let bg = hsv(base, r.range(0.22, 0.5), r.range(0.06, 0.12)); // deep tinted slate
let border = hsv(base, r.range(0.18, 0.42), r.range(0.40, 0.55));
let accent = hsv(accent_hue, r.range(0.70, 1.0), r.range(0.85, 1.0));
// Title is either the accent or a near-white tint of the base hue.
@@ -94,6 +93,18 @@ impl Theme {
let system = hsv(base, r.range(0.20, 0.45), r.range(0.58, 0.74));
let dim = hsv(base, r.range(0.14, 0.34), r.range(0.50, 0.66));
// The surface is no longer rolled blind: derive it from the ink we just
// rolled so it's *guaranteed* legible yet as expressive as that allows.
// `legible_bg` walks the base-hue slate from near-black upward and keeps
// the lightest tint at which the primary chat ink still clears a contrast
// floor — so brighter palettes earn a richer, more visible background and
// cooler ones settle onto a deeper one, all while staying readable. Only
// the reliably-light inks (`me`, `other`) gate the surface: the muted or
// accent-tinted inks (`title` can be the accent, `system`/`dim` run dark
// by design) would otherwise be impossible to satisfy and collapse the
// background to flat black, so they ride along rather than veto the tint.
let bg = legible_bg(base, r.range(0.30, 0.60), &[me, other]);
Theme {
name: format!("{}-{}", r.pick(&NAME_ADJ), r.pick(&NAME_NOUN)),
border,
@@ -214,6 +225,52 @@ fn hsv(h: f32, s: f32, v: f32) -> Color {
Color::Rgb(to(r), to(g), to(b))
}
/// WCAG relative luminance of a colour, 0.0 (black) 1.0 (white). Channels are
/// linearised (sRGB → linear) before weighting, so it tracks *perceived*
/// brightness rather than raw RGB — what we need to reason about legibility.
fn luminance(c: Color) -> f32 {
let Color::Rgb(r, g, b) = c else { return 0.0 };
let lin = |u: u8| {
let s = u as f32 / 255.0;
if s <= 0.03928 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
}
/// WCAG contrast ratio between two colours (1.0 = identical, 21.0 = black/white).
/// 4.5 is the threshold for comfortable body text, 3.0 for incidental UI chrome.
fn contrast(a: Color, b: Color) -> f32 {
let (la, lb) = (luminance(a), luminance(b));
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
/// Derive a background relationally from the inks rolled for it. Walk the surface
/// in the given `hue`/`sat` family from near-black upward and return the
/// *lightest* value at which every `ink` still clears the 4.5:1 body-text floor.
/// Because contrast only falls as the surface lightens, the lightest passing
/// value is the most expressive background that's still legible. Pass only inks
/// that are light enough to be satisfiable on some surface (e.g. `me`/`other`):
/// an intrinsically dark ink can never clear the floor and would collapse the
/// result to flat black.
fn legible_bg(hue: f32, sat: f32, ink: &[Color]) -> Color {
const FLOOR: f32 = 4.5;
let mut best = hsv(hue, sat, 0.0); // darkest, highest-contrast fallback
let mut v = 0.0_f32;
while v <= 0.45 {
let bg = hsv(hue, sat, v);
if ink.iter().all(|&c| contrast(c, bg) >= FLOOR) {
best = bg; // still legible at this value — keep climbing for a richer tint
}
v += 0.005;
}
best
}
/// Tiny seeded xorshift64* PRNG — enough randomness to roll a fresh palette
/// without pulling in the `rand` crate. Seeded from the wall clock so every
/// `Ctrl+Alt+P` conjures a different vestment.
@@ -293,13 +350,53 @@ roster_width = 24
t.sigil
);
assert_eq!(t.roster_width, 22);
// Surface must stay dark enough to read light ink against it.
if let Color::Rgb(r, g, b) = t.bg {
let sum = r as u16 + g as u16 + b as u16;
assert!(sum < 200, "bg too bright: {r},{g},{b}");
} else {
panic!("random bg should be Rgb");
assert!(matches!(t.bg, Color::Rgb(..)), "random bg should be Rgb");
// The surface is derived from the inks to stay legible: it must never be
// brighter than the text on it, and `me` (always near-white) must clear
// the body-text contrast floor against it.
assert!(
luminance(t.bg) <= luminance(t.me),
"bg should not outshine the ink"
);
assert!(
contrast(t.me, t.bg) >= 4.5,
"your ink must stay readable on the surface: {:.2}",
contrast(t.me, t.bg)
);
}
#[test]
fn legible_bg_is_relational_and_readable() {
// contrast() sanity: identical = 1, black/white = 21.
let white = Color::Rgb(255, 255, 255);
let black = Color::Rgb(0, 0, 0);
assert!((contrast(white, white) - 1.0).abs() < 1e-3);
assert!((contrast(white, black) - 21.0).abs() < 0.1);
// Given bright inks, the surface stays under the 4.5 floor yet isn't
// forced all the way to black — it earns a visible tint.
let inks = [Color::Rgb(0xff, 0xff, 0xff), Color::Rgb(0xd0, 0xd0, 0xd0)];
let bg = legible_bg(210.0, 0.5, &inks);
for ink in inks {
assert!(
contrast(ink, bg) >= 4.5,
"ink unreadable: {:.2}",
contrast(ink, bg)
);
}
assert!(
luminance(bg) > 0.0,
"bright inks should permit a tinted, non-black bg"
);
// A darker ink set forces a correspondingly deeper surface — the
// relationship runs the right way.
let dim_inks = [Color::Rgb(0x88, 0x88, 0x88)];
let dark_bg = legible_bg(210.0, 0.5, &dim_inks);
assert!(
luminance(dark_bg) <= luminance(bg),
"dimmer ink should yield a darker surface"
);
}
#[test]
+9 -3
View File
@@ -155,8 +155,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
),
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
kv(
"/sbx save [label]",
"snapshot state (docker image; survives stop)",
"/sbx save [label] [--local]",
"snapshot state (docker image; --local also writes a portable .tar)",
),
kv(
"/sbx load <label>",
@@ -185,6 +185,11 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
"/sbx gui cancel",
"abort a pending launch (nothing stopped or installed)",
),
kv(
"/sbx vmsave <vm> [label] [--local]",
"snapshot a VM (--local also exports a portable .ova)",
),
kv("/sbx vmsnaps <vm>", "list a VM's snapshots"),
],
},
HelpCluster {
@@ -562,7 +567,8 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
f.render_widget(roster, area);
}
/// Animated "⠋ oracle is thinking…" title shown while AI agents generate a reply.
/// Animated "⠋ <agent> is thinking…" title shown while AI agents generate a
/// reply. The name(s) come from `app.ai_typing`, i.e. each agent's own handle.
fn ai_thinking_title(app: &App) -> String {
const FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
let glyph = FRAMES[(app.spin / 2) % FRAMES.len()];
+13
View File
@@ -0,0 +1,13 @@
name = "oc-demo"
border = "#7C775C"
title = "#FFFBE7"
accent = "#0296E1"
dim = "#A29A72"
me = "#EBEBEC"
other = "#E0D393"
system = "#A49B6D"
input = "#EBEBEC"
roster_me = "#0296E1"
bg = "#12110C"
roster_width = 22
sigil = "✝"
Executable
+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 (where this lives)
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[@]}"
+160
View File
@@ -0,0 +1,160 @@
"""Unit tests for the per-connection-queue ConnectionManager.
These pin the latency-resilience contract: the broadcaster never awaits a
socket, so one slow viewer can't stall the room (the bug behind a sandbox PTY
stream arriving "all at once" for non-host viewers), while per-client ordering
stays FIFO and a hopelessly-backed-up peer is evicted rather than buffered
without bound.
"""
import asyncio
import cmd_chat.server.managers as managers
from cmd_chat.server.managers import ConnectionManager
class FakeWS:
"""Minimal stand-in for a Sanic Websocket: records sends, can be made slow
or gated, and records the close code."""
def __init__(self, delay: float = 0.0, gate: "asyncio.Event | None" = None):
self.sent: list[str] = []
self.delay = delay
self.gate = gate
self.closed: "int | None" = None
async def send(self, msg: str) -> None:
if self.gate is not None:
await self.gate.wait()
if self.delay:
await asyncio.sleep(self.delay)
self.sent.append(msg)
async def close(self, code: int = 1000) -> None:
self.closed = code
async def _drain(timeout: float = 1.0) -> None:
"""Yield control so writer tasks can flush their queues."""
await asyncio.sleep(0)
await asyncio.sleep(0.02)
def test_broadcast_is_fifo_per_client():
async def go():
mgr = ConnectionManager()
a, b = FakeWS(), FakeWS()
await mgr.connect("a", a, initial="INIT_A")
await mgr.connect("b", b, initial="INIT_B")
for i in range(5):
await mgr.broadcast(f"m{i}")
await _drain()
assert a.sent == ["INIT_A", "m0", "m1", "m2", "m3", "m4"]
assert b.sent == ["INIT_B", "m0", "m1", "m2", "m3", "m4"]
await mgr.disconnect("a")
await mgr.disconnect("b")
asyncio.run(go())
def test_exclude_user_is_skipped():
async def go():
mgr = ConnectionManager()
a, b = FakeWS(), FakeWS()
await mgr.connect("a", a)
await mgr.connect("b", b)
await mgr.broadcast("hello", exclude_user="a")
await _drain()
assert a.sent == []
assert b.sent == ["hello"]
await mgr.disconnect("a")
await mgr.disconnect("b")
asyncio.run(go())
def test_slow_client_does_not_block_broadcast():
"""The broadcaster must return promptly even when a peer's socket is slow —
delivery is decoupled via the per-connection writer task."""
async def go():
mgr = ConnectionManager()
slow = FakeWS(delay=0.5)
fast = FakeWS()
await mgr.connect("slow", slow)
await mgr.connect("fast", fast)
start = asyncio.get_event_loop().time()
await mgr.broadcast("x")
elapsed = asyncio.get_event_loop().time() - start
# Enqueue-only: must not wait anywhere near the slow socket's 0.5s.
assert elapsed < 0.1, f"broadcast blocked on slow peer ({elapsed:.3f}s)"
# Fast client gets it almost immediately; slow one lags behind.
await asyncio.sleep(0.05)
assert fast.sent == ["x"]
assert slow.sent == []
await mgr.disconnect("slow")
await mgr.disconnect("fast")
asyncio.run(go())
def test_wedged_client_is_evicted_and_closed(monkeypatch):
"""A peer that overflows its backlog is dropped (closed 1013) instead of
growing memory without bound; other clients are unaffected."""
async def go():
monkeypatch.setattr(managers, "SEND_QUEUE_MAX", 3)
mgr = ConnectionManager()
gate = asyncio.Event() # never set → this peer's send never completes
wedged = FakeWS(gate=gate)
healthy = FakeWS()
await mgr.connect("wedged", wedged)
await mgr.connect("healthy", healthy)
await asyncio.sleep(0) # let each writer pull its first frame
# Flood with a yield between frames: the healthy peer drains each time, so
# it never overflows; the wedged peer's writer is stuck on the gate, so
# its backlog fills (max 3) and then overflows → eviction.
n = 20
for i in range(n):
await mgr.broadcast(f"f{i}")
await asyncio.sleep(0)
await _drain()
assert "wedged" not in mgr.active_connections, "wedged peer must be evicted"
assert wedged.closed == 1013
# The healthy peer was never penalised for its neighbour: still live and
# it received everything (far more than the wedged peer's tiny backlog).
assert "healthy" in mgr.active_connections
assert healthy.sent == [f"f{i}" for i in range(n)]
await mgr.disconnect("healthy")
asyncio.run(go())
def test_reconnect_replaces_old_connection():
async def go():
mgr = ConnectionManager()
first = FakeWS()
await mgr.connect("u", first, initial="INIT1")
second = FakeWS()
await mgr.connect("u", second, initial="INIT2")
await mgr.broadcast("after")
await _drain()
# Only the live (second) connection receives the broadcast.
assert second.sent == ["INIT2", "after"]
assert "after" not in first.sent
assert mgr.active_connections["u"].ws is second
await mgr.disconnect("u")
asyncio.run(go())
def test_send_personal_to_missing_user_is_false():
async def go():
mgr = ConnectionManager()
assert await mgr.send_personal("ghost", "hi") is False
asyncio.run(go())