diff --git a/docs/09-pairing-security.md b/docs/09-pairing-security.md index 6dc816e..dd2607a 100644 --- a/docs/09-pairing-security.md +++ b/docs/09-pairing-security.md @@ -60,6 +60,14 @@ security principal (the token is). - **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at the edge, forward WS to `127.0.0.1:8790`. - **Public bind** (`0.0.0.0`) + WSS + strong token — last resort. +- **HTTP fallback leg (docs/19):** the gateway also serves the same frames + over plain HTTP (`ANDROID_HTTP_PORT`, default 8791) for the app's + fallback transport. It is a *second door with the same lock*: the same + Bearer token (constant-time `verify_token`) + the same device allowlist + (`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as + the WS. Optional TLS via `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`. + `GET /v1/health` is unauthenticated by design (liveness only — it must + not reflect tokens, device ids, or versions). - The app stores the server URL + (for self-signed) the pinned cert fingerprint in secure storage. diff --git a/docs/13-testing.md b/docs/13-testing.md index 32ce7de..bad1fe0 100644 --- a/docs/13-testing.md +++ b/docs/13-testing.md @@ -131,6 +131,15 @@ adb logcat -d > /tmp/logcat.txt entries drop out); tap a row → the command is sent and the drawer closes; type an unknown command → the drawer closes (the raw text can still be sent; hermes answers with its unknown-command reply). +15. **HTTP fallback (docs/19):** with the WS port unreachable (e.g. the + gateway bound WS to a dead port, or a firewall dropping 8790 but not + 8791), the app stays sendable: the status pill shows "connected · http" + (green), a sent message echoes back within ~1 s and the agent reply + streams in over SSE; the attach button is disabled (media needs the + live WS). When the WS comes back the pill returns to "connected" and + media works again. Automated: `e2e.py` scenario 13 (health + + `POST /v1/frame` + SSE turn, user-echo < 1.5 s) and + `ws_probe.py --http` (same assertion flags as the WS leg). ## 13.5 Debugging tips diff --git a/docs/19-http-fallback-transport.md b/docs/19-http-fallback-transport.md index 0e30ce0..33846a3 100644 --- a/docs/19-http-fallback-transport.md +++ b/docs/19-http-fallback-transport.md @@ -6,7 +6,9 @@ by the gateway. When the WS is down (flaky network, NAT timeout, app just relaunched), the app **sends over `POST` and receives over SSE** instead of waiting 2–20 s for a WS redial. -Status: **design (proposed, not built)**. Complements — does not replace — +Status: **implemented** (gateway leg: `gateway-plugin/http_server.py`; app +leg: `app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt` + +`GatewayClient.State.HttpFallback`). Complements — does not replace — `04-wire-protocol.md` (frames), `08-push.md` (outbox/sync/push), and `09-pairing-security.md` (auth model). diff --git a/gateway-plugin/tests/e2e.py b/gateway-plugin/tests/e2e.py index bd1185a..99ed6db 100644 --- a/gateway-plugin/tests/e2e.py +++ b/gateway-plugin/tests/e2e.py @@ -33,6 +33,7 @@ import sys import uuid import zlib from pathlib import Path +from urllib.parse import urlparse HERE = Path(__file__).resolve().parent REPO = HERE.parent.parent @@ -303,6 +304,33 @@ def s12_sync(env, url, token): return FAIL, f"probe rc={rc}" +def s13_http_fallback(env, url, token): + """docs/19: the HTTP fallback leg. The probe drives a full turn over + health + POST /v1/frame + SSE /v1/events (no WS involved). The user echo + must land on the SSE stream promptly after the POST (< 1.5 s on LAN).""" + u = urlparse(url) + scheme = "https" if u.scheme == "wss" else "http" + http_port = os.getenv("ANDROID_HTTP_PORT", "8791") + http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}" + rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url, + "--send", "Reply with exactly: e2e http fallback OK", + "--timeout", "120") + if rc == 0: + m = re.search(r"== user echo in ([\d.]+)s", out) + echo = float(m.group(1)) if m else None + if echo is not None and echo > 1.5: + return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)" + return PASS, ("health + POST /v1/frame + SSE turn complete" + + (f"; user echo in {echo:.2f}s" if echo is not None else "")) + if rc == 20: + return FAIL, "health check failed (HTTP leg not running?)" + if rc == 21: + return FAIL, "SSE open failed" + if rc == 22: + return FAIL, "POST /v1/frame rejected" + return FAIL, f"probe rc={rc}" + + SCENARIOS = [ (1, "pair", s1_pair), (2, "text round-trip", s2_text), @@ -316,6 +344,7 @@ SCENARIOS = [ (10, "media out", s10_media_out), (11, "push", s11_push), (12, "reconnect/sync", s12_sync), + (13, "http fallback", s13_http_fallback), ] diff --git a/gateway-plugin/tests/ws_probe.py b/gateway-plugin/tests/ws_probe.py index f68ab39..a65bfe3 100644 --- a/gateway-plugin/tests/ws_probe.py +++ b/gateway-plugin/tests/ws_probe.py @@ -740,6 +740,7 @@ def run_http(args, base: str) -> int: print("== SSE open (/v1/events)") # 3. POST the message.send frame (accept-and-ack). + post_time: float | None = None if args.send: frame = { "v": 1, "id": 1, "type": "message.send", @@ -753,6 +754,7 @@ def run_http(args, base: str) -> int: r = conn.getresponse() body = r.read() conn.close() + post_time = time.time() print(f"== POST /v1/frame -> {r.status} {body[:200]!r}") if r.status >= 400: print("!! POST /v1/frame rejected") @@ -763,11 +765,12 @@ def run_http(args, base: str) -> int: st = _TurnState() got_final = False seen_final_frame = False + echo_logged = False deadline = time.time() + args.timeout cur_data: list[str] = [] def feed(line: str) -> bool: - nonlocal cur_data, got_final, seen_final_frame + nonlocal cur_data, got_final, seen_final_frame, echo_logged line = line.rstrip("\r\n") if line == "": if cur_data: @@ -776,6 +779,14 @@ def run_http(args, base: str) -> int: ftype = data.get("type") payload = data.get("payload") or {} st.track(ftype, payload) + # docs/19: the user echo must land on the SSE stream + # promptly after the POST (the < 1 s sendable-in-fallback + # UX assertion). + if (not echo_logged and post_time is not None + and ftype == "message" + and payload.get("role") == "user"): + echo_logged = True + print(f"== user echo in {time.time() - post_time:.2f}s") if ftype == "message" and payload.get("role") == "assistant": got_final = True if ftype == "message.stop":