Compare commits
6 Commits
2c4a4f9a22
...
9f7ab92270
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f7ab92270 | |||
| 9222fa8ad7 | |||
| 3a54578f0a | |||
| c552ece6b1 | |||
| d6d44128c0 | |||
| 70245cbd9d |
@@ -275,7 +275,8 @@ directly:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python -m cmd_chat.agent <server_ip> <port> \
|
.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)
|
# cloud (opt-in): --provider anthropic --model claude-opus-4-6 (needs ANTHROPIC_API_KEY)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+28
-6
@@ -12,7 +12,8 @@
|
|||||||
# ./bootstrap-ai.sh # baseline setup + Ollama + default model
|
# ./bootstrap-ai.sh # baseline setup + Ollama + default model
|
||||||
# ./bootstrap-ai.sh --release # ...and build the client in release mode
|
# ./bootstrap-ai.sh --release # ...and build the client in release mode
|
||||||
# ./bootstrap-ai.sh --check # report only; install/pull nothing
|
# ./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
|
# ./bootstrap-ai.sh -h | --help # this help
|
||||||
#
|
#
|
||||||
# environment:
|
# environment:
|
||||||
@@ -28,13 +29,15 @@ INSTALLER_URL="https://ollama.com/install.sh"
|
|||||||
RELEASE_ARGS=()
|
RELEASE_ARGS=()
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
ASSUME_YES=0
|
ASSUME_YES=0
|
||||||
|
AI_ONLY=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--release) RELEASE_ARGS+=(--release) ;;
|
--release) RELEASE_ARGS+=(--release) ;;
|
||||||
--check) CHECK_ONLY=1 ;;
|
--check) CHECK_ONLY=1 ;;
|
||||||
--yes|-y) ASSUME_YES=1 ;;
|
--yes|-y) ASSUME_YES=1 ;;
|
||||||
|
--ai-only) AI_ONLY=1 ;;
|
||||||
-h|--help|-help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
-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
|
esac
|
||||||
done
|
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
|
# 0. Baseline setup (venv + server/agent deps + client build). The agent's own
|
||||||
# runtime deps (requests, websockets) are already in requirements.txt, so the
|
# runtime deps (requests, websockets) are already in requirements.txt, so the
|
||||||
# baseline install covers them — this script adds only the model runtime.
|
# baseline install covers them — this script adds only the model runtime.
|
||||||
|
# --no-ai stops bootstrap.sh re-entering this script (it now runs the AI layer
|
||||||
|
# by default). --ai-only skips the baseline entirely (caller already ran it).
|
||||||
|
if [[ $AI_ONLY -ne 1 ]]; then
|
||||||
if [[ $CHECK_ONLY -eq 1 ]]; then
|
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||||||
"$ROOT/bootstrap.sh" --check || exit $?
|
"$ROOT/bootstrap.sh" --no-ai --check || exit $?
|
||||||
else
|
else
|
||||||
"$ROOT/bootstrap.sh" "${RELEASE_ARGS[@]}" || exit $?
|
"$ROOT/bootstrap.sh" --no-ai "${RELEASE_ARGS[@]}" || exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -95,10 +102,25 @@ if ! ollama_up; then
|
|||||||
echo " ✓ daemon up"
|
echo " ✓ daemon up"
|
||||||
fi
|
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
|
if ollama list 2>/dev/null | awk 'NR>1{print $1}' | grep -Fxq "$MODEL"; then
|
||||||
echo " ✓ model '$MODEL' already present"
|
echo " ✓ model '$MODEL' already present"
|
||||||
else
|
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)…"
|
echo " pulling model '$MODEL' (first pull can take a while)…"
|
||||||
ollama pull "$MODEL" || { echo " ✖ failed to pull '$MODEL'" >&2; exit 1; }
|
ollama pull "$MODEL" || { echo " ✖ failed to pull '$MODEL'" >&2; exit 1; }
|
||||||
echo " ✓ model '$MODEL' ready"
|
echo " ✓ model '$MODEL' ready"
|
||||||
@@ -107,6 +129,6 @@ fi
|
|||||||
echo
|
echo
|
||||||
echo "AI ready. start a local agent against a running room with:"
|
echo "AI ready. start a local agent against a running room with:"
|
||||||
echo " .venv/bin/python -m cmd_chat.agent <host> <port> \\"
|
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
|
||||||
echo "tip: pick a different model with HH_AI_MODEL=llama3 ./bootstrap-ai.sh"
|
echo "tip: pick a different model with HH_AI_MODEL=llama3 ./bootstrap-ai.sh"
|
||||||
|
|||||||
+28
-3
@@ -5,11 +5,18 @@
|
|||||||
# for the server, installs its dependencies, and builds the Rust client.
|
# for the server, installs its dependencies, and builds the Rust client.
|
||||||
#
|
#
|
||||||
# usage:
|
# 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 --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 --check # only report what's installed; change nothing
|
||||||
# ./bootstrap.sh -h | --help # this help
|
# ./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
|
# After it finishes, spin up a local test session with: cd hh && ./lets-hack.sh
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -19,12 +26,16 @@ HH_DIR="$ROOT/hh"
|
|||||||
|
|
||||||
RELEASE=0
|
RELEASE=0
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
|
DO_AI=1
|
||||||
|
ASSUME_YES=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--release) RELEASE=1 ;;
|
--release) RELEASE=1 ;;
|
||||||
--check) CHECK_ONLY=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 ;;
|
-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
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -73,8 +84,22 @@ else
|
|||||||
echo " ✓ client built: hh/target/debug/hack-house"
|
echo " ✓ client built: hh/target/debug/hack-house"
|
||||||
fi
|
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
|
||||||
echo "ready. next steps:"
|
echo "ready. next steps:"
|
||||||
echo " cd hh && ./lets-hack.sh # local test session (server + clients in tmux)"
|
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 " # 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
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Examples
|
Examples
|
||||||
--------
|
--------
|
||||||
# local Ollama (default, recommended)
|
# local Ollama (default, recommended) — joins as "qwen2.5:3b"
|
||||||
python -m cmd_chat.agent 127.0.0.1 3000 --name oracle \
|
python -m cmd_chat.agent 127.0.0.1 3000 \
|
||||||
--password hunter2 --model qwen2.5:3b --no-tls
|
--password hunter2 --model qwen2.5:3b --no-tls
|
||||||
|
|
||||||
# cloud, opt-in
|
# 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("server", nargs="?", help="room host (omit with --list-models/--check)")
|
||||||
ap.add_argument("port", type=int, nargs="?", help="room port")
|
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("--password", default=None, help="room password")
|
||||||
ap.add_argument("--provider", default="ollama",
|
ap.add_argument("--provider", default="ollama",
|
||||||
help="ollama | anthropic | openai | module:Class")
|
help="ollama | anthropic | openai | module:Class")
|
||||||
@@ -184,8 +185,11 @@ def main() -> None:
|
|||||||
if code_provider is not None:
|
if code_provider is not None:
|
||||||
print(f"sandbox/code path → {code_provider.name}/{code_provider.model}", file=sys.stderr)
|
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(
|
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,
|
password=args.password, insecure=args.insecure, no_tls=args.no_tls,
|
||||||
system_prompt=args.system, context_window=args.context_window,
|
system_prompt=args.system, context_window=args.context_window,
|
||||||
token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k,
|
token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k,
|
||||||
|
|||||||
@@ -63,6 +63,35 @@ class OllamaProvider:
|
|||||||
opts["num_thread"] = self.num_thread
|
opts["num_thread"] = self.num_thread
|
||||||
return opts
|
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:
|
def complete(self, system: str, messages: list[Msg]) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
@@ -73,7 +102,7 @@ class OllamaProvider:
|
|||||||
+ [{"role": m.role, "content": m.content} for m in messages],
|
+ [{"role": m.role, "content": m.content} for m in messages],
|
||||||
}
|
}
|
||||||
r = requests.post(f"{self.host}/api/chat", json=payload, timeout=self.timeout)
|
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()
|
return (r.json().get("message", {}).get("content") or "").strip()
|
||||||
|
|
||||||
def stream(self, system: str, messages: list[Msg]):
|
def stream(self, system: str, messages: list[Msg]):
|
||||||
@@ -89,7 +118,7 @@ class OllamaProvider:
|
|||||||
}
|
}
|
||||||
with requests.post(f"{self.host}/api/chat", json=payload,
|
with requests.post(f"{self.host}/api/chat", json=payload,
|
||||||
timeout=self.timeout, stream=True) as r:
|
timeout=self.timeout, stream=True) as r:
|
||||||
r.raise_for_status()
|
self._raise_for_status(r)
|
||||||
for line in r.iter_lines():
|
for line in r.iter_lines():
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ def get_client_ip(request: Request) -> str:
|
|||||||
return request.ip
|
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()
|
messages = app.ctx.message_store.get_all()
|
||||||
users = app.ctx.session_store.get_all()
|
users = app.ctx.session_store.get_all()
|
||||||
await ws.send(
|
return json.dumps(
|
||||||
json.dumps(
|
|
||||||
{
|
{
|
||||||
"type": "init",
|
"type": "init",
|
||||||
"messages": [asdict(m) for m in messages],
|
"messages": [asdict(m) for m in messages],
|
||||||
@@ -37,7 +39,6 @@ async def send_state(ws: Websocket, app: Sanic) -> None:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
class RateLimiter:
|
||||||
|
|||||||
+98
-22
@@ -3,41 +3,117 @@ from typing import Optional
|
|||||||
from sanic import Websocket
|
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:
|
class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active_connections: dict[str, Websocket] = {}
|
self.active_connections: dict[str, _Conn] = {}
|
||||||
self._lock = asyncio.Lock()
|
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:
|
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 def disconnect(self, user_id: str) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if user_id in self.active_connections:
|
conn = self.active_connections.pop(user_id, None)
|
||||||
del self.active_connections[user_id]
|
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 def broadcast(self, message: str, exclude_user: Optional[str] = None) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
disconnected = []
|
targets = [
|
||||||
for user_id, connection in list(self.active_connections.items()):
|
(uid, conn)
|
||||||
if exclude_user and user_id == exclude_user:
|
for uid, conn in self.active_connections.items()
|
||||||
continue
|
if not (exclude_user and uid == exclude_user)
|
||||||
try:
|
]
|
||||||
await connection.send(message)
|
# Enqueue is synchronous and fast, so the broadcaster returns immediately
|
||||||
except Exception:
|
# — it never waits on any socket. Only genuinely wedged peers overflow.
|
||||||
disconnected.append(user_id)
|
wedged = [(uid, conn) for uid, conn in targets if not self._enqueue(conn, message)]
|
||||||
|
for uid, conn in wedged:
|
||||||
for user_id in disconnected:
|
await self._drop_wedged(uid, conn)
|
||||||
if user_id in self.active_connections:
|
|
||||||
del self.active_connections[user_id]
|
|
||||||
|
|
||||||
async def send_personal(self, user_id: str, message: str) -> bool:
|
async def send_personal(self, user_id: str, message: str) -> bool:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if connection := self.active_connections.get(user_id):
|
conn = self.active_connections.get(user_id)
|
||||||
try:
|
if conn is None:
|
||||||
await connection.send(message)
|
return False
|
||||||
|
if self._enqueue(conn, message):
|
||||||
return True
|
return True
|
||||||
except Exception:
|
await self._drop_wedged(user_id, conn)
|
||||||
return False
|
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -2,13 +2,27 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
|
import socket
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
from sanic import Sanic, Request, response, Websocket
|
from sanic import Sanic, Request, response, Websocket
|
||||||
from sanic.response import HTTPResponse, json as json_response
|
from sanic.response import HTTPResponse, json as json_response
|
||||||
|
|
||||||
from .models import Message, UserSession
|
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
|
# 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
|
return
|
||||||
|
|
||||||
manager = app.ctx.connection_manager
|
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:
|
try:
|
||||||
await send_state(ws, app)
|
|
||||||
|
|
||||||
# Announce arrival to everyone already present, then a fresh roster.
|
# Announce arrival to everyone already present, then a fresh roster.
|
||||||
await manager.broadcast(
|
await manager.broadcast(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|||||||
@@ -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."
|
1. **Title card** — "Ephemeral by default. Persistent on demand."
|
||||||
2. **Summon** — alice: `/sbx launch docker` → "summoned" sandbox bubble.
|
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`).
|
(the coder model rides along for `!task`).
|
||||||
4. **Build, by the fast model** — alice:
|
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
|
→ agent drives the shared shell; `fib.py` is written and executed; the
|
||||||
sandbox pane shows the Fibonacci output.
|
sandbox pane shows the Fibonacci output.
|
||||||
5. **Freeze it** — alice: `/sbx save buildbox` →
|
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`.
|
- 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.
|
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
|
- Keep `!task` phrasing single-line; the agent's drive output lands in the sandbox
|
||||||
pane, not chat.
|
pane, not chat.
|
||||||
- `/sbx load` refuses if a sandbox is already running — stop first.
|
- `/sbx load` refuses if a sandbox is already running — stop first.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# demo-save-load.sh — PoC harness for the "persistent sandbox" video beat.
|
# demo-save-load.sh — PoC harness for the "persistent sandbox" video beat.
|
||||||
#
|
#
|
||||||
# Flow (see docs/demo-save-load-poc.md):
|
# Flow (see docs/demo-save-load-poc.md):
|
||||||
# session A: /sbx launch docker → /ai start → /grant oracle →
|
# session A: /sbx launch docker → /ai start → /grant qwen2.5:3b →
|
||||||
# /ai oracle !build fib.py & run it → /sbx save buildbox → quit
|
# /ai qwen2.5:3b !build fib.py & run it → /sbx save buildbox → quit
|
||||||
# prove: container purged on quit, but hh-snap:buildbox image survives
|
# prove: container purged on quit, but hh-snap:buildbox image survives
|
||||||
# session B: fresh client → /sbx load buildbox → the model's code is intact
|
# 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
|
snap_evid "$A_SESS" 02-sandbox
|
||||||
|
|
||||||
# ---- 4. spawn the coder agent + grant drive --------------------------------
|
# ---- 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"
|
say "$A_SESS" "/ai start"
|
||||||
wait_for "$A_SESS" 'oracle|online|ollama' 45 && ok "oracle announced" \
|
wait_for "$A_SESS" 'online|ollama|qwen' 45 && ok "agent announced" \
|
||||||
|| note "no 'online' line yet - agent log: ${TMPDIR:-/tmp}/hh-agent-oracle.log"
|
|| note "no 'online' line yet - agent log: ${TMPDIR:-/tmp}/hh-agent-qwen2.5:3b.log"
|
||||||
say "$A_SESS" "/grant oracle"
|
say "$A_SESS" "/grant qwen2.5:3b"
|
||||||
sleep 1
|
sleep 1
|
||||||
snap_evid "$A_SESS" 03-agent
|
snap_evid "$A_SESS" 03-agent
|
||||||
|
|
||||||
# ---- 5. fast model builds code in the sandbox ------------------------------
|
# ---- 5. fast model builds code in the sandbox ------------------------------
|
||||||
step "fast qwen builds /root/fib.py 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.
|
# 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
|
WT=150 wait_cmd docker exec "$CTR" test -s /root/fib.py
|
||||||
NEED='0 1 1 2 3 5 8 13 21 34'
|
NEED='0 1 1 2 3 5 8 13 21 34'
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ wait_for 'summoned|sandbox|ready|online' 60 >/dev/null
|
|||||||
sleep 1.5
|
sleep 1.5
|
||||||
|
|
||||||
# ---- 5. spawn fast qwen agent (auto-grant drive) ---------------------------
|
# ---- 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"
|
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
|
sleep 1.5
|
||||||
|
|
||||||
# ---- 6. the fast model builds code in the sandbox --------------------------
|
# ---- 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
|
# through the PTY. Validate-by-running; retry once; abort before save if it
|
||||||
# still fails (no silent fallback in a film).
|
# still fails (no silent fallback in a film).
|
||||||
step "fast qwen writes /root/fib.py and runs it"
|
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
|
BUILT=0
|
||||||
for attempt in 1 2; do
|
for attempt in 1 2; do
|
||||||
note "build attempt $attempt"
|
note "build attempt $attempt"
|
||||||
|
|||||||
+24
-1
@@ -2,7 +2,30 @@
|
|||||||
# Join the live hack-house room. Usage: ./join.sh <yourname> [host]
|
# Join the live hack-house room. Usage: ./join.sh <yourname> [host]
|
||||||
# local: ./join.sh alice
|
# local: ./join.sh alice
|
||||||
# remote: ./join.sh alice 100.117.177.50
|
# 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}"
|
NAME="${1:-guest}"
|
||||||
HOST="${2:-127.0.0.1}"
|
HOST="${2:-127.0.0.1}"
|
||||||
cd "$(dirname "$0")"
|
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
@@ -53,7 +53,7 @@ environment (override any default):
|
|||||||
SESSION tmux session name (default: hh-test)
|
SESSION tmux session name (default: hh-test)
|
||||||
HOST server bind host (default: 127.0.0.1)
|
HOST server bind host (default: 127.0.0.1)
|
||||||
PORT server port (default: 4173)
|
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)
|
THEME theme name (church | neon | crypt)
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
@@ -79,7 +79,9 @@ DEMO_WT="${HH_DEMO_WORKTREE:-/tmp/hh-demo-$DEMO_BRANCH}"
|
|||||||
SESSION="${SESSION:-hh-test}"
|
SESSION="${SESSION:-hh-test}"
|
||||||
HOST="${HOST:-127.0.0.1}"
|
HOST="${HOST:-127.0.0.1}"
|
||||||
PORT="${PORT:-4173}"
|
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_LOG="/tmp/hh-${SESSION}-server.log"
|
||||||
SRV_PIDFILE="/tmp/hh-${SESSION}-server.pid"
|
SRV_PIDFILE="/tmp/hh-${SESSION}-server.pid"
|
||||||
THEMES_DIR="$HERE/themes"
|
THEMES_DIR="$HERE/themes"
|
||||||
@@ -224,6 +226,7 @@ done
|
|||||||
tmux select-layout -t "$SESSION" tiled >/dev/null
|
tmux select-layout -t "$SESSION" tiled >/dev/null
|
||||||
|
|
||||||
echo "clergy: ${USERS[*]} · session: $SESSION · $HOST:$PORT · vestments: ${THEME:-church (default)}"
|
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"
|
echo "tear down later with: $0 --kill"
|
||||||
|
|
||||||
# 4. land in the clergy. From a plain shell we replace this process with `attach`.
|
# 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
@@ -161,9 +161,6 @@ pub struct PendingVm {
|
|||||||
pub needs_install: bool,
|
pub needs_install: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display handle the summoned Python agent joins under (see `spawn_agent`).
|
|
||||||
const AGENT_NAME: &str = "oracle";
|
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub me: String,
|
pub me: String,
|
||||||
pub lines: Vec<ChatLine>,
|
pub lines: Vec<ChatLine>,
|
||||||
@@ -212,6 +209,11 @@ pub struct App {
|
|||||||
/// (`/ai start <name> allow`). Re-applied in the broker Ready handler, since
|
/// (`/ai start <name> allow`). Re-applied in the broker Ready handler, since
|
||||||
/// launching a sandbox resets the ACL back to just the owner.
|
/// launching a sandbox resets the ACL back to just the owner.
|
||||||
pub agent_sbx_allow: bool,
|
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 {
|
impl App {
|
||||||
@@ -244,6 +246,7 @@ impl App {
|
|||||||
ai_stream: std::collections::HashMap::new(),
|
ai_stream: std::collections::HashMap::new(),
|
||||||
spin: 0,
|
spin: 0,
|
||||||
agent_sbx_allow: false,
|
agent_sbx_allow: false,
|
||||||
|
agent_name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,11 +377,25 @@ impl App {
|
|||||||
cols,
|
cols,
|
||||||
} => {
|
} => {
|
||||||
if ready {
|
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 {
|
self.sandbox = Some(SbxView {
|
||||||
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
|
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
|
||||||
backend: backend.clone(),
|
backend: backend.clone(),
|
||||||
});
|
});
|
||||||
self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive"));
|
self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive"));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.sandbox = None;
|
self.sandbox = None;
|
||||||
self.driving = false;
|
self.driving = false;
|
||||||
@@ -534,11 +551,14 @@ fn handle_ft(
|
|||||||
out: &UnboundedSender<WsMsg>,
|
out: &UnboundedSender<WsMsg>,
|
||||||
room: &Arc<fernet::Fernet>,
|
room: &Arc<fernet::Fernet>,
|
||||||
downloads: &std::path::Path,
|
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 {
|
match f {
|
||||||
ft::Ft::Offer(o) => {
|
ft::Ft::Offer(o) => {
|
||||||
if o.from == app.me {
|
if o.from == app.me {
|
||||||
return; // our own offer echo
|
return None; // our own offer echo
|
||||||
}
|
}
|
||||||
app.sys(format!(
|
app.sys(format!(
|
||||||
"⛧ {} offers {} ({}{}) — /accept or /reject",
|
"⛧ {} offers {} ({}{}) — /accept or /reject",
|
||||||
@@ -604,11 +624,14 @@ fn handle_ft(
|
|||||||
app.err(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!(
|
Ok(p) => {
|
||||||
|
app.sys(format!(
|
||||||
"⛧ saved {} ({}) — verified ✓",
|
"⛧ saved {} ({}) — verified ✓",
|
||||||
p.display(),
|
p.display(),
|
||||||
ft::human(t.buf.len())
|
ft::human(t.buf.len())
|
||||||
)),
|
));
|
||||||
|
saved = Some(p);
|
||||||
|
}
|
||||||
Err(e) => app.err(format!("save failed: {e}")),
|
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
|
/// 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
|
// The broker renders its sandbox locally from the PTY, so it
|
||||||
// ignores its own echoed status/data; everyone else uses them.
|
// ignores its own echoed status/data; everyone else uses them.
|
||||||
Net::SbxData(b) => {
|
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() => {}
|
Net::SbxStatus { .. } if broker.is_some() => {}
|
||||||
ev @ Net::Joined(_) => {
|
ev @ Net::Joined(_) => {
|
||||||
// A late joiner (e.g. a just-summoned agent) missed any
|
// Someone (re)entered and missed everything broadcast
|
||||||
// ACL broadcast sent before they connected. If we host the
|
// before they connected. If we host the sandbox, replay
|
||||||
// sandbox, re-broadcast so their local grant state syncs —
|
// it so it reappears for them: `status:ready` makes the
|
||||||
// this is what makes `/ai start <m> allow` actually reach
|
// pane show up, the screen snapshot (`_sbx:data`) paints
|
||||||
// the agent.
|
// 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 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);
|
broadcast_acl(&out_tx, &session.room, &app);
|
||||||
}
|
}
|
||||||
app.apply(ev);
|
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
|
app.sudoers.insert(app.me.clone()); // owner = superuser
|
||||||
if app.agent_sbx_allow {
|
if app.agent_sbx_allow {
|
||||||
// Re-apply a `/ai start … allow` grant the launch reset.
|
// 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!({
|
send_frame(&out_tx, &session.room, json!({
|
||||||
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
|
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
|
||||||
@@ -1360,14 +1436,28 @@ fn handle_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("save") => {
|
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) {
|
if !is_snap_label(&label) {
|
||||||
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
||||||
} else if let Some((be, name)) = broker_meta.clone() {
|
} 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());
|
let (tx, lbl) = (app_tx.clone(), label.clone());
|
||||||
tokio::spawn(async move {
|
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 {
|
let _ = match res {
|
||||||
Ok(Ok(desc)) => tx.send(Net::Sys(format!(
|
Ok(Ok(desc)) => tx.send(Net::Sys(format!(
|
||||||
"⛧ saved sandbox → {desc} · reload with `/sbx load {lbl}`"))),
|
"⛧ 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") => {
|
Some("gui") => {
|
||||||
let gargs: Vec<&str> = p.collect();
|
let gargs: Vec<&str> = p.collect();
|
||||||
let first = gargs.iter().copied().find(|a| !a.starts_with('-'));
|
let first = gargs.iter().copied().find(|a| !a.starts_with('-'));
|
||||||
@@ -1511,7 +1656,7 @@ fn handle_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => app.sys(
|
_ => 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") {
|
} else if let Some(rest) = line.strip_prefix("/unsudo") {
|
||||||
@@ -1582,7 +1727,10 @@ fn handle_command(
|
|||||||
app.sys("⛧ dismissed the AI agent");
|
app.sys("⛧ dismissed the AI agent");
|
||||||
// Drop any sandbox drive the agent held so a dead handle can't act.
|
// Drop any sandbox drive the agent held so a dead handle can't act.
|
||||||
app.agent_sbx_allow = false;
|
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() {
|
if revoked && app.sandbox.is_some() {
|
||||||
broadcast_acl(out_tx, room, app);
|
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),
|
Some(head) if head.is_empty() || head.ends_with(' ') => (head.trim(), true),
|
||||||
_ => (raw, false),
|
_ => (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;
|
// A bare name (no ':' tag, no '/' path) is a models.toml profile;
|
||||||
// anything else is treated as a literal Ollama model tag.
|
// anything else is treated as a literal Ollama model tag.
|
||||||
let (profile, model): (Option<&str>, &str) = if raw.is_empty() {
|
let (profile, model): (Option<&str>, &str) = if raw.is_empty() {
|
||||||
@@ -1624,10 +1766,14 @@ fn handle_command(
|
|||||||
} else {
|
} else {
|
||||||
(Some(raw), raw)
|
(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) {
|
match spawn_agent(params, &app.password, name, profile, model) {
|
||||||
Ok(child) => {
|
Ok(child) => {
|
||||||
*agent = Some(child);
|
*agent = Some(child);
|
||||||
|
app.agent_name = Some(name.to_string());
|
||||||
app.agent_sbx_allow = grant_sbx;
|
app.agent_sbx_allow = grant_sbx;
|
||||||
let desc = match profile {
|
let desc = match profile {
|
||||||
Some(p) => format!("profile {p}"),
|
Some(p) => format!("profile {p}"),
|
||||||
|
|||||||
@@ -88,6 +88,31 @@ fn tar_dir(dir: &Path) -> Result<Vec<u8>> {
|
|||||||
Ok(buf)
|
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
|
/// Persist received bytes under `downloads`. Directories (tar) are extracted
|
||||||
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
|
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
|
||||||
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ pub async fn connect(session: &Session) -> Result<Ws> {
|
|||||||
let (ws, _) = tokio_tungstenite::connect_async(&session.ws_url)
|
let (ws, _) = tokio_tungstenite::connect_async(&session.ws_url)
|
||||||
.await
|
.await
|
||||||
.context("websocket connect")?;
|
.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)
|
Ok(ws)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+193
-1
@@ -428,6 +428,19 @@ pub fn teardown(backend: Backend, name: &str) {
|
|||||||
/// (`hh-snap:<label>`). Owner-side only — never pushed anywhere.
|
/// (`hh-snap:<label>`). Owner-side only — never pushed anywhere.
|
||||||
pub const SNAP_REPO: &str = "hh-snap";
|
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
|
/// Snapshot the running sandbox's filesystem state to a named artifact on the
|
||||||
/// owner's machine. Blocking — run off the UI thread.
|
/// owner's machine. Blocking — run off the UI thread.
|
||||||
///
|
///
|
||||||
@@ -439,8 +452,15 @@ pub const SNAP_REPO: &str = "hh-snap";
|
|||||||
/// error is surfaced verbatim.
|
/// error is surfaced verbatim.
|
||||||
/// - **Local:** no backing VM/container, so nothing to save.
|
/// - **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.
|
/// 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 {
|
match backend {
|
||||||
Backend::Docker => {
|
Backend::Docker => {
|
||||||
let tag = format!("{SNAP_REPO}:{label}");
|
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()
|
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}"))
|
Ok(format!("image {tag}"))
|
||||||
}
|
}
|
||||||
Backend::Multipass => {
|
Backend::Multipass => {
|
||||||
@@ -469,6 +505,13 @@ pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
|
|||||||
err.lines().last().unwrap_or("").trim()
|
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}"))
|
Ok(format!("snapshot {name}.{label}"))
|
||||||
}
|
}
|
||||||
Backend::Local => {
|
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
|
/// List saved snapshot labels for a backend (Docker image tags under
|
||||||
/// `hh-snap`, or multipass snapshots of the instance). Blocking.
|
/// `hh-snap`, or multipass snapshots of the instance). Blocking.
|
||||||
pub fn list_snapshots(backend: Backend, name: &str) -> Result<Vec<String>> {
|
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 {
|
pub struct Sandbox {
|
||||||
// Held for the PTY's lifetime (dropping it closes the terminal) + resize.
|
// Held for the PTY's lifetime (dropping it closes the terminal) + resize.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
+104
-7
@@ -80,7 +80,6 @@ impl Theme {
|
|||||||
// Accent sits at an analogous or complementary offset for contrast.
|
// 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 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 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));
|
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.
|
// 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 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));
|
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 {
|
Theme {
|
||||||
name: format!("{}-{}", r.pick(&NAME_ADJ), r.pick(&NAME_NOUN)),
|
name: format!("{}-{}", r.pick(&NAME_ADJ), r.pick(&NAME_NOUN)),
|
||||||
border,
|
border,
|
||||||
@@ -214,6 +225,52 @@ fn hsv(h: f32, s: f32, v: f32) -> Color {
|
|||||||
Color::Rgb(to(r), to(g), to(b))
|
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
|
/// Tiny seeded xorshift64* PRNG — enough randomness to roll a fresh palette
|
||||||
/// without pulling in the `rand` crate. Seeded from the wall clock so every
|
/// without pulling in the `rand` crate. Seeded from the wall clock so every
|
||||||
/// `Ctrl+Alt+P` conjures a different vestment.
|
/// `Ctrl+Alt+P` conjures a different vestment.
|
||||||
@@ -293,13 +350,53 @@ roster_width = 24
|
|||||||
t.sigil
|
t.sigil
|
||||||
);
|
);
|
||||||
assert_eq!(t.roster_width, 22);
|
assert_eq!(t.roster_width, 22);
|
||||||
// Surface must stay dark enough to read light ink against it.
|
assert!(matches!(t.bg, Color::Rgb(..)), "random bg should be Rgb");
|
||||||
if let Color::Rgb(r, g, b) = t.bg {
|
// The surface is derived from the inks to stay legible: it must never be
|
||||||
let sum = r as u16 + g as u16 + b as u16;
|
// brighter than the text on it, and `me` (always near-white) must clear
|
||||||
assert!(sum < 200, "bg too bright: {r},{g},{b}");
|
// the body-text contrast floor against it.
|
||||||
} else {
|
assert!(
|
||||||
panic!("random bg should be Rgb");
|
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]
|
#[test]
|
||||||
|
|||||||
+9
-3
@@ -155,8 +155,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
),
|
),
|
||||||
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
||||||
kv(
|
kv(
|
||||||
"/sbx save [label]",
|
"/sbx save [label] [--local]",
|
||||||
"snapshot state (docker image; survives stop)",
|
"snapshot state (docker image; --local also writes a portable .tar)",
|
||||||
),
|
),
|
||||||
kv(
|
kv(
|
||||||
"/sbx load <label>",
|
"/sbx load <label>",
|
||||||
@@ -185,6 +185,11 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
"/sbx gui cancel",
|
"/sbx gui cancel",
|
||||||
"abort a pending launch (nothing stopped or installed)",
|
"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 {
|
HelpCluster {
|
||||||
@@ -562,7 +567,8 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
|
|||||||
f.render_widget(roster, area);
|
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 {
|
fn ai_thinking_title(app: &App) -> String {
|
||||||
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
let glyph = FRAMES[(app.spin / 2) % FRAMES.len()];
|
let glyph = FRAMES[(app.spin / 2) % FRAMES.len()];
|
||||||
|
|||||||
@@ -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
@@ -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[@]}"
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user