"""Clients that publish this node into the mesh. Two transports exist because the mesh has two reachable control planes with different admission rules, and exactly one of them can accept a public node today: ``DeviceV2Client`` — the canonical MeshStack control plane Talks to the ``meshstack-device-v2`` edge function with Ed25519 request signing. This is the contract owned by the Mesh backend lane and it is what a paired ThoxOS device uses. It requires (a) a device row on ``meshstack_devices_v2`` with our public key, minted through a pairing session by an authenticated mesh owner, and (b) a ``base_url`` that passes ``validateLocalBaseUrl`` — RFC1918, loopback, link-local, CGNAT or ``.local`` only. Requirement (b) is why a Colab tunnel cannot currently self-register here: ``https://.trycloudflare.com`` and ``https://.hf.space`` are public hostnames and the function rejects them by design (anti-SSRF: the mesh must not be induced to call arbitrary internet hosts). We surface that refusal as ``PublicUrlRejectedError`` rather than retrying it, and the agent degrades to the controller transport. ``ControllerClient`` — this repo's mesh controller The pre-existing in-memory registry (``controller/mesh.py``) that already accepts public Colab and Space URLs and health-checks them. It is the transport that makes a live demo possible today, and it is the natural place for a public-node broker to live even after the v2 policy question is settled. Both implement ``MeshClient`` so the agent is written once against one interface. """ from __future__ import annotations import json import logging import time import urllib.error import urllib.request from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Mapping from .config import NodeConfig from .errors import ( MeshRejectedError, MeshTransportError, PublicUrlRejectedError, ) from .signing import DeviceIdentity, check_clock_skew logger = logging.getLogger("thox.mesh") #: Substring of the edge function's private-address refusal. _PRIVATE_URL_REFUSAL = "private mesh, LAN, link-local, or loopback" #: HTTP statuses worth retrying; everything else is a contract decision. _RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504}) @dataclass class Registration: """Result of publishing this node into the mesh.""" endpoint_id: str transport: str base_url: str raw: dict[str, Any] class MeshClient(ABC): """Interface the agent uses to join, hold and leave the mesh.""" name: str = "abstract" @abstractmethod def register(self, config: NodeConfig, base_url: str, capabilities: Mapping[str, Any], status: str) -> Registration: """Publish (or refresh) this node's endpoint. Must be idempotent.""" @abstractmethod def heartbeat(self, config: NodeConfig, registration: Registration, capabilities: Mapping[str, Any], status: str) -> None: """Renew liveness and refresh telemetry.""" @abstractmethod def deregister(self, registration: Registration) -> None: """Remove the endpoint from routing. Best-effort; must never raise.""" # ── MeshStack v2 (canonical) ───────────────────────────────────────────── class DeviceV2Client(MeshClient): """Signed client for the ``meshstack-device-v2`` edge function. Note there is no Supabase API key anywhere in this class. The function runs with JWT verification disabled and authenticates purely on the Ed25519 signature, so the device private key is the entire credential. That is a deliberately smaller secret surface than shipping an anon key into a public notebook, where it would be trivially extractable. """ name = "device_v2" def __init__(self, identity: DeviceIdentity, function_url: str, *, timeout: int = 20) -> None: self._identity = identity self._url = function_url self._timeout = timeout self._clock_checked = False def _call(self, action: str, payload: Mapping[str, Any]) -> dict[str, Any]: """Sign and POST one action, translating failures into typed errors.""" body_payload = dict(payload) body_payload["action"] = action signed = self._identity.sign_request(action, body_payload) request = urllib.request.Request(self._url, data=signed.body, headers=signed.headers, method="POST") try: with urllib.request.urlopen(request, timeout=self._timeout) as response: return json.loads(response.read().decode("utf-8") or "{}") except urllib.error.HTTPError as exc: detail = _read_error_body(exc) message = detail.get("message") or detail.get("error") or f"HTTP {exc.code}" if _PRIVATE_URL_REFUSAL in message: raise PublicUrlRejectedError(message, status=exc.code, action=action) from exc if exc.code in _RETRYABLE_STATUS: raise MeshTransportError(f"{action} failed with retryable HTTP {exc.code}: {message}") from exc raise MeshRejectedError(message, status=exc.code, action=action) from exc except urllib.error.URLError as exc: raise MeshTransportError(f"{action} could not reach the mesh: {exc.reason}") from exc except (TimeoutError, json.JSONDecodeError) as exc: raise MeshTransportError(f"{action} failed: {exc}") from exc def register(self, config: NodeConfig, base_url: str, capabilities: Mapping[str, Any], status: str) -> Registration: response = self._call( "register_model", { "model_id": config.model_id, "alias": config.alias, "protocol": config.protocol, "base_url": base_url, "context_window": config.context_window, "capabilities": dict(capabilities), "resource_requirements": { "node_kind": config.node_kind, "ephemeral": config.ephemeral, "context_window": config.context_window, }, "status": status, }, ) endpoint = response.get("endpoint") or {} endpoint_id = endpoint.get("id") if not endpoint_id: raise MeshRejectedError("register_model returned no endpoint id", action="register_model") logger.info("registered endpoint %s via device_v2 (%s)", endpoint_id, base_url) return Registration(endpoint_id=endpoint_id, transport=self.name, base_url=base_url, raw=endpoint) def heartbeat(self, config: NodeConfig, registration: Registration, capabilities: Mapping[str, Any], status: str) -> None: """Renew device liveness, then refresh the endpoint's telemetry. The backend's ``heartbeat`` action updates the **device** row only — it never touches ``meshstack_model_endpoints_v2``. Telemetry therefore has to ride on a ``register_model`` upsert, which is idempotent on ``(device_id, alias)`` and is the contract's own refresh path. """ response = self._call( "heartbeat", { "capabilities": dict(capabilities), "resource_snapshot": capabilities.get("thox_telemetry", {}), "app_version": "thoxmesh-node/1.0", }, ) if not self._clock_checked: server_time = response.get("server_time") if isinstance(server_time, str): parsed = _parse_server_time(server_time) if parsed is not None: skew = check_clock_skew(parsed) logger.info("clock skew vs mesh: %.1fs", skew) self._clock_checked = True self.register(config, registration.base_url, capabilities, status) def deregister(self, registration: Registration) -> None: try: self._call("unregister_model", {"endpoint_id": registration.endpoint_id}) logger.info("deregistered endpoint %s", registration.endpoint_id) except Exception as exc: # noqa: BLE001 - shutdown must not raise logger.warning("deregister failed (endpoint will expire by lease): %s", exc) # ── Controller (public-node broker, works today) ───────────────────────── class ControllerClient(MeshClient): """Client for this repo's mesh controller, which accepts public URLs. The controller keeps endpoints in memory and health-checks them, so a lapsed Colab node is dropped by its own health loop as well as by our lease. """ name = "controller" def __init__(self, controller_url: str, *, timeout: int = 20, token: str | None = None) -> None: self._url = controller_url.rstrip("/") self._timeout = timeout self._token = token def _post(self, path: str, payload: Mapping[str, Any]) -> dict[str, Any]: body = json.dumps(payload).encode("utf-8") headers = {"content-type": "application/json"} if self._token: headers["authorization"] = f"Bearer {self._token}" request = urllib.request.Request(f"{self._url}{path}", data=body, headers=headers, method="POST") try: with urllib.request.urlopen(request, timeout=self._timeout) as response: return json.loads(response.read().decode("utf-8") or "{}") except urllib.error.HTTPError as exc: detail = _read_error_body(exc) message = detail.get("message") or detail.get("error") or f"HTTP {exc.code}" if exc.code in _RETRYABLE_STATUS: raise MeshTransportError(f"{path} retryable HTTP {exc.code}: {message}") from exc raise MeshRejectedError(message, status=exc.code, action=path) from exc except urllib.error.URLError as exc: raise MeshTransportError(f"{path} could not reach the controller: {exc.reason}") from exc except (TimeoutError, json.JSONDecodeError) as exc: raise MeshTransportError(f"{path} failed: {exc}") from exc def register(self, config: NodeConfig, base_url: str, capabilities: Mapping[str, Any], status: str) -> Registration: response = self._post( "/mesh/register", { "model_id": config.model_id, "alias": config.alias, "base_url": base_url, "source": config.node_kind, "protocol": config.protocol, "context_window": config.context_window, "capabilities": dict(capabilities), "status": status, }, ) logger.info("registered %s via controller (%s)", config.model_id, base_url) return Registration( endpoint_id=response.get("model_id") or config.model_id, transport=self.name, base_url=base_url, raw=response, ) def heartbeat(self, config: NodeConfig, registration: Registration, capabilities: Mapping[str, Any], status: str) -> None: # The controller's register is an upsert, so a re-register is the heartbeat. self.register(config, registration.base_url, capabilities, status) def deregister(self, registration: Registration) -> None: try: self._post("/mesh/deregister", {"model_id": registration.endpoint_id}) logger.info("deregistered %s from controller", registration.endpoint_id) except Exception as exc: # noqa: BLE001 - shutdown must not raise logger.warning("controller deregister failed: %s", exc) class NullClient(MeshClient): """No-op transport for local development and tests. Chosen when no mesh credentials and no controller URL are configured, so the node still serves completions and can be exercised end-to-end without any control plane at all. """ name = "none" def register(self, config: NodeConfig, base_url: str, capabilities: Mapping[str, Any], status: str) -> Registration: logger.warning("no mesh transport configured; serving locally without registration") return Registration(endpoint_id="local", transport=self.name, base_url=base_url, raw={}) def heartbeat(self, config: NodeConfig, registration: Registration, capabilities: Mapping[str, Any], status: str) -> None: return None def deregister(self, registration: Registration) -> None: return None def build_client(config: NodeConfig, identity: DeviceIdentity | None) -> MeshClient: """Pick a transport from configuration and available credentials. ``auto`` prefers the canonical control plane when a device identity exists, because that is the contract everything else in the fleet speaks; it falls back to the controller so an unpaired node is still useful. """ if config.transport == "device_v2": if identity is None: raise MeshRejectedError( "transport device_v2 requires THOX_MESH_DEVICE_ID and a device key; " "pair the device first or use THOX_MESH_TRANSPORT=controller" ) return DeviceV2Client(identity, config.device_function_url) if config.transport == "controller": return ControllerClient(config.controller_url) # auto if identity is not None: return DeviceV2Client(identity, config.device_function_url) if config.controller_url: return ControllerClient(config.controller_url) return NullClient() def _read_error_body(exc: urllib.error.HTTPError) -> dict[str, Any]: """Decode a JSON error body, tolerating non-JSON responses.""" try: raw = exc.read().decode("utf-8") except Exception: # noqa: BLE001 return {} try: parsed = json.loads(raw) return parsed if isinstance(parsed, dict) else {"message": raw[:400]} except json.JSONDecodeError: return {"message": raw[:400]} def _parse_server_time(value: str) -> int | None: """Parse the backend's ISO timestamp into epoch milliseconds.""" text = value.strip().replace("Z", "+0000") for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"): try: import datetime as _dt return int(_dt.datetime.strptime(text, fmt).timestamp() * 1000) except ValueError: continue return None