Compare commits
10 Commits
2d6258cab0
...
8ba04316c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ba04316c1 | |||
| 012b7c15e5 | |||
| 3a90ee985a | |||
| e97a45b78d | |||
| f4572c985e | |||
| 2cd61ee598 | |||
| 4a17f1c6f1 | |||
| 7bd972f39c | |||
| 1c36325d07 | |||
| 35da940cc8 |
@@ -31,32 +31,45 @@ $HH spawn "<objective incl. the full join cmd>" --room-host … --go --skip-perm
|
||||
## Mobile operator on Android / Termux — the phone is REACHABLE, use it
|
||||
|
||||
Spec: `docs/termux-operator.md`. The operator side is pure Python and runs on
|
||||
the **Fairphone-6** in Termux. You can drive that device directly over SSH — so
|
||||
**verify reachability before ever telling the user "this can only run on-device,
|
||||
paste it back."** That framing is wrong when the phone is a live SSH host.
|
||||
the **Fairphone-6** in Termux — it can both **join** and **host** rooms (the SRP
|
||||
shim now covers the server side too, so no Rust / no `srp` C-ext). You can drive
|
||||
the device directly over SSH — so **verify reachability before ever telling the
|
||||
user "this can only run on-device, paste it back."** That framing is wrong when
|
||||
the phone answers SSH.
|
||||
|
||||
One canonical, hardened invocation (use this prefix everywhere — it sidesteps
|
||||
the wrong-alias trap and never hangs):
|
||||
```bash
|
||||
# Fairphone-6 Termux over Tailscale (verified 2026-07-07). sshd on port 8022.
|
||||
ssh -i ~/.ssh/phone-deploy -p 8022 u0_a203@100.95.202.68
|
||||
PHONE='ssh -i ~/.ssh/phone-deploy -p 8022 -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=accept-new u0_a203@100.95.202.68'
|
||||
$PHONE 'uname -m; python --version' # aarch64 / Python 3.13.x
|
||||
```
|
||||
|
||||
- Tailscale device `fairphone-6` = **`100.95.202.68`**, port **8022**, user
|
||||
**`u0_a203`**, key **`~/.ssh/phone-deploy`**. Confirm it's up first:
|
||||
`tailscale status | grep fairphone` and a TCP probe of `:8022`.
|
||||
- **Reachability gate — probe the PORT, not `tailscale status`:**
|
||||
`timeout 8 bash -c 'cat </dev/null >/dev/tcp/100.95.202.68/8022' && echo REACHABLE || echo OFFLINE`.
|
||||
Tailscale reports `fairphone-6` **`active` even when sshd is dead** — Android
|
||||
kills backgrounded Termux, taking `sshd` with it. The tailnet line is NOT
|
||||
proof; the TCP probe is. If OFFLINE, the fix is **on-device** (wake the phone,
|
||||
relaunch Termux, `termux-wake-lock` + start `sshd`) — say so, don't retry a
|
||||
dead port.
|
||||
- **Trap:** the `~/.ssh/config` aliases `phone` / `phone-deploy` point at
|
||||
`100.95.180.14` (`trilluminati`), a *different, usually-offline* phone —
|
||||
`ssh phone` will time out. Use the Fairphone IP above, not the alias.
|
||||
`ssh phone` will time out. Use the Fairphone IP `100.95.202.68` (the `$PHONE`
|
||||
prefix), not the alias.
|
||||
- Termux facts: Python 3.13 (≥3.11); **no `/tmp`** — `$TMPDIR` is
|
||||
`$HOME/.claude-temp`; `$PREFIX=/data/data/com.termux/files/usr`; `rustc`/
|
||||
`openssl` not installed by default.
|
||||
- Deps that work: `pkg install python-cryptography` (avoid a rust build),
|
||||
`pip install requests rich websockets`; **skip `srp`** — the client falls back
|
||||
to `cmd_chat/client/_srp_pure` (pure-Python SRP-6a shim).
|
||||
`pip install requests rich websockets`; **skip `srp`** — both client and
|
||||
server fall back to `cmd_chat/client/_srp_pure` (pure-Python SRP-6a shim).
|
||||
- Push-restricted work: copy this tree to the phone with tar-over-ssh rather
|
||||
than a stale gitea clone —
|
||||
`tar czf - cmd_chat scripts requirements-operator.txt | ssh … 'tar xzf - -C ~/hh-mobile'`.
|
||||
`tar czf - cmd_chat scripts requirements-operator.txt | $PHONE 'tar xzf - -C ~/hh-mobile'`.
|
||||
- Phase-0 gate: `python scripts/termux-preflight.py` on the phone → expect
|
||||
`RESULT: PASS`. Operator files currently staged at `~/hh-mobile` on-device.
|
||||
`RESULT: PASS`. Operator files staged at `~/hh-mobile` on-device.
|
||||
- **Proven live (2026-07-07):** phone JOINS a dev-box room; the phone HOSTS a
|
||||
room the laptop joins (cross-impl SRP: laptop C-ext ↔ phone pure Verifier);
|
||||
and the stdlib mobile web console (`python -m cmd_chat.operator web`) mirrors a
|
||||
real room (terminal + keystroke drive + chat) in a mobile browser.
|
||||
|
||||
## Key components
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ often fails to build under Termux/Android. This module reproduces the *exact*
|
||||
client-side derivation of that package's pure-Python reference (``srp._pysrp``)
|
||||
with ``rfc5054_enable()`` active, using only the standard library.
|
||||
|
||||
Only the surface ``cmd_chat/client/client.py`` consumes is provided:
|
||||
The client surface ``cmd_chat/client/client.py`` consumes:
|
||||
|
||||
rfc5054_enable() # module-level toggle
|
||||
SHA256 # hash-alg constant
|
||||
@@ -15,10 +15,19 @@ Only the surface ``cmd_chat/client/client.py`` consumes is provided:
|
||||
usr.verify_session(H_AMK) # sets authenticated flag
|
||||
usr.authenticated() # -> bool
|
||||
|
||||
The server uses the real ``srp`` library (see cmd_chat/server/srp_auth.py), so
|
||||
every constant, padding rule, and hash input here mirrors ``srp._pysrp`` byte
|
||||
for byte — otherwise the M1 / H_AMK proofs would not match and auth would fail.
|
||||
Correctness is gated by tests/test_srp_pure_interop.py.
|
||||
The server surface ``cmd_chat/server/srp_auth.py`` consumes (so a phone/Termux
|
||||
box with no ``srp`` C-ext can *host* a room, not only join one):
|
||||
|
||||
salt, vkey = create_salted_verification_key(b"chat", pw, hash_alg=SHA256)
|
||||
svr = Verifier(b"chat", salt, vkey, A, hash_alg=SHA256)
|
||||
s, B = svr.get_challenge() # -> (salt, B_bytes)
|
||||
H_AMK = svr.verify_session(M) # -> H_AMK or None
|
||||
K = svr.get_session_key()
|
||||
|
||||
Both sides mirror ``srp._pysrp`` byte for byte — every constant, padding rule,
|
||||
and hash input — otherwise the M1 / H_AMK proofs would not match the real
|
||||
library and auth would fail. Correctness is gated by
|
||||
tests/test_srp_pure_interop.py (pure↔real in both directions).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -334,3 +343,109 @@ class User:
|
||||
def verify_session(self, host_HAMK) -> None:
|
||||
if self.H_AMK == host_HAMK:
|
||||
self._authenticated = True
|
||||
|
||||
|
||||
# ── server side ──────────────────────────────────────────────────────────────
|
||||
def create_salted_verification_key(
|
||||
username,
|
||||
password,
|
||||
hash_alg=SHA1,
|
||||
ng_type=NG_2048,
|
||||
n_hex=None,
|
||||
g_hex=None,
|
||||
salt_len=4,
|
||||
):
|
||||
"""Return ``(salt, vkey)`` for a password — mirrors srp._pysrp exactly.
|
||||
|
||||
``v = g**x mod N`` where ``x = H(salt, H(username:password))``; the salt is
|
||||
``salt_len`` random bytes. This is what the room stores in place of the
|
||||
password (SRP never sees the password on the wire)."""
|
||||
if ng_type == NG_CUSTOM and (n_hex is None or g_hex is None):
|
||||
raise ValueError("Both n_hex and g_hex are required when ng_type = NG_CUSTOM")
|
||||
hash_class = _hash_map[hash_alg]
|
||||
N, g = _get_ng(ng_type, n_hex, g_hex)
|
||||
_s = _long_to_bytes(_get_random(salt_len))
|
||||
_v = _long_to_bytes(pow(g, _gen_x(hash_class, _s, username, password), N))
|
||||
return _s, _v
|
||||
|
||||
|
||||
class Verifier:
|
||||
"""Server side of the SRP-6a exchange, matching srp._pysrp.Verifier's surface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username,
|
||||
bytes_s,
|
||||
bytes_v,
|
||||
bytes_A,
|
||||
hash_alg=SHA1,
|
||||
ng_type=NG_2048,
|
||||
n_hex=None,
|
||||
g_hex=None,
|
||||
bytes_b=None,
|
||||
):
|
||||
if ng_type == NG_CUSTOM and (n_hex is None or g_hex is None):
|
||||
raise ValueError("Both n_hex and g_hex are required when ng_type = NG_CUSTOM")
|
||||
if bytes_b and len(bytes_b) != 32:
|
||||
raise ValueError("32 bytes required for bytes_b")
|
||||
|
||||
self.A = _bytes_to_long(bytes_A)
|
||||
self.M = None
|
||||
self.K = None
|
||||
self.H_AMK = None
|
||||
self._authenticated = False
|
||||
self.I = username
|
||||
self.s = bytes_s
|
||||
self.v = _bytes_to_long(bytes_v)
|
||||
|
||||
N, g = _get_ng(ng_type, n_hex, g_hex)
|
||||
hash_class = _hash_map[hash_alg]
|
||||
k = _bytes_to_long(_H(hash_class, N, g, width=len(_long_to_bytes(N))))
|
||||
|
||||
self.hash_class = hash_class
|
||||
self.N = N
|
||||
self.g = g
|
||||
self.k = k
|
||||
|
||||
# SRP-6a safety check: abort (no challenge) if A is a multiple of N.
|
||||
self.safety_failed = False
|
||||
if (self.A % N) == 0:
|
||||
self.safety_failed = True
|
||||
self.B = None
|
||||
return
|
||||
|
||||
if bytes_b:
|
||||
self.b = _bytes_to_long(bytes_b)
|
||||
else:
|
||||
self.b = _get_random_of_length(32)
|
||||
self.B = (k * self.v + pow(g, self.b, N)) % N
|
||||
self.u = _bytes_to_long(_H(hash_class, self.A, self.B, width=len(_long_to_bytes(N))))
|
||||
self.S = pow(self.A * pow(self.v, self.u, N), self.b, N)
|
||||
self.K = hash_class(_long_to_bytes(self.S)).digest()
|
||||
self.M = _calculate_M(hash_class, N, g, self.I, self.s, self.A, self.B, self.K)
|
||||
self.H_AMK = _calculate_H_AMK(hash_class, self.A, self.M, self.K)
|
||||
|
||||
def authenticated(self) -> bool:
|
||||
return self._authenticated
|
||||
|
||||
def get_username(self):
|
||||
return self.I
|
||||
|
||||
def get_ephemeral_secret(self) -> bytes:
|
||||
return _long_to_bytes(self.b)
|
||||
|
||||
def get_session_key(self):
|
||||
return self.K if self._authenticated else None
|
||||
|
||||
def get_challenge(self):
|
||||
"""Return ``(salt, B_bytes)`` or ``(None, None)`` if the safety check failed."""
|
||||
if self.safety_failed:
|
||||
return None, None
|
||||
return (self.s, _long_to_bytes(self.B))
|
||||
|
||||
def verify_session(self, user_M):
|
||||
"""Check the client proof M; return H_AMK (bytes) on success, else None."""
|
||||
if not self.safety_failed and user_M == self.M:
|
||||
self._authenticated = True
|
||||
return self.H_AMK
|
||||
return None
|
||||
|
||||
@@ -136,6 +136,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
op.add_argument("--cost", type=float, default=5.0)
|
||||
op.add_argument("--session", default=None)
|
||||
|
||||
wb = sub.add_parser("web", help="serve a mobile chat UI in front of the daemon")
|
||||
wb.add_argument("--bind", default="127.0.0.1",
|
||||
help="listen address (default: 127.0.0.1 — loopback only; "
|
||||
"the control socket grants room-send, so keep it local)")
|
||||
wb.add_argument("--port", type=int, default=8790, help="listen port (default: 8790)")
|
||||
wb.add_argument("--session", default=None)
|
||||
|
||||
sc = sub.add_parser("screen", help="print the relayed sandbox terminal buffer")
|
||||
sc.add_argument("--tail", type=int, default=None, help="last N bytes only")
|
||||
sc.add_argument("--ansi", action="store_true", help="keep ANSI codes (raw)")
|
||||
@@ -560,6 +567,11 @@ def _run_registry(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _run_web(args) -> int:
|
||||
from .web import serve
|
||||
return serve(args.session, args.bind, args.port)
|
||||
|
||||
|
||||
def _run_simple(args, op: str) -> int:
|
||||
resp = _client_request(args, {"op": op})
|
||||
print(json.dumps(resp))
|
||||
@@ -599,6 +611,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return _run_screen(args)
|
||||
if verb == "watch":
|
||||
return _run_watch(args)
|
||||
if verb == "web":
|
||||
return _run_web(args)
|
||||
if verb in ("roster", "status", "down"):
|
||||
return _run_simple(args, verb)
|
||||
return 2
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Mobile hack-house console for the operator bridge (`operator web`).
|
||||
|
||||
A phone-friendly browser mirror of a live hack-house room. The daemon already
|
||||
exposes the room as a local API over its AF_UNIX control socket; this module is
|
||||
a thin, **stdlib-only** HTTP shim in front of it so a browser gets the same
|
||||
things the Rust TUI shows — the shared sandbox terminal, chat, roster — mobile
|
||||
optimised:
|
||||
|
||||
GET / the single-page console (self-contained HTML/JS)
|
||||
GET /api/status proxies {"op":"status"} (connection/roster/driver)
|
||||
GET /api/events?since=N long-poll: proxies {"op":"read","wait":true}
|
||||
POST /api/say {text} proxies {"op":"say"}
|
||||
GET /api/screen?ansi=1 proxies {"op":"screen"} — the relayed shared PTY
|
||||
POST /api/keys {tokens} proxies {"op":"keys"} — drive the shared PTY
|
||||
|
||||
Why this exists: on Termux the tmux `read --wait` loop is read-only and shows
|
||||
none of the sandbox. A browser page gives the terminal view + a real input box
|
||||
+ live updates with zero extra dependencies (no server stack, no JS build).
|
||||
|
||||
Binds 127.0.0.1 by default: the control socket grants room-send/drive rights,
|
||||
so the listener stays loopback-only unless you pass a routable --bind.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from .cli_client import BridgeUnreachable, request
|
||||
from .session import resolve
|
||||
|
||||
# Long-poll window the browser holds open per /api/events call. The socket read
|
||||
# timeout must outlast it (see request(read_timeout=...)).
|
||||
_POLL_TIMEOUT = 25.0
|
||||
|
||||
|
||||
_PAGE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>hack-house · __SESSION__</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font: 14px/1.4 -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0b0e14; color: #cdd6f4; display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
header {
|
||||
padding: 8px 12px; background: #11151f; border-bottom: 1px solid #1f2430;
|
||||
display: flex; align-items: center; gap: 8px; flex: none;
|
||||
}
|
||||
header .dot { width: 9px; height: 9px; border-radius: 50%; background: #f38ba8; flex: none; }
|
||||
header .dot.on { background: #a6e3a1; box-shadow: 0 0 6px #a6e3a1; }
|
||||
header .name { font-weight: 700; letter-spacing: .3px; }
|
||||
header .badge { font-size: 11px; padding: 1px 7px; border-radius: 10px;
|
||||
background: #313244; color: #9399b2; }
|
||||
header .badge.drive { background: #1e3a2a; color: #a6e3a1; }
|
||||
header .roster { color: #7f849c; font-size: 12px; margin-left: auto;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 45%; }
|
||||
.tabs { display: flex; flex: none; background: #11151f; border-bottom: 1px solid #1f2430; }
|
||||
.tabs button { flex: 1; background: none; border: 0; color: #7f849c; padding: 9px;
|
||||
font: inherit; font-weight: 600; border-bottom: 2px solid transparent; }
|
||||
.tabs button.active { color: #89b4fa; border-bottom-color: #89b4fa; }
|
||||
.pane { flex: 1; min-height: 0; display: none; flex-direction: column; }
|
||||
.pane.active { display: flex; }
|
||||
|
||||
/* terminal */
|
||||
#term { flex: 1; margin: 0; padding: 10px; overflow: auto; white-space: pre;
|
||||
font: 12px/1.35 "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
background: #05070c; color: #b9c0d4; }
|
||||
#term .empty { color: #565a70; font-style: italic; }
|
||||
.keys { flex: none; background: #11151f; border-top: 1px solid #1f2430; padding: 6px; }
|
||||
.quick { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 6px; }
|
||||
.quick button { flex: none; background: #1f2430; color: #cdd6f4; border: 1px solid #313244;
|
||||
border-radius: 7px; padding: 6px 9px; font: 12px monospace; }
|
||||
.quick button:active { background: #313244; }
|
||||
.keyrow { display: flex; gap: 6px; }
|
||||
#kbox { flex: 1; background: #05070c; color: #b9c0d4; border: 1px solid #313244;
|
||||
border-radius: 8px; padding: 8px; font: 13px monospace; }
|
||||
#kbox:disabled { opacity: .5; }
|
||||
#ksend { flex: none; background: #89b4fa; color: #0b0e14; border: 0; border-radius: 8px;
|
||||
padding: 0 14px; font: inherit; font-weight: 700; }
|
||||
#ksend:disabled { opacity: .4; }
|
||||
.hint { color: #f9e2af; font-size: 11px; padding: 4px 2px 0; }
|
||||
|
||||
/* chat */
|
||||
#log { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 7px; }
|
||||
.msg { max-width: 82%; padding: 7px 10px; border-radius: 13px; word-wrap: break-word; white-space: pre-wrap; }
|
||||
.msg .who { font-size: 10px; color: #7f849c; margin-bottom: 2px; }
|
||||
.msg.them { background: #1f2430; align-self: flex-start; border-bottom-left-radius: 4px; }
|
||||
.msg.me { background: #3a4a7a; align-self: flex-end; border-bottom-right-radius: 4px; }
|
||||
.msg.addr { box-shadow: 0 0 0 2px #f9e2af inset; }
|
||||
.sys { align-self: center; color: #7f849c; font-size: 11px; font-style: italic; }
|
||||
footer { flex: none; display: flex; gap: 7px; padding: 8px; background: #11151f; border-top: 1px solid #1f2430; }
|
||||
#box { flex: 1; resize: none; background: #05070c; color: #cdd6f4; border: 1px solid #313244;
|
||||
border-radius: 9px; padding: 9px; font: inherit; max-height: 110px; }
|
||||
#send { flex: none; background: #a6e3a1; color: #0b0e14; border: 0; border-radius: 9px;
|
||||
padding: 0 16px; font: inherit; font-weight: 700; }
|
||||
#send:disabled { opacity: .5; }
|
||||
/* ANSI palette */
|
||||
.a30{color:#45475a}.a31{color:#f38ba8}.a32{color:#a6e3a1}.a33{color:#f9e2af}
|
||||
.a34{color:#89b4fa}.a35{color:#f5c2e7}.a36{color:#94e2d5}.a37{color:#bac2de}
|
||||
.a90{color:#585b70}.a91{color:#f38ba8}.a92{color:#a6e3a1}.a93{color:#f9e2af}
|
||||
.a94{color:#89b4fa}.a95{color:#f5c2e7}.a96{color:#94e2d5}.a97{color:#e6edf3}
|
||||
.ab{font-weight:700}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="dot" id="dot"></span>
|
||||
<span class="name">__SESSION__</span>
|
||||
<span class="badge" id="drive">watching</span>
|
||||
<span class="roster" id="roster">—</span>
|
||||
</header>
|
||||
<div class="tabs">
|
||||
<button id="tabTerm" class="active">Terminal</button>
|
||||
<button id="tabChat">Chat</button>
|
||||
</div>
|
||||
|
||||
<div class="pane active" id="paneTerm">
|
||||
<pre id="term"><span class="empty">no shared sandbox yet — a room member runs /sbx and /grants this operator to drive it here.</span></pre>
|
||||
<div class="keys">
|
||||
<div class="quick">
|
||||
<button data-k="enter">⏎ Enter</button>
|
||||
<button data-k="ctrl-c">^C</button>
|
||||
<button data-k="esc">Esc</button>
|
||||
<button data-k="tab">Tab</button>
|
||||
<button data-k="up">↑</button>
|
||||
<button data-k="down">↓</button>
|
||||
<button data-k="ctrl-l">clear</button>
|
||||
<button data-k="ctrl-d">^D</button>
|
||||
</div>
|
||||
<div class="keyrow">
|
||||
<input id="kbox" placeholder="type a command → Send runs it (adds Enter)" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<button id="ksend">Send</button>
|
||||
</div>
|
||||
<div class="hint" id="khint"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="paneChat">
|
||||
<div id="log"></div>
|
||||
<footer>
|
||||
<textarea id="box" rows="1" placeholder="Message the room…" autocomplete="off"></textarea>
|
||||
<button id="send">Send</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ME = "__SESSION__";
|
||||
const $ = id => document.getElementById(id);
|
||||
let since = 0, granted = false, active = "term", termSeq = -1;
|
||||
const seen = new Set();
|
||||
|
||||
/* ── tabs ── */
|
||||
function show(tab) {
|
||||
active = tab;
|
||||
$("tabTerm").classList.toggle("active", tab === "term");
|
||||
$("tabChat").classList.toggle("active", tab === "chat");
|
||||
$("paneTerm").classList.toggle("active", tab === "term");
|
||||
$("paneChat").classList.toggle("active", tab === "chat");
|
||||
}
|
||||
$("tabTerm").onclick = () => show("term");
|
||||
$("tabChat").onclick = () => show("chat");
|
||||
|
||||
/* ── ANSI (SGR) → HTML for the terminal pane ── */
|
||||
function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
||||
function ansiToHtml(raw) {
|
||||
let out = "", open = false, cls = [];
|
||||
const re = /\\x1b\\[([0-9;]*)m|\\x1b\\[[0-9;?]*[A-Za-z]|\\x1b\\][^\\x07]*\\x07/g;
|
||||
let last = 0, m;
|
||||
const flush = t => { if (!t) return; out += esc(t); };
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
flush(raw.slice(last, m.index)); last = re.lastIndex;
|
||||
if (m[1] === undefined) continue; // non-SGR CSI / OSC → drop
|
||||
const codes = m[1] === "" ? [0] : m[1].split(";").map(Number);
|
||||
if (open) { out += "</span>"; open = false; }
|
||||
cls = [];
|
||||
for (const c of codes) {
|
||||
if (c === 0) cls = [];
|
||||
else if (c === 1) cls.push("ab");
|
||||
else if ((c>=30&&c<=37)||(c>=90&&c<=97)) cls.push("a"+c);
|
||||
}
|
||||
if (cls.length) { out += '<span class="'+cls.join(" ")+'">'; open = true; }
|
||||
}
|
||||
flush(raw.slice(last));
|
||||
if (open) out += "</span>";
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── carriage-return overwrite (mini vt) ──
|
||||
A real terminal treats a lone \\r as "cursor to column 0" so readline's
|
||||
in-place prompt redraw (…└─# \\r└─# cmd) overwrites the old line. The DOM
|
||||
instead turns \\r into \\n, stacking the pre-redraw prompt as a phantom
|
||||
extra line. Resolve overwrites per \\n-delimited line, treating ANSI escapes
|
||||
as zero-width (attached to the next column) so colours survive; honour
|
||||
erase-to-EOL (\\x1b[K) so a shrinking redraw leaves no tail chars. */
|
||||
function collapseCR(raw) {
|
||||
const escRe = /^(?:\\x1b\\[[0-9;?]*[ -\\/]*[@-~]|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)|\\x1b[@-Z\\\\-_])/;
|
||||
const elRe = /^\\x1b\\[[02]?K$/;
|
||||
const lines = [];
|
||||
let cells = [], col = 0, pending = "";
|
||||
function flush() {
|
||||
let s = "";
|
||||
for (let k = 0; k < cells.length; k++) s += cells[k] ? (cells[k].pre + cells[k].ch) : "";
|
||||
lines.push(s + pending); pending = ""; cells = []; col = 0;
|
||||
}
|
||||
for (let i = 0; i < raw.length; ) {
|
||||
const m = escRe.exec(raw.slice(i));
|
||||
if (m) {
|
||||
const seq = m[0];
|
||||
if (elRe.test(seq)) cells.length = col; // erase to end of line
|
||||
else pending += seq; // zero-width; ride the next char
|
||||
i += seq.length; continue;
|
||||
}
|
||||
const ch = raw[i++];
|
||||
if (ch === "\\n") flush();
|
||||
else if (ch === "\\r") col = 0; // overwrite from column 0
|
||||
else { cells[col] = {pre: pending, ch: ch}; pending = ""; col++; }
|
||||
}
|
||||
flush();
|
||||
return lines.join("\\n");
|
||||
}
|
||||
|
||||
/* ── terminal poll ── */
|
||||
async function pollScreen() {
|
||||
for (;;) {
|
||||
try {
|
||||
if (active === "term") {
|
||||
const r = await fetch("/api/screen?ansi=1");
|
||||
const d = await r.json();
|
||||
if (d.ok && d.seq !== termSeq) {
|
||||
termSeq = d.seq;
|
||||
const t = $("term");
|
||||
const bottom = t.scrollHeight - t.scrollTop - t.clientHeight < 40;
|
||||
if (d.text && d.text.length) t.innerHTML = ansiToHtml(collapseCR(d.text));
|
||||
if (bottom) t.scrollTop = t.scrollHeight;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
await new Promise(r => setTimeout(r, active === "term" ? 900 : 2000));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── keys ── */
|
||||
async function sendKeys(tokens) {
|
||||
if (!granted) return;
|
||||
try {
|
||||
const r = await fetch("/api/keys", {method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({tokens})});
|
||||
const j = await r.json();
|
||||
if (!j.ok) $("khint").textContent = "keys: " + (j.error || "?");
|
||||
} catch (e) { $("khint").textContent = "keys: offline"; }
|
||||
}
|
||||
document.querySelectorAll(".quick button").forEach(b =>
|
||||
b.onclick = () => sendKeys([b.dataset.k]));
|
||||
function runLine() {
|
||||
const v = $("kbox").value;
|
||||
if (!granted) return;
|
||||
sendKeys(v ? ["text:" + v, "enter"] : ["enter"]);
|
||||
$("kbox").value = "";
|
||||
}
|
||||
$("ksend").onclick = runLine;
|
||||
$("kbox").addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); runLine(); } });
|
||||
|
||||
function setGrant(g) {
|
||||
granted = g;
|
||||
$("drive").textContent = g ? "driver" : "watching";
|
||||
$("drive").classList.toggle("drive", g);
|
||||
$("kbox").disabled = !g; $("ksend").disabled = !g;
|
||||
document.querySelectorAll(".quick button").forEach(b => b.disabled = !g);
|
||||
$("khint").textContent = g ? "" : "read-only — a room owner must /grant " + ME + " to drive the sandbox";
|
||||
}
|
||||
|
||||
/* ── chat ── */
|
||||
const log = $("log"), box = $("box"), send = $("send"), dot = $("dot"), rosterEl = $("roster");
|
||||
function atBottom(){ return log.scrollHeight - log.scrollTop - log.clientHeight < 60; }
|
||||
function addMsg(from, text, mine, addressed) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg " + (mine ? "me" : "them") + (addressed ? " addr" : "");
|
||||
if (!mine){ const w=document.createElement("div"); w.className="who"; w.textContent=from; el.appendChild(w); }
|
||||
el.appendChild(document.createTextNode(text));
|
||||
const stick = atBottom(); log.appendChild(el); if (stick||mine) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
function addSys(text){ const el=document.createElement("div"); el.className="sys"; el.textContent=text;
|
||||
const stick=atBottom(); log.appendChild(el); if(stick) log.scrollTop=log.scrollHeight; }
|
||||
|
||||
function setRoster(u){ rosterEl.textContent = (u||[]).length + " · " + (u||[]).join(", "); }
|
||||
|
||||
function render(ev) {
|
||||
if (seen.has(ev.seq)) return; seen.add(ev.seq);
|
||||
if (ev.kind === "message") addMsg(ev.from, ev.text, false, ev.addressed);
|
||||
else if (ev.kind === "sent") addMsg(ev.from, ev.text, true, false);
|
||||
else if (ev.kind === "roster") setRoster(ev.users);
|
||||
else if (ev.kind === "system") {
|
||||
if (ev.event === "connected") dot.classList.add("on");
|
||||
if (ev.event === "reconnecting") dot.classList.remove("on");
|
||||
addSys("· " + (ev.event||"") + (ev.reason ? " ("+ev.reason+")" : "") + " ·");
|
||||
} else if (ev.kind === "acl") { setGrant(!!ev.granted); addSys("· drive "+(ev.granted?"granted":"revoked")+" ·"); }
|
||||
else if (ev.kind === "sandbox") addSys("· sandbox " + (ev.state||"") + " ·");
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try { const s = await (await fetch("/api/status")).json();
|
||||
dot.classList.toggle("on", !!s.connected);
|
||||
if (s.users) setRoster(s.users);
|
||||
setGrant(!!s.granted);
|
||||
} catch (e) {}
|
||||
}
|
||||
async function pollEvents() {
|
||||
for (;;) {
|
||||
try {
|
||||
const d = await (await fetch("/api/events?since=" + since)).json();
|
||||
for (const ev of (d.events || [])) { since = Math.max(since, ev.seq); render(ev); }
|
||||
} catch (e) { dot.classList.remove("on"); await new Promise(r=>setTimeout(r,2000)); }
|
||||
}
|
||||
}
|
||||
async function doSend() {
|
||||
const text = box.value.trim(); if (!text) return;
|
||||
send.disabled = true;
|
||||
try {
|
||||
const j = await (await fetch("/api/say",{method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({text})})).json();
|
||||
if (j.ok){ box.value=""; box.style.height="auto"; } else addSys("· send failed: "+(j.error||"?")+" ·");
|
||||
} catch(e){ addSys("· send failed (offline) ·"); }
|
||||
send.disabled = false; box.focus();
|
||||
}
|
||||
send.onclick = doSend;
|
||||
box.addEventListener("keydown", e => { if (e.key==="Enter" && !e.shiftKey){ e.preventDefault(); doSend(); } });
|
||||
box.addEventListener("input", () => { box.style.height="auto"; box.style.height=Math.min(box.scrollHeight,110)+"px"; });
|
||||
|
||||
refreshStatus(); pollEvents(); pollScreen();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
session_name = "default" # set on the class before serving
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a): # silence per-request stderr spam
|
||||
pass
|
||||
|
||||
def _sock(self):
|
||||
return resolve(self.session_name).sock_path
|
||||
|
||||
def _send_json(self, obj: dict, code: int = 200) -> None:
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _bridge(self, req: dict, read_timeout: float = 35.0) -> dict:
|
||||
try:
|
||||
return request(self._sock(), req, read_timeout=read_timeout)
|
||||
except BridgeUnreachable as e:
|
||||
return {"ok": False, "error": str(e), "offline": True}
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────
|
||||
def do_GET(self) -> None:
|
||||
url = urlparse(self.path)
|
||||
if url.path in ("/", "/index.html"):
|
||||
page = _PAGE.replace("__SESSION__", resolve(self.session_name).name)
|
||||
body = page.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if url.path == "/api/status":
|
||||
self._send_json(self._bridge({"op": "status"}, read_timeout=8))
|
||||
return
|
||||
if url.path == "/api/screen":
|
||||
q = parse_qs(url.query)
|
||||
ansi = (q.get("ansi") or ["0"])[0] not in ("0", "", "false")
|
||||
tail = q.get("tail")
|
||||
req = {"op": "screen", "ansi": ansi}
|
||||
if tail:
|
||||
req["tail"] = int(tail[0])
|
||||
self._send_json(self._bridge(req, read_timeout=8))
|
||||
return
|
||||
if url.path == "/api/events":
|
||||
q = parse_qs(url.query)
|
||||
since = int((q.get("since") or ["0"])[0])
|
||||
self._send_json(self._bridge(
|
||||
{"op": "read", "since": since, "wait": True, "timeout": _POLL_TIMEOUT},
|
||||
read_timeout=_POLL_TIMEOUT + 5.0))
|
||||
return
|
||||
self._send_json({"ok": False, "error": "not found"}, code=404)
|
||||
|
||||
def _read_body(self) -> dict:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
obj = json.loads(raw.decode())
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def do_POST(self) -> None:
|
||||
url = urlparse(self.path)
|
||||
if url.path == "/api/say":
|
||||
text = str(self._read_body().get("text", "")).strip()
|
||||
if not text:
|
||||
self._send_json({"ok": False, "error": "empty"}, code=400)
|
||||
return
|
||||
self._send_json(self._bridge({"op": "say", "text": text}, read_timeout=15))
|
||||
return
|
||||
if url.path == "/api/keys":
|
||||
tokens = self._read_body().get("tokens")
|
||||
if not isinstance(tokens, list) or not tokens:
|
||||
self._send_json({"ok": False, "error": "no tokens"}, code=400)
|
||||
return
|
||||
self._send_json(self._bridge({"op": "keys", "keys": tokens}, read_timeout=10))
|
||||
return
|
||||
self._send_json({"ok": False, "error": "not found"}, code=404)
|
||||
|
||||
|
||||
def serve(session_name: str, bind: str, port: int) -> int:
|
||||
"""Run the web front-end until Ctrl-C. Returns a process exit code."""
|
||||
sess = resolve(session_name)
|
||||
if not sess.sock_path.exists():
|
||||
print(f"no live bridge for session '{sess.name}' (run `up` first)")
|
||||
return 2
|
||||
_Handler.session_name = session_name
|
||||
httpd = ThreadingHTTPServer((bind, port), _Handler)
|
||||
httpd.daemon_threads = True
|
||||
shown = bind if bind not in ("0.0.0.0", "::") else "127.0.0.1"
|
||||
print(f"operator web console for session '{sess.name}' → http://{shown}:{port} (Ctrl-C to stop)")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nweb console stopped")
|
||||
finally:
|
||||
httpd.server_close()
|
||||
return 0
|
||||
@@ -3,7 +3,10 @@ from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
try:
|
||||
import srp
|
||||
except ImportError: # no aarch64 wheel / C-ext build (Termux) — use the shim
|
||||
from ..client import _srp_pure as srp
|
||||
|
||||
|
||||
srp.rfc5054_enable()
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# hack-house on the phone — mobile quickstart
|
||||
|
||||
Run and drive a hack-house room from the **Fairphone-6** (Termux, Android). This
|
||||
is the *operator's* how-to; the design rationale lives in
|
||||
[`termux-operator.md`](termux-operator.md).
|
||||
|
||||
Two devices are in play:
|
||||
|
||||
- **the phone** — Termux, `aarch64`, Python 3.13, reached over Tailscale as
|
||||
`100.95.202.68` (sshd on **:8022**, user `u0_a203`). Hosts/joins rooms.
|
||||
- **the laptop / dev-box** — pulls captures off the phone, joins phone-hosted
|
||||
rooms, and does any chromium/Whisper-heavy work the phone can't.
|
||||
|
||||
> **Reachability first.** Android kills backgrounded Termux, taking `sshd` with
|
||||
> it — and `tailscale status` still shows the phone `active`. Always probe the
|
||||
> **port**, not the tailnet:
|
||||
> ```bash
|
||||
> timeout 8 bash -c 'cat </dev/null >/dev/tcp/100.95.202.68/8022' \
|
||||
> && echo REACHABLE || echo OFFLINE
|
||||
> ```
|
||||
> If OFFLINE the fix is on-device: wake the phone, open Termux, run
|
||||
> `termux-wake-lock` and start `sshd`. Don't hammer a dead port.
|
||||
|
||||
---
|
||||
|
||||
## 1. One-time on-device setup
|
||||
|
||||
**Step 1 — sync the tree** onto the phone (tar-over-ssh from the laptop; no gitea
|
||||
clone needed if the branch is unpushed):
|
||||
|
||||
```bash
|
||||
# from the laptop, in this repo
|
||||
tar czf - cmd_chat scripts requirements-operator.txt \
|
||||
| ssh -i ~/.ssh/phone-deploy -p 8022 u0_a203@100.95.202.68 'mkdir -p ~/hh-mobile && tar xzf - -C ~/hh-mobile'
|
||||
```
|
||||
|
||||
**Step 2 — run the bootstrap** in Termux on the phone. It installs deps
|
||||
(`python-cryptography` to dodge a rust build; pure/wheeled pip deps; **no** `srp`
|
||||
C-ext — the shim covers it), holds a wake-lock, installs the `hh` alias, and runs
|
||||
the Phase-0 preflight. Idempotent — safe to re-run.
|
||||
|
||||
```bash
|
||||
# in Termux, on the phone
|
||||
bash ~/hh-mobile/scripts/termux-bootstrap.sh
|
||||
source ~/.bashrc # activate the 'hh' alias
|
||||
```
|
||||
|
||||
Expect the preflight to end `RESULT: PASS`. (Manual equivalent, if you'd rather:
|
||||
`pkg install python tmux git python-cryptography openssh` · `pip install requests
|
||||
rich websockets` · `termux-wake-lock` · add `alias hh='bash
|
||||
"$HOME/hh-mobile/scripts/hh"'` to `~/.bashrc`.)
|
||||
|
||||
---
|
||||
|
||||
## 2. The `hh` launcher
|
||||
|
||||
Everything runs in a persistent tmux session named `hh`, so it survives an SSH
|
||||
drop. `HH_PY` defaults to `python`; override any config via env (see the header
|
||||
of `scripts/hh`).
|
||||
|
||||
```bash
|
||||
hh host [NAME] [PORT] # host a room ON THE PHONE (default :8799)
|
||||
hh join HOST PORT [NAME] # join an existing room as operator
|
||||
hh web [PORT] # serve the mobile web console (default :8790)
|
||||
hh op <args...> # passthrough to `python -m cmd_chat.operator`
|
||||
hh status # what's running + listening (curl health per room)
|
||||
hh stop [all|host [PORT]|web [PORT]|join]
|
||||
```
|
||||
|
||||
Room windows are **port-scoped** (`host-8799`, `web-8790`), so several rooms
|
||||
coexist — one per directory when paired with direnv below.
|
||||
|
||||
```bash
|
||||
hh host myroom 8799 # → hosting on 0.0.0.0:8799 (password: hh-mobile)
|
||||
hh status # host :8799 UP (health json) · operator daemons
|
||||
hh stop host 8799 # tear down just that room
|
||||
```
|
||||
|
||||
Config env (all optional): `HH_PASSWORD` (hh-mobile), `HH_PORT` (8799),
|
||||
`HH_WEB_PORT` (8790), `HH_NAME` (dir basename), `HH_BIND` (0.0.0.0), `HH_TLS`
|
||||
(1 → keep TLS; default drops it with `--no-tls`).
|
||||
|
||||
## 3. Auto-host a room per directory (direnv)
|
||||
|
||||
Drop an `.envrc` in any project dir so `cd`-ing in auto-hosts a room named after
|
||||
the directory:
|
||||
|
||||
```bash
|
||||
# .envrc
|
||||
export HH_PORT=8801 # optional; override before sourcing
|
||||
source "$HOME/hh-mobile/scripts/hh-direnv.sh"
|
||||
```
|
||||
|
||||
`direnv allow`, then every `cd` in idempotently ensures the room is up (the
|
||||
launcher no-ops if the window already exists). Disable hosting but keep the env
|
||||
with `export HH_AUTOHOST=0`.
|
||||
|
||||
## 4. Mobile web console
|
||||
|
||||
`hh web` serves a stdlib web UI in front of the operator daemon — Terminal + Chat
|
||||
tabs, a quick-keys row (Enter/^C/Esc/Tab/↑/↓/clear/^D), and a command box.
|
||||
|
||||
- On the phone browser: `http://127.0.0.1:8790`
|
||||
- From the laptop: `http://100.95.202.68:8790`
|
||||
|
||||
It's **read-only** until a room owner runs `/grant <name>`; until then keys/exec
|
||||
are inert by design.
|
||||
|
||||
---
|
||||
|
||||
## 5. Capture & share from the phone (`scripts/phone-capture.sh`)
|
||||
|
||||
Runs on the **laptop**; it SSHes into the phone and pulls content back. Add
|
||||
`--tg [target]` to any command to also ship the artifact to Telegram.
|
||||
|
||||
```bash
|
||||
scripts/phone-capture.sh file <remote> [local] # pull a file/dir (tar over ssh)
|
||||
scripts/phone-capture.sh term [tmux-session] # capture tmux pane text (default: hh)
|
||||
scripts/phone-capture.sh webshot <url> [png] # screenshot a URL via headless chromium
|
||||
scripts/phone-capture.sh shot [png] # device screenshot (needs ADB)
|
||||
scripts/phone-capture.sh screen [secs] [mp4] # device screen record (needs ADB)
|
||||
```
|
||||
|
||||
**Non-root routes** (`file`, `term`, `webshot`) work straight from the Termux app
|
||||
uid. Example — grab the live web console as a PNG:
|
||||
|
||||
```bash
|
||||
scripts/phone-capture.sh webshot 'http://100.95.202.68:8790/' --tg
|
||||
```
|
||||
|
||||
**Screen capture** (`shot`/`screen`) can't reach SurfaceFlinger from an app uid,
|
||||
so it needs ADB over **Wireless Debugging** (Settings → System → Developer
|
||||
options). If ADB isn't connected the script prints the one-time pairing steps.
|
||||
|
||||
Env overrides: `PHONE_IP`, `PHONE_PORT`, `PHONE_USER`, `PHONE_KEY`; captures land
|
||||
in `~/coding/_data/phone-captures/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Which Claude skills to ship to the phone
|
||||
|
||||
Lean, self-contained skills that earn their place on a phone (bash/curl/ssh, no
|
||||
chromium, no build step):
|
||||
|
||||
**Ship now:** `hh-operator` · `tg-send`/`tg-direct` (ship captures out) ·
|
||||
`transcribe` (offloads to laptop Whisper over SSH — ideal for a phone) ·
|
||||
`dns-toolkit` · `news-intel` · `session-audit` · `project-map` · `site-audit`.
|
||||
|
||||
**Tether, don't relocate:** `screenshot` (already covered by
|
||||
`phone-capture.sh webshot`), `web-video-grab` (yt-dlp mode only — the Playwright
|
||||
path needs chromium), `youtube-research`/`vault-*`/`imagegen` (laptop-heavy).
|
||||
|
||||
**Skip:** anything GPU/vision, local-infra (gitea-admin, Nuclei/Suricata
|
||||
digests), creds-on-laptop (IMAP/gopass email), or OAO business skills.
|
||||
|
||||
Leanest starter kit: `hh-operator + tg-send + transcribe + dns-toolkit +
|
||||
session-audit + news-intel`.
|
||||
@@ -10,6 +10,10 @@ reverse-SSH documented as alternates.
|
||||
Target device is the Murena Fairphone 6 (Android 15/16 base) running Termux. A
|
||||
future postmarketOS path (full Linux userland) is noted in Appendix C.
|
||||
|
||||
> **Operators:** for the day-to-day how-to (the `hh` launcher, direnv auto-host,
|
||||
> the mobile web console, and pulling captures back to the laptop) see
|
||||
> [`mobile-quickstart.md`](mobile-quickstart.md). This document is the design spec.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this is tractable
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# hh — hack-house mobile launcher (Termux/Fairphone friendly).
|
||||
#
|
||||
# hh host [NAME] [PORT] start a room ON THIS DEVICE (tmux window)
|
||||
# hh join HOST PORT [NAME] join an existing room as an operator (daemon)
|
||||
# hh web [PORT] serve the mobile web console (tmux window)
|
||||
# hh op <operator args...> passthrough to `python -m cmd_chat.operator`
|
||||
# hh status what's running + listening
|
||||
# hh stop [all|host|web|join] tear it down
|
||||
#
|
||||
# Config via env (all optional):
|
||||
# HH_PASSWORD room password (default: hh-mobile)
|
||||
# HH_PORT host port (default: 8799)
|
||||
# HH_WEB_PORT web console port (default: 8790)
|
||||
# HH_NAME room / operator name (default: current dir basename)
|
||||
# HH_BIND host/web bind addr (default: 0.0.0.0 — reachable over Tailscale)
|
||||
# HH_TLS set to 1 to drop --no-tls (default: no-tls)
|
||||
set -euo pipefail
|
||||
|
||||
# --- locate the repo root (this script lives in <root>/scripts/hh) -----------
|
||||
_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(dirname "$_self")"
|
||||
cd "$ROOT"
|
||||
|
||||
PY="${HH_PY:-python}"
|
||||
PW="${HH_PASSWORD:-hh-mobile}"
|
||||
PORT="${HH_PORT:-8799}"
|
||||
WEB_PORT="${HH_WEB_PORT:-8790}"
|
||||
BIND="${HH_BIND:-0.0.0.0}"
|
||||
NAME="${HH_NAME:-$(basename "$ROOT")}"
|
||||
TLS_FLAG="--no-tls"; [ "${HH_TLS:-0}" = "1" ] && TLS_FLAG=""
|
||||
TMUX_SES="hh"
|
||||
OP="$PY -m cmd_chat.operator"
|
||||
|
||||
_die() { echo "hh: $*" >&2; exit 1; }
|
||||
command -v "$PY" >/dev/null || _die "python ('$PY') not found"
|
||||
|
||||
# run a long-lived process in a named tmux window (survives SSH disconnect)
|
||||
_tmux_run() { # <window> <command string>
|
||||
local win="$1"; shift
|
||||
command -v tmux >/dev/null || _die "tmux not found (pkg install tmux)"
|
||||
tmux has-session -t "$TMUX_SES" 2>/dev/null || tmux new-session -d -s "$TMUX_SES" -n main
|
||||
if tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null | grep -qx "$win"; then
|
||||
echo "hh: window '$win' already running (hh stop $win to replace)"; return 0
|
||||
fi
|
||||
tmux new-window -t "$TMUX_SES" -n "$win" "cd '$ROOT'; $*; echo '[$win exited — press enter]'; read"
|
||||
}
|
||||
|
||||
# best-effort Tailscale IP for the "reachable at" hint
|
||||
_ts_ip() { command -v tailscale >/dev/null && tailscale ip -4 2>/dev/null | head -1 || true; }
|
||||
|
||||
cmd="${1:-status}"; shift || true
|
||||
case "$cmd" in
|
||||
host)
|
||||
[ $# -ge 1 ] && NAME="$1"; [ $# -ge 2 ] && PORT="$2"
|
||||
# window name is port-scoped so several rooms (one per direnv dir) coexist
|
||||
_tmux_run "host-$PORT" "$PY cmd_chat.py serve '$BIND' '$PORT' --password '$PW' $TLS_FLAG"
|
||||
ip="$(_ts_ip)"
|
||||
echo "hh: hosting room '$NAME' on $BIND:$PORT (password: $PW)"
|
||||
[ -n "$ip" ] && echo " reachable at: $ip:$PORT"
|
||||
;;
|
||||
join)
|
||||
[ $# -ge 2 ] || _die "usage: hh join HOST PORT [NAME]"
|
||||
local_host="$1"; local_port="$2"; jname="${3:-$NAME}"
|
||||
$OP up "$local_host" "$local_port" "$jname" --password "$PW" $TLS_FLAG
|
||||
echo "hh: joined $local_host:$local_port as '$jname' — use: hh op read --wait / hh op say '...'"
|
||||
;;
|
||||
web)
|
||||
[ $# -ge 1 ] && WEB_PORT="$1"
|
||||
_tmux_run "web-$WEB_PORT" "$OP web --bind '$BIND' --port '$WEB_PORT'"
|
||||
ip="$(_ts_ip)"
|
||||
echo "hh: web console on $BIND:$WEB_PORT"
|
||||
[ -n "$ip" ] && echo " open on phone browser: http://127.0.0.1:$WEB_PORT | from laptop: http://$ip:$WEB_PORT"
|
||||
;;
|
||||
op) exec $OP "$@" ;;
|
||||
status)
|
||||
wins="$(tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null || true)"
|
||||
echo "=== tmux windows (session $TMUX_SES) ==="
|
||||
[ -n "$wins" ] && echo "$wins" | sed 's/^/ /' || echo " (no $TMUX_SES session)"
|
||||
echo "=== rooms (curl health per host-* window, Android-reliable) ==="
|
||||
echo "$wins" | grep '^host-' | while read -r w; do
|
||||
p="${w#host-}"; h="$(curl -s --max-time 3 "http://127.0.0.1:$p/health" 2>/dev/null || true)"
|
||||
[ -n "$h" ] && echo " host :$p UP ($h)" || echo " host :$p down"
|
||||
done
|
||||
echo "$wins" | grep '^web-' | while read -r w; do
|
||||
p="${w#web-}"; c="$(curl -s --max-time 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$p/" 2>/dev/null || true)"
|
||||
[ "$c" = "200" ] && echo " web :$p UP" || echo " web :$p down"
|
||||
done
|
||||
echo "$wins" | grep -qE '^(host|web)-' || echo " (no rooms/web running)"
|
||||
echo "=== operator daemons ($(basename "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}")/hh-bridge) ==="
|
||||
_br="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/hh-bridge"
|
||||
if [ -d "$_br" ] && ls -1 "$_br" >/dev/null 2>&1 && [ -n "$(ls -A "$_br" 2>/dev/null)" ]; then
|
||||
for d in "$_br"/*/; do
|
||||
[ -S "$d/control.sock" ] && echo " $(basename "$d") (socket live)" || echo " $(basename "$d") (stale — no socket)"
|
||||
done
|
||||
else echo " (no operator sessions)"; fi
|
||||
;;
|
||||
stop)
|
||||
what="${1:-all}"; arg="${2:-}"
|
||||
_kill_windows() { # <prefix> [port]
|
||||
local pfx="$1" port="${2:-}"
|
||||
local pat; [ -n "$port" ] && pat="^${pfx}-${port}$" || pat="^${pfx}-"
|
||||
tmux list-windows -t "$TMUX_SES" -F '#W' 2>/dev/null | grep -E "$pat" | while read -r w; do
|
||||
tmux kill-window -t "$TMUX_SES:$w" 2>/dev/null && echo "hh: stopped $w"
|
||||
done
|
||||
}
|
||||
case "$what" in
|
||||
host) _kill_windows host "$arg" ;;
|
||||
web) _kill_windows web "$arg" ;;
|
||||
join) $OP down 2>/dev/null && echo "hh: operator down" || echo "hh: no operator session" ;;
|
||||
all)
|
||||
_kill_windows host; _kill_windows web
|
||||
$OP down 2>/dev/null && echo "hh: operator down" || true ;;
|
||||
*) _die "usage: hh stop [all|host [PORT]|web [PORT]|join]" ;;
|
||||
esac
|
||||
;;
|
||||
-h|--help|help) awk 'NR>1 && /^#/{sub(/^# ?/,"");print;next} NR>1{exit}' "${BASH_SOURCE[0]}" ;;
|
||||
*) _die "unknown command '$cmd' (try: hh help)" ;;
|
||||
esac
|
||||
@@ -0,0 +1,22 @@
|
||||
# hh-direnv.sh — source this from a directory's .envrc to auto-host a
|
||||
# hack-house room whenever you `cd` in (direnv triggers it).
|
||||
#
|
||||
# # .envrc
|
||||
# source "$HOME/hh-mobile/scripts/hh-direnv.sh"
|
||||
#
|
||||
# The room is named after the directory. Override before sourcing:
|
||||
# export HH_PORT=8801 ; export HH_PASSWORD=hunter2
|
||||
# source "$HOME/hh-mobile/scripts/hh-direnv.sh"
|
||||
#
|
||||
# Auto-host is ON by default; disable with `export HH_AUTOHOST=0` (env still set).
|
||||
|
||||
export HH_NAME="${HH_NAME:-$(basename "$PWD")}"
|
||||
export HH_PORT="${HH_PORT:-8799}"
|
||||
export HH_PASSWORD="${HH_PASSWORD:-hh-mobile}"
|
||||
export HH_BIND="${HH_BIND:-0.0.0.0}"
|
||||
|
||||
if [ "${HH_AUTOHOST:-1}" = "1" ]; then
|
||||
# idempotent: the launcher no-ops if the host window already exists
|
||||
bash "$HOME/hh-mobile/scripts/hh" host "$HH_NAME" "$HH_PORT" >/dev/null 2>&1 || true
|
||||
echo "hh: auto-hosting room '$HH_NAME' on :$HH_PORT (HH_AUTOHOST=0 to disable)" >&2
|
||||
fi
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# phone-capture.sh — capture content FROM the Fairphone and pull it to the
|
||||
# laptop. Runs on the LAPTOP/dev-box (it SSHes into the phone). Optionally
|
||||
# ships the result to Telegram via the video-toolkit tg-send.sh.
|
||||
#
|
||||
# phone-capture.sh file <remote> [local] pull a file/dir (tar over ssh)
|
||||
# phone-capture.sh term [tmux-session] capture tmux pane text (default: hh)
|
||||
# phone-capture.sh shot [local.png] device screenshot via ADB *
|
||||
# phone-capture.sh screen [secs] [local.mp4] device screen record via ADB *
|
||||
# phone-capture.sh webshot <url> [local.png] screenshot a URL via headless chromium
|
||||
#
|
||||
# * ADB routes need Wireless Debugging on the phone (Termux can't reach
|
||||
# SurfaceFlinger — app uid, non-root). If not connected, this prints the
|
||||
# one-time pairing steps instead of failing.
|
||||
#
|
||||
# Add --tg [target] to any command to also send the artifact to Telegram
|
||||
# (default target: andre). Env: PHONE_IP, PHONE_PORT, PHONE_USER, PHONE_KEY.
|
||||
set -euo pipefail
|
||||
|
||||
IP="${PHONE_IP:-100.95.202.68}"; SSHPORT="${PHONE_PORT:-8022}"
|
||||
USER_="${PHONE_USER:-u0_a203}"; KEY="${PHONE_KEY:-$HOME/.ssh/phone-deploy}"
|
||||
SSH="ssh -i $KEY -p $SSHPORT -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=accept-new $USER_@$IP"
|
||||
TG_SEND="$HOME/coding/video-toolkit/bin/tg-send.sh"
|
||||
OUTDIR="${HH_CAPTURE_DIR:-$HOME/coding/_data/phone-captures}"
|
||||
mkdir -p "$OUTDIR"
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
_die(){ echo "phone-capture: $*" >&2; exit 1; }
|
||||
# strip a trailing "--tg [target]" off the args, remember it
|
||||
TG=0; TGT="andre"
|
||||
_args=(); while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--tg) TG=1; shift; case "${1:-}" in ""|-*) : ;; *) TGT="$1"; shift;; esac ;;
|
||||
*) _args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "${_args[@]:-}"
|
||||
|
||||
_reachable(){ timeout 8 bash -c "cat </dev/null >/dev/tcp/$IP/$SSHPORT" 2>/dev/null; }
|
||||
_ship(){ # <file>
|
||||
[ "$TG" = "1" ] || { echo "saved: $1"; return 0; }
|
||||
[ -x "$TG_SEND" ] || _die "tg-send.sh not found at $TG_SEND"
|
||||
echo "→ Telegram ($TGT): $1"; "$TG_SEND" "$1" "$TGT" "phone capture $TS"
|
||||
}
|
||||
# first fully-authorized adb device serial (wireless debugging uses a dynamic
|
||||
# port, so don't assume :5555 — read whatever 'adb devices' actually shows)
|
||||
_adb_serial(){ adb devices 2>/dev/null | awk '$2=="device"{print $1; exit}'; }
|
||||
_adb_ready(){ [ -n "$(_adb_serial)" ]; }
|
||||
_adb_help(){
|
||||
cat >&2 <<EOF
|
||||
ADB not connected. One-time setup on the phone (ports are shown on-device;
|
||||
the IP can be the Tailscale IP $IP since adbd binds all interfaces):
|
||||
1. Settings → System → Developer options → Wireless debugging: ON
|
||||
2. Tap "Pair device with pairing code" — note the PAIR_PORT and 6-digit code
|
||||
3. On the laptop: adb pair $IP:<PAIR_PORT> (enter the code)
|
||||
4. Then: adb connect $IP:<DEBUG_PORT> (port under "Wireless debugging")
|
||||
Re-run this command once 'adb devices' shows a 'device' line.
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
file)
|
||||
[ $# -ge 1 ] || _die "usage: file <remote> [local]"
|
||||
rem="$1"; loc="${2:-$OUTDIR/$(basename "$rem")}"
|
||||
_reachable || _die "phone offline (TCP $IP:$SSHPORT). Wake it, ensure sshd + termux-wake-lock."
|
||||
# $rem substituted UNQUOTED so the phone's shell expands a leading ~ (avoid spaces in remote paths)
|
||||
$SSH "cd \"\$(dirname $rem)\" && tar czf - \"\$(basename $rem)\"" | tar xzf - -C "$(dirname "$loc")"
|
||||
echo "pulled $rem → $loc"; _ship "$loc"
|
||||
;;
|
||||
term)
|
||||
ses="${1:-hh}"; loc="$OUTDIR/phone-term-$ses-$TS.txt"
|
||||
_reachable || _die "phone offline (TCP $IP:$SSHPORT)."
|
||||
$SSH "tmux capture-pane -t '$ses' -p -S -2000 2>/dev/null" > "$loc" || _die "no tmux session '$ses' on phone"
|
||||
echo "captured tmux '$ses' → $loc ($(wc -l < "$loc") lines)"; _ship "$loc"
|
||||
;;
|
||||
shot)
|
||||
loc="${1:-$OUTDIR/phone-shot-$TS.png}"
|
||||
_adb_ready || { _adb_help; exit 1; }
|
||||
dev="$(_adb_serial)"
|
||||
adb -s "$dev" exec-out screencap -p > "$loc"
|
||||
[ -s "$loc" ] || _die "screencap produced no data"
|
||||
echo "device screenshot → $loc"; _ship "$loc"
|
||||
;;
|
||||
screen)
|
||||
secs="${1:-15}"; loc="${2:-$OUTDIR/phone-screen-$TS.mp4}"
|
||||
_adb_ready || { _adb_help; exit 1; }
|
||||
dev="$(_adb_serial)"
|
||||
echo "recording ${secs}s of the device screen…"
|
||||
adb -s "$dev" shell screenrecord --time-limit "$secs" /sdcard/hh-rec.mp4
|
||||
adb -s "$dev" pull /sdcard/hh-rec.mp4 "$loc" >/dev/null && adb -s "$dev" shell rm -f /sdcard/hh-rec.mp4
|
||||
echo "device screen recording → $loc"; _ship "$loc"
|
||||
;;
|
||||
webshot)
|
||||
[ $# -ge 1 ] || _die "usage: webshot <url> [local.png]"
|
||||
url="$1"; loc="${2:-$OUTDIR/phone-webshot-$TS.png}"
|
||||
chrome="$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)"
|
||||
[ -n "$chrome" ] || _die "no chromium/chrome found (or use the 'screenshot' Claude skill)"
|
||||
"$chrome" --headless --disable-gpu --hide-scrollbars --window-size=430,932 \
|
||||
--screenshot="$loc" "$url" >/dev/null 2>&1
|
||||
[ -s "$loc" ] || _die "chromium produced no screenshot"
|
||||
echo "web console screenshot ($url) → $loc"; _ship "$loc"
|
||||
;;
|
||||
-h|--help|help|"") awk 'NR>1 && /^#/{sub(/^# ?/,"");print;next} NR>1{exit}' "${BASH_SOURCE[0]}" ;;
|
||||
*) _die "unknown command '$cmd' (try: help)" ;;
|
||||
esac
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
# termux-bootstrap.sh — one-shot on-device setup for the hack-house mobile
|
||||
# operator. Run this IN TERMUX on the phone (not on the laptop):
|
||||
#
|
||||
# bash ~/hh-mobile/scripts/termux-bootstrap.sh
|
||||
#
|
||||
# It installs the deps that actually work on aarch64 Termux (cryptography from
|
||||
# the package repo to dodge a rust build; pure/wheeled pip deps; NO `srp` — the
|
||||
# pure-Python shim covers both client and server), holds a wake-lock so Android
|
||||
# doesn't kill sshd, installs the `hh` alias, and runs the Phase-0 preflight.
|
||||
#
|
||||
# Idempotent: safe to re-run. Env: HH_ROOT (default ~/hh-mobile).
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${HH_ROOT:-$HOME/hh-mobile}"
|
||||
BASHRC="$HOME/.bashrc"
|
||||
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
warn(){ printf ' \033[33m!\033[0m %s\n' "$*"; }
|
||||
step(){ printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
|
||||
|
||||
[ -d "$ROOT" ] || { echo "hh-mobile tree not found at $ROOT — sync it first:"; \
|
||||
echo " (laptop) tar czf - cmd_chat scripts requirements-operator.txt | \\"; \
|
||||
echo " ssh -i ~/.ssh/phone-deploy -p 8022 u0_a203@100.95.202.68 'mkdir -p ~/hh-mobile && tar xzf - -C ~/hh-mobile'"; \
|
||||
exit 1; }
|
||||
|
||||
step "Termux packages"
|
||||
# python-cryptography from the repo avoids compiling the rust cryptography wheel
|
||||
for p in python tmux git python-cryptography openssh; do
|
||||
if dpkg -s "$p" >/dev/null 2>&1 || pkg list-installed 2>/dev/null | grep -q "^$p/"; then
|
||||
ok "$p present"
|
||||
else
|
||||
warn "installing $p…"; yes | pkg install "$p" >/dev/null 2>&1 && ok "$p installed" || warn "$p install FAILED (continuing)"
|
||||
fi
|
||||
done
|
||||
|
||||
step "Python operator deps (pure/wheeled — no srp C-ext)"
|
||||
_py_has(){ python -c "import $1" >/dev/null 2>&1; }
|
||||
if _py_has requests && _py_has rich && _py_has websockets; then
|
||||
ok "requests rich websockets importable"
|
||||
else
|
||||
warn "installing requests rich websockets…"
|
||||
pip install --quiet requests rich websockets && ok "pip deps installed" || warn "pip install had errors"
|
||||
fi
|
||||
if python -c "from cryptography.fernet import Fernet; Fernet(Fernet.generate_key())" >/dev/null 2>&1; then
|
||||
ok "cryptography (Fernet) importable"
|
||||
else
|
||||
warn "cryptography import FAILED — run: pkg install python-cryptography"
|
||||
fi
|
||||
|
||||
step "Wake-lock (keep Termux/sshd alive when backgrounded)"
|
||||
if command -v termux-wake-lock >/dev/null 2>&1; then
|
||||
termux-wake-lock && ok "wake-lock held" || warn "wake-lock failed"
|
||||
else
|
||||
warn "termux-wake-lock not found (install Termux:API for it) — Android may kill sshd"
|
||||
fi
|
||||
|
||||
step "hh launcher alias"
|
||||
if grep -q "alias hh=" "$BASHRC" 2>/dev/null; then
|
||||
ok "alias already in $BASHRC"
|
||||
else
|
||||
printf "\n# hack-house mobile launcher\nalias hh='bash \"%s/scripts/hh\"'\n" "$ROOT" >> "$BASHRC"
|
||||
ok "added 'hh' alias to $BASHRC (run: source $BASHRC)"
|
||||
fi
|
||||
|
||||
step "Phase-0 preflight"
|
||||
if [ -f "$ROOT/scripts/termux-preflight.py" ]; then
|
||||
python "$ROOT/scripts/termux-preflight.py" || warn "preflight reported issues (see above)"
|
||||
else
|
||||
warn "scripts/termux-preflight.py not in the synced tree — skipped"
|
||||
fi
|
||||
|
||||
step "Next steps"
|
||||
cat <<EOF
|
||||
source $BASHRC # activate the 'hh' alias now
|
||||
hh host # host a room on this phone (:8799, password hh-mobile)
|
||||
hh web # mobile web console (:8790)
|
||||
hh status # what's running
|
||||
See docs/mobile-quickstart.md for the full grammar.
|
||||
EOF
|
||||
@@ -134,6 +134,48 @@ A room often has an `/ai <name>` Ollama agent. To offload a task, just `say`
|
||||
`/ai <name> !<task>` and read its reply — cheaper than doing trivial work
|
||||
yourself. (Direct operator→Ollama delegation is a later phase.)
|
||||
|
||||
## 5. Mobile / Termux (Fairphone) — the phone is a live SSH host
|
||||
|
||||
The operator is **pure Python** and runs on the Fairphone-6 in Termux — it can
|
||||
both **join** and (with the SRP shim's server side) **host** rooms, no Rust, no
|
||||
`srp` C-ext. Drive the phone directly over SSH. **Never tell the user "run this
|
||||
on-device, paste it back" without probing reachability first** — that framing is
|
||||
wrong when the phone answers SSH.
|
||||
|
||||
**One canonical, hardened invocation** (kills the wrong-alias trap — see below):
|
||||
```bash
|
||||
PHONE='ssh -i ~/.ssh/phone-deploy -p 8022 -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=accept-new u0_a203@100.95.202.68'
|
||||
$PHONE 'uname -m; python --version' # aarch64 / Python 3.13.x
|
||||
```
|
||||
`BatchMode=yes` fails fast instead of hanging on a password prompt;
|
||||
`ConnectTimeout=8` stops indefinite hangs; `accept-new` avoids the first-connect
|
||||
host-key prompt while still pinning after.
|
||||
|
||||
**Reachability gate — probe the PORT, not `tailscale status`:**
|
||||
```bash
|
||||
timeout 8 bash -c 'cat </dev/null >/dev/tcp/100.95.202.68/8022' && echo REACHABLE || echo OFFLINE
|
||||
```
|
||||
`tailscale status` reports `fairphone-6` **`active` even when sshd is dead** —
|
||||
Android kills backgrounded Termux, taking `sshd` with it. So the tailnet line is
|
||||
NOT proof; the TCP probe is. If the gate says OFFLINE the fix is **on-device**
|
||||
(wake the phone, relaunch Termux, `termux-wake-lock` + start `sshd`), not a
|
||||
retry loop from here — say so plainly rather than hammering a dead port.
|
||||
|
||||
**The wrong-alias trap:** `~/.ssh/config` aliases `phone` / `phone-deploy` point
|
||||
at `100.95.180.14` (`trilluminati`), a *different, usually-offline* device.
|
||||
`ssh phone` will time out. Always use the Fairphone IP `100.95.202.68` (the
|
||||
`$PHONE` prefix above), never the alias.
|
||||
|
||||
**Termux env quirks that break naive commands:**
|
||||
- **No `/tmp`** — `$TMPDIR` is `$HOME/.claude-temp`; `$PREFIX` =
|
||||
`/data/data/com.termux/files/usr`.
|
||||
- `rustc`/`openssl` binaries absent by default; `git/clang/make/pkg-config` present.
|
||||
- Deps: `pkg install python-cryptography` (avoids a rust build); `pip install
|
||||
requests rich websockets`; **skip `srp`** → the shim (`cmd_chat/client/
|
||||
_srp_pure`) covers both client and server.
|
||||
- Sync the tree with tar-over-ssh, not a stale clone:
|
||||
`tar czf - cmd_chat scripts | $PHONE 'tar xzf - -C ~/hh-mobile'`.
|
||||
|
||||
## Safety
|
||||
|
||||
- One bad action's blast radius is the container; `local` exec is opt-in only.
|
||||
|
||||
@@ -107,3 +107,82 @@ def test_pure_client_full_http_handshake(test_client):
|
||||
|
||||
assert usr.authenticated(), "server accepted us but H_AMK did not match"
|
||||
assert verify["ws_token"], "no ws_token issued — cannot join the room"
|
||||
|
||||
|
||||
# --- Inverse direction: the pure *Verifier* (server side) --------------------
|
||||
# When the server itself runs on Termux (no srp C-extension — e.g. a room HOSTED
|
||||
# ON THE PHONE) srp_auth.py falls back to pure.Verifier. These tests exercise
|
||||
# that server surface, so a broken constant in the shim's Verifier can't ship.
|
||||
|
||||
|
||||
def test_real_user_authenticates_against_pure_verifier():
|
||||
"""A real srp.User (C-ext client, e.g. a laptop) must authenticate against
|
||||
the pure Verifier — the exact cross-implementation handshake proven live
|
||||
when the laptop joined a phone-hosted room."""
|
||||
salt, vkey = srp.create_salted_verification_key(
|
||||
b"chat", PASSWORD, hash_alg=srp.SHA256
|
||||
)
|
||||
|
||||
usr = srp.User(b"chat", PASSWORD, hash_alg=srp.SHA256)
|
||||
_, A = usr.start_authentication()
|
||||
|
||||
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
|
||||
s, B = svr.get_challenge()
|
||||
assert B is not None, "pure Verifier aborted the SRP-6a safety check"
|
||||
|
||||
M = usr.process_challenge(s, B)
|
||||
assert M is not None, "real client failed the SRP-6a safety checks"
|
||||
|
||||
H_AMK = svr.verify_session(M)
|
||||
assert H_AMK is not None, "pure Verifier rejected the real client's proof M1"
|
||||
|
||||
usr.verify_session(H_AMK)
|
||||
assert usr.authenticated(), "real client rejected the pure Verifier's H_AMK"
|
||||
assert svr.authenticated()
|
||||
# Both sides must derive the identical session key or encryption breaks.
|
||||
assert usr.get_session_key() == svr.get_session_key()
|
||||
|
||||
|
||||
def test_pure_user_authenticates_against_pure_verifier():
|
||||
"""Both ends on Termux (pure client + pure Verifier) — a room hosted and
|
||||
joined entirely without the C-extension must still complete the handshake."""
|
||||
salt, vkey = pure.create_salted_verification_key(
|
||||
b"chat", PASSWORD, hash_alg=pure.SHA256
|
||||
)
|
||||
|
||||
usr = pure.User(b"chat", PASSWORD, hash_alg=pure.SHA256)
|
||||
_, A = usr.start_authentication()
|
||||
|
||||
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
|
||||
s, B = svr.get_challenge()
|
||||
assert B is not None
|
||||
|
||||
M = usr.process_challenge(s, B)
|
||||
assert M is not None
|
||||
|
||||
H_AMK = svr.verify_session(M)
|
||||
assert H_AMK is not None
|
||||
|
||||
usr.verify_session(H_AMK)
|
||||
assert usr.authenticated()
|
||||
assert svr.authenticated()
|
||||
assert usr.get_session_key() == svr.get_session_key()
|
||||
|
||||
|
||||
def test_pure_verifier_rejects_wrong_password():
|
||||
"""Negative control for the pure Verifier: a bad password must never
|
||||
authenticate, proving the positive tests aren't passing trivially."""
|
||||
salt, vkey = srp.create_salted_verification_key(
|
||||
b"chat", PASSWORD, hash_alg=srp.SHA256
|
||||
)
|
||||
|
||||
usr = srp.User(b"chat", b"wrongpassword", hash_alg=srp.SHA256)
|
||||
_, A = usr.start_authentication()
|
||||
|
||||
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
|
||||
s, B = svr.get_challenge()
|
||||
|
||||
M = usr.process_challenge(s, B)
|
||||
assert svr.verify_session(M) is None
|
||||
assert not svr.authenticated()
|
||||
assert svr.get_session_key() is None
|
||||
|
||||
Reference in New Issue
Block a user