Files
iris_x_hermes/gateway-plugin/pairing.py
T
ARIA 218c50d688 M1+M2: gateway core loop + agent transparency
M1 (gateway core loop / text round-trip):
- WS server (ws_server.py): bind, hello auth (constant-time), hello.ack, heartbeat, connection registry
- pairing.py: token generation, pairing store, QR payload
- adapter.py: send() -> message frame; inbound message.send -> MessageEvent -> handle_message
- app: Connect screen, GatewayClient (connect + reconnect), ChatScreen send/render, SecureStore (Android/Desktop)
- tests/ws_probe.py: probe harness driving a real turn

M2 (streaming + reasoning + tools + commentary):
- protocol.py: M2 frame types (message.start/update/stop, tool.start/progress/end, commentary)
- adapter.py: per-chat turn-state machine; classify outbound into frames; _split_reasoning; tool-line parsing
- reasoning in streaming: capture via on_stream_delta hook (kind=reasoning, gated by plugins.stream_reasoning_deltas) with a FIFO barrier, attach to message.stop
- app: live streaming bubble, ReasoningBlock (collapse + copy), ToolCard (Everything/Truncated/Nothing), dimmed commentary, typing
- docs/14-milestones.md: M1/M2 marked done; reasoning note corrected
2026-08-19 13:56:19 +02:00

189 lines
6.2 KiB
Python

"""Pairing: token generation/verification + device registry.
Token generation (64-hex) and constant-time verification. Device registry
(SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
created. QR payload for the pairing flow (``interactive_setup``).
Storage: ``get_hermes_home()/"android"/devices.db``.
Milestone M1.
"""
import hmac
import json
import logging
import secrets
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import quote
logger = logging.getLogger(__name__)
# 32 random bytes -> 64 hex chars (docs/09-pairing-security.md)
TOKEN_BYTES = 32
def generate_token() -> str:
"""Mint a fresh high-entropy pairing token (64 hex chars)."""
return secrets.token_hex(TOKEN_BYTES)
def verify_token(provided: Optional[str], expected: Optional[str]) -> bool:
"""Constant-time token comparison (never time-leaks the token)."""
if not provided or not expected:
return False
return hmac.compare_digest(
provided.encode("utf-8", "replace"),
expected.encode("utf-8", "replace"),
)
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
"""Pairing URL encoded into the QR / pre-filled into the app.
``iris://pair?host=<lan-ip>&port=8790&token=<token>`` — the app's
Connect screen parses this to pre-fill settings (docs/09 §9.2).
"""
return (
f"iris://pair?host={quote(host, safe='')}"
f"&port={int(port)}"
f"&secure={'1' if secure else '0'}"
f"&token={quote(token, safe='')}"
)
def pairing_url(host: str, port: int, secure: bool = False) -> str:
"""Plain ws(s) URL the app connects to (shown next to the QR)."""
scheme = "wss" if secure else "ws"
return f"{scheme}://{host}:{int(port)}/ws"
# ---------------------------------------------------------------------------
# Device registry (SQLite)
# ---------------------------------------------------------------------------
class DeviceRegistry:
"""Persistent device registry under ``get_hermes_home()/"android"``.
Thread-safe (single connection + lock); all operations are small and
fast enough to run inline on the gateway's asyncio loop.
"""
def __init__(self, db_path: Path):
self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
with self._lock:
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
caps TEXT NOT NULL DEFAULT '{}',
fcm_token TEXT,
ntfy_topic TEXT,
last_seen REAL NOT NULL DEFAULT 0,
created REAL NOT NULL DEFAULT 0
)
"""
)
self._conn.commit()
def upsert(
self,
device_id: str,
name: str,
caps: Optional[Dict[str, Any]] = None,
fcm_token: Optional[str] = None,
ntfy_topic: Optional[str] = None,
) -> None:
now = time.time()
caps_json = json.dumps(caps or {}, separators=(",", ":"))
with self._lock:
self._conn.execute(
"""
INSERT INTO devices (device_id, name, caps, fcm_token, ntfy_topic,
last_seen, created)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET
name = excluded.name,
caps = excluded.caps,
fcm_token = COALESCE(excluded.fcm_token, devices.fcm_token),
ntfy_topic = COALESCE(excluded.ntfy_topic, devices.ntfy_topic),
last_seen = excluded.last_seen
""",
(device_id, name or "", caps_json, fcm_token, ntfy_topic, now, now),
)
self._conn.commit()
def update_push_tokens(
self,
device_id: str,
fcm_token: Optional[str] = None,
ntfy_topic: Optional[str] = None,
) -> None:
with self._lock:
self._conn.execute(
"""
UPDATE devices SET
fcm_token = COALESCE(?, fcm_token),
ntfy_topic = COALESCE(?, ntfy_topic),
last_seen = ?
WHERE device_id = ?
""",
(fcm_token, ntfy_topic, time.time(), device_id),
)
self._conn.commit()
def touch(self, device_id: str) -> None:
with self._lock:
self._conn.execute(
"UPDATE devices SET last_seen = ? WHERE device_id = ?",
(time.time(), device_id),
)
self._conn.commit()
def get(self, device_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
row = self._conn.execute(
"SELECT * FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
return _row_to_device(row) if row else None
def list(self) -> List[Dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM devices ORDER BY last_seen DESC"
).fetchall()
return [_row_to_device(r) for r in rows]
def close(self) -> None:
with self._lock:
try:
self._conn.close()
except Exception:
pass
def _row_to_device(row: sqlite3.Row) -> Dict[str, Any]:
try:
caps = json.loads(row["caps"] or "{}")
if not isinstance(caps, dict):
caps = {}
except (json.JSONDecodeError, TypeError):
caps = {}
return {
"device_id": row["device_id"],
"name": row["name"],
"caps": caps,
"fcm_token": row["fcm_token"],
"ntfy_topic": row["ntfy_topic"],
"last_seen": row["last_seen"],
"created": row["created"],
}