Phased plan (P0 dependency spike → P1 read-only presence → P2 sandbox drive → P3 remote-provider autonomy → P4 persistence) for joining a room as a user from Termux. Grounded in the real SRP/Fernet/ws client and the operate provider path. Covers the srp aarch64 build risk + pure-Python SRP fallback, the cryptography pin workaround, and Tailscale / reverse-SSH / port-forward reachability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 KiB
Spec — hack-house operator from Android / Termux
Status: planning · Owner: andre · Drafted: 2026-07-07 Goal: run a hack-house operator/user from a phone (Termux, aarch64 Android) that can join a room as a first-class member — read, chat, and drive a room-owned shared sandbox — with autonomy driven by a remote model provider (no on-device model). Reachability via Tailscale first, with port-forward and 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.
1. Why this is tractable
The operator side is pure Python and, for everything except sbx launch,
needs only a network connection — the shared sandbox lives on the server
host, and the operator drives it by relaying keystrokes/reads over the room
websocket. So Android's lack of podman/systemd/X11 does not block us.
Grounding (verified in-repo):
| Concern | Reality | Source |
|---|---|---|
| Operator↔room transport | SRP-auth HTTP handshake → Fernet-encrypted websocket | cmd_chat/client/client.py:106-157,502-505 |
| Operator reuses the same client | from ..client.client import Client |
cmd_chat/operator/bridge.py:27 |
| TLS is optional | --no-tls (ws/http), --insecure (skip cert verify) |
cmd_chat/client/client.py:41,69-89 |
| Sandbox drive needs no local engine | keys/screen/watch relay frames to the broker-owned PTY |
bridge keys/screen verbs |
Only sbx launch needs podman |
operator's own container path; skip it on Android | bridge sandbox target logic |
| Autonomy via remote model | operate --profile NAME | --provider SPEC --model M |
cmd_chat/operator/__main__.py:113-123,408-436 |
| Model profiles file | models.toml, secrets via api_key_env |
cmd_chat/ai/profiles.py:1-70 |
Non-goals (this spec): running a local LLM on-device; sbx launch /
operator-owned containers on Android; the Rust TUI on Android; recursive
spawn of claude -p on-device (needs Node; deferred, see Phase 3B).
2. Phase 0 — Dependency spike (the risk gate)
Objective: prove the operator's Python deps install & import in Termux on aarch64. This is the make-or-break step; everything else is wiring.
0.1 Base packages
pkg update && pkg upgrade
pkg install python openssl clang rust libffi make git
python -V # expect 3.11+ (gives stdlib tomllib for models.toml)
0.2 Easy deps (pure-Python or aarch64 wheels)
pip install requests rich websockets
python -c "import requests, rich, websockets; print('ok')"
Expected: clean. websockets ships aarch64 wheels; requests/rich are pure
Python.
0.3 The two hard deps
pip install "cryptography" # see 0.4 on the >=46 pin
pip install srp # <-- most likely failure
python -c "import srp; from cryptography.fernet import Fernet; print('ok')"
0.4 cryptography>=46.0.0 pin
requirements.txt pins cryptography>=46.0.0. Two outcomes:
- Rust build succeeds (we installed
rust+openssl) → done. - Build too heavy / OOM on device → install Termux's packaged build and
relax the pin for the operator install only:
If the packaged version is
pkg install python-cryptography python -c "import cryptography, sys; print(cryptography.__version__)"< 46, confirm the operator actually needs the floor. It uses onlycryptography.fernet.Fernet(client.py:12), stable for years — so a lower version is almost certainly fine. Action if needed: add an extras/loosened constraint (e.g.requirements-operator.txtwithcryptography>=41) rather than editing the server's pin. Do not weaken the server requirement.
0.5 srp==1.0.22 — the gate + fallback
srp is a C extension with no reliable aarch64 wheel. If pip install srp
fails to build under Termux, the fix is a pure-Python SRP shim behind the
exact interface client.py already uses.
Interface to preserve (from cmd_chat/client/client.py):
srp.rfc5054_enable() # module-level, line 19
usr = srp.User(b"chat", password, hash_alg=srp.SHA256) # line 106
_, A = usr.start_authentication() # line 107 -> (username, A_bytes)
M = usr.process_challenge(salt, B) # line 134 -> M_bytes (or None on failure)
usr.verify_session(H_AMK) # line 152 -> raises/None on bad server proof
usr.authenticated() # -> bool (guard after verify)
Server endpoints consumed: POST /srp/… (challenge) and POST /srp/verify
(client.py:109,139-157). The wire params are salt, B, A, M, H_AMK,
user_id, ws_token — a pure-Python SRP-6a (RFC 5054 group, SHA-256) must
reproduce the same N/g group, k, x, u, and M1/H_AMK derivation as
the srp package with rfc5054_enable().
Fallback plan (code change, only if 0.5 build fails):
- Vendor a small
cmd_chat/client/_srp_pure.pyimplementing SRP-6a (RFC 5054 2048-bit group, SHA-256) exposing a drop-inrfc5054_enable,User,SHA256, matching the five calls above. - In
client.py,try: import srp/except ImportError: from . import _srp_pure as srp. No other call-site changes. - Interop test is mandatory (server uses the real
srp): run the pure client against a real server room and confirm auth succeeds — SRP is unforgiving about constant/hash mismatches.
Phase 0 acceptance: on the phone, python -c "import srp, websockets, requests, rich; from cryptography.fernet import Fernet; print('ok')" prints
ok (via wheel/build or the pure-Python shim).
3. Phase 1 — Read-only presence (transport proof)
Objective: join a live room from the phone; prove SRP + Fernet + ws over the network. No sandbox.
Layout on device (clone or rsync the repo; operator only needs cmd_chat/):
git clone <gitea>/hacker-house && cd hacker-house
python -m venv .venv && . .venv/bin/activate
pip install requests rich websockets cryptography # + srp or the shim
HH=".venv/bin/python -m cmd_chat.operator"
Join + observe + speak (over Tailscale, --no-tls acceptable — see App. A):
$HH up <server-tailscale-host> <port> phone-op --password <pw> --no-tls
$HH read --wait --timeout 30
$HH say "phone operator online"
$HH roster ; $HH status
$HH down
Acceptance: roster shows phone-op; say appears in the room; read
streams room events as JSONL. Daemon control socket lands under
$XDG_RUNTIME_DIR → in Termux falls back to $PREFIX/tmp/hh-bridge/<session>/
(AF_UNIX works there — verify control.sock is created).
4. Phase 2 — Sandbox drive (core operator loop)
Objective: drive a room-owned sandbox from the phone with no local container engine.
$HH keys "uname -a" enter
$HH screen # read the PTY buffer back
$HH watch --for 'PASS|FAIL' --in screen --timeout 60
$HH exec "ls /root" # only if the room sandbox is co-located & granted
Skip sbx launch (wants podman). If a verb reports "no sandbox / not a
granted driver", that's the grant model working — the room must /grant phone-op first.
Acceptance: keystrokes reach the shared PTY, screen returns output,
watch resolves a stop-condition — all from the phone.
5. Phase 3 — Autonomy via a remote provider
Objective: let a remote model be the operator brain — no on-device inference.
3A (primary) — operate with a remote provider
Create ~/.config/hh/models.toml (secrets via env, never in the file):
[claude]
provider = "anthropic"
model = "claude-sonnet-4-6"
api_key_env = "ANTHROPIC_API_KEY"
[groq-llama]
provider = "openai" # OpenAI-compatible endpoint
base_url = "https://api.groq.com/openai/v1"
model = "llama-3.3-70b-versatile"
api_key_env = "GROQ_API_KEY"
export ANTHROPIC_API_KEY=… # in the Termux session / profile
$HH operate --profile claude # or: $HH operate --provider openai --model … --base-url …
Resolution + a preflight credential/endpoint check happen in
_build_operate_provider (__main__.py:408-436); a bad/missing key fails fast
with a clear message. models.toml lookup order:
$HH_MODELS_FILE → ./models.toml → ~/.config/hh/models.toml
(ai/profiles.py:34-43).
Acceptance: operate --profile claude runs the read→think→act loop against
the room using a cloud model; the phone stays cool (no local weights).
3B (deferred) — recursive spawn
spawn shells out to claude -p (needs Node + @anthropic-ai/claude-code).
Heavy on-device; out of scope for v1. Revisit only if the phone becomes a
persistent autonomous node (or under postmarketOS — App. C).
6. Phase 4 — Persistence & UX on Android
Android Doze/OOM will reap a detached daemon. Mitigations:
termux-wake-lockbeforeup(release withtermux-wake-unlockon stop). Requires the Termux:API addon.- Termux:Boot addon → a boot script that
wake-locks and runs the operator, for auto-start after reboot. - Wrapper script
~/bin/hh-phonecapturing host/port/name/password (from Termux secure storage or achmod 600env file — never commit) →termux-wake-lock; $HH up …; $HH operate --profile claude. - Reconnect-on-drop: mobile networks flap. Confirm the daemon's behaviour on
ws drop; if it exits, wrap
up+operatein a supervised restart loop (backoff). (Potential small code hardening — track separately.) - Battery: on cellular + wake-lock this is a real drain; expect to run it plugged in for long sessions.
Acceptance: operator survives screen-off / Doze for ≥30 min and auto-resumes after a network blip.
Appendix A — Connectivity (primary: Tailscale)
Tailscale (chosen for now). First-class Termux support. Put phone + server on one tailnet; the server gets a stable MagicDNS name reachable from anywhere.
- Pros: zero port-forwarding, NAT-traversal handled, WireGuard-encrypted, ACLs.
- TLS: over the tailnet the link is already encrypted →
--no-tlsis acceptable (SRP still authenticates and Fernet still encrypts room payloads end-to-end). For belt-and-suspenders, use Tailscale MagicDNS + a cert and drop--no-tls. - Setup: install Tailscale on phone + server,
tailscale upboth, then$HH up <server-magicdns> <port> ….
Appendix B — Connectivity alternates
B1. Port-forward (router). Forward server_port on the home router to the
server host.
- Pros: no extra software; direct.
- Cons: exposes the port to the public internet — must use
wss://(real cert, drop--no-tls), a strong SRP password, and ideally an allowlist/geo filter or fail2ban. Dynamic home IP → needs DDNS. Weakest security posture; use only with TLS + strong auth.
B2. Reverse-SSH tunnel. Server opens an outbound SSH tunnel to a VPS that has a public IP; phone connects to the VPS:
# on the server (autossh keeps it up):
autossh -M 0 -N -R 0.0.0.0:8443:localhost:<server_port> user@vps
# phone → $HH up <vps-host> 8443 …
- Pros: works behind CGNAT / no router access; only the VPS is exposed; can bind the tunnel to the VPS loopback and reach it via a second SSH hop for zero public exposure.
- Cons: needs a VPS; extra hop adds latency; manage
autossh/keys. Bind to0.0.0.0only with TLS, else preferlocalhost+ SSH-hop from the phone.
Recommendation: Tailscale for daily use; reverse-SSH (B2) as the fallback when a tailnet isn't an option; public port-forward (B1) only as a last resort and only with real TLS.
Appendix C — Future: postmarketOS full-Linux path
Per the Fairphone-6 C2 notes, pmOS has a mainline FP6 port. On a full Linux
userland the calculus changes: real package manager, easier cryptography/srp
builds, Node for spawn, and — with a working container stack — even
operator-owned sbx launch. If the phone graduates to pmOS, this spec collapses
to "install like a normal Linux operator" and Phases 0/3B/skip-podman caveats
mostly evaporate.
Risk register
| # | Risk | Likelihood | Mitigation |
|---|---|---|---|
| R1 | srp C-ext won't build in Termux |
High | Pure-Python SRP shim (§0.5) + interop test vs real server |
| R2 | cryptography>=46 too heavy to build |
Med | pkg install python-cryptography; loosen pin in an operator-only reqs file (§0.4) |
| R3 | Daemon reaped by Doze/OOM | High | termux-wake-lock, Termux:Boot, supervised restart (§Phase 4) |
| R4 | Public exposure via port-forward | Med | Prefer Tailscale/reverse-SSH; if forwarding, mandatory wss:// + strong SRP (§App B1) |
| R5 | Mobile network flap drops ws | High | Reconnect/backoff wrapper; possible daemon hardening (§Phase 4) |
| R6 | SRP shim constants mismatch server | Med | Mirror RFC 5054 2048-bit group + SHA-256 exactly; gate on a live-auth test |
Milestone acceptance summary
- P0: all operator deps import on device (wheel/build or shim).
- P1: phone appears in roster; can
read/say. - P2: phone drives a granted room sandbox via
keys/screen/watch. - P3:
operate --profile <remote>runs the loop with a cloud model. - P4: survives Doze + network blip, auto-resumes.