HTTP fallback leg (docs/19): e2e scenario 13 + docs
- e2e.py: scenario 13 (http fallback) — drives a full turn over the HTTP leg (health + POST /v1/frame + SSE /v1/events, no WS) and asserts the user echo lands on the SSE stream in < 1.5 s. - ws_probe.py --http: prints '== user echo in X.XXs' (the docs/19 sendable-in-fallback timing assertion) alongside the existing health/POST/SSE output; same assertion flags as the WS leg. - docs: 19 status flipped to implemented; 09-pairing-security §9.4 cross-reference (second door, same lock: token + device allowlist, 64 KiB cap, rate limit, optional TLS, unauthenticated /v1/health); 13-testing manual scenario 15 + automated pointers.
This commit is contained in:
1 parent
7f936fa596
commit
2349a95dd4
5 files changed
+61
-2
No files matched your search
@@ -60,6 +60,14 @@ security principal (the token is).
|
|||||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
||||||
at the edge, forward WS to `127.0.0.1:8790`.
|
at the edge, forward WS to `127.0.0.1:8790`.
|
||||||
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
- **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
|
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
||||||
in secure storage.
|
in secure storage.
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ adb logcat -d > /tmp/logcat.txt
|
|||||||
entries drop out); tap a row → the command is sent and the drawer closes;
|
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
|
type an unknown command → the drawer closes (the raw text can still be
|
||||||
sent; hermes answers with its unknown-command reply).
|
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
|
## 13.5 Debugging tips
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
relaunched), the app **sends over `POST` and receives over SSE** instead of
|
||||||
waiting 2–20 s for a WS redial.
|
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
|
`04-wire-protocol.md` (frames), `08-push.md` (outbox/sync/push), and
|
||||||
`09-pairing-security.md` (auth model).
|
`09-pairing-security.md` (auth model).
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import sys
|
|||||||
import uuid
|
import uuid
|
||||||
import zlib
|
import zlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
REPO = HERE.parent.parent
|
REPO = HERE.parent.parent
|
||||||
@@ -303,6 +304,33 @@ def s12_sync(env, url, token):
|
|||||||
return FAIL, f"probe rc={rc}"
|
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 = [
|
SCENARIOS = [
|
||||||
(1, "pair", s1_pair),
|
(1, "pair", s1_pair),
|
||||||
(2, "text round-trip", s2_text),
|
(2, "text round-trip", s2_text),
|
||||||
@@ -316,6 +344,7 @@ SCENARIOS = [
|
|||||||
(10, "media out", s10_media_out),
|
(10, "media out", s10_media_out),
|
||||||
(11, "push", s11_push),
|
(11, "push", s11_push),
|
||||||
(12, "reconnect/sync", s12_sync),
|
(12, "reconnect/sync", s12_sync),
|
||||||
|
(13, "http fallback", s13_http_fallback),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -740,6 +740,7 @@ def run_http(args, base: str) -> int:
|
|||||||
print("== SSE open (/v1/events)")
|
print("== SSE open (/v1/events)")
|
||||||
|
|
||||||
# 3. POST the message.send frame (accept-and-ack).
|
# 3. POST the message.send frame (accept-and-ack).
|
||||||
|
post_time: float | None = None
|
||||||
if args.send:
|
if args.send:
|
||||||
frame = {
|
frame = {
|
||||||
"v": 1, "id": 1, "type": "message.send",
|
"v": 1, "id": 1, "type": "message.send",
|
||||||
@@ -753,6 +754,7 @@ def run_http(args, base: str) -> int:
|
|||||||
r = conn.getresponse()
|
r = conn.getresponse()
|
||||||
body = r.read()
|
body = r.read()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
post_time = time.time()
|
||||||
print(f"== POST /v1/frame -> {r.status} {body[:200]!r}")
|
print(f"== POST /v1/frame -> {r.status} {body[:200]!r}")
|
||||||
if r.status >= 400:
|
if r.status >= 400:
|
||||||
print("!! POST /v1/frame rejected")
|
print("!! POST /v1/frame rejected")
|
||||||
@@ -763,11 +765,12 @@ def run_http(args, base: str) -> int:
|
|||||||
st = _TurnState()
|
st = _TurnState()
|
||||||
got_final = False
|
got_final = False
|
||||||
seen_final_frame = False
|
seen_final_frame = False
|
||||||
|
echo_logged = False
|
||||||
deadline = time.time() + args.timeout
|
deadline = time.time() + args.timeout
|
||||||
cur_data: list[str] = []
|
cur_data: list[str] = []
|
||||||
|
|
||||||
def feed(line: str) -> bool:
|
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")
|
line = line.rstrip("\r\n")
|
||||||
if line == "":
|
if line == "":
|
||||||
if cur_data:
|
if cur_data:
|
||||||
@@ -776,6 +779,14 @@ def run_http(args, base: str) -> int:
|
|||||||
ftype = data.get("type")
|
ftype = data.get("type")
|
||||||
payload = data.get("payload") or {}
|
payload = data.get("payload") or {}
|
||||||
st.track(ftype, payload)
|
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":
|
if ftype == "message" and payload.get("role") == "assistant":
|
||||||
got_final = True
|
got_final = True
|
||||||
if ftype == "message.stop":
|
if ftype == "message.stop":
|
||||||
|
|||||||
Reference in new issue
Block a user