"""The agent API over plain HTTP, for servers that are not Gradio. **Why this exists.** `distinct_agent` has always reached a server through `gradio_client.Client.predict`, because the server has always been the Gradio app. The phone is a server and is not Gradio, and a phone has no business implementing Gradio's wire protocol to be talked to. So this is the same conversation over an obvious transport. Every named API becomes one POST of JSON to one path, and the response body is the same JSON string `predict` would have returned. Nothing else changes: the signing, the nonces, the response verification, the retry policy and the offer bookkeeping all live in `GradioAgentTransport` and are untouched. This class is a `PredictClient` and that is the whole of its job. **The security properties are not relaxed.** The request is signed with the same HMAC over the same canonical form, and the response is proved with the same `ResponseAuthentication` before anything reads it. A server on this transport that cannot sign is refused exactly as a Gradio one would be. The paths are the ones the signature already commits to. `transport.py` has had `PATH_SYNC` and its siblings since the signing scheme was written, because the signature covers the path; this simply sends the request to the place the signature always said it was going. """ from __future__ import annotations import json import urllib.error import urllib.request from typing import Any from .transport import ( ACCEPT_API, CATALOGUE_API, COMPLETE_API, PAIR_API, PATH_ACCEPT, PATH_CATALOGUE, PATH_COMPLETE, PATH_SYNC, SYNC_API, AgentTransportUnavailable, ) #: Pairing is the one call with no signature, so it has no signed path #: constant. It gets the obvious sibling of the others. PATH_PAIR = "/agent/pair" API_PATHS = { PAIR_API: PATH_PAIR, SYNC_API: PATH_SYNC, ACCEPT_API: PATH_ACCEPT, COMPLETE_API: PATH_COMPLETE, CATALOGUE_API: PATH_CATALOGUE, } #: The five positional arguments every signed call carries, named. Positions #: are how the Gradio endpoints take them and names are how they go on the #: wire, because a server reading this has to get them the right way round and #: an unnamed array is how that goes wrong. SIGNED_FIELDS = ("agent_id", "timestamp", "nonce", "payload", "signature") PAIR_FIELDS = ("code", "capabilities", "signing_public_key", "access_code") MAX_RESPONSE_BYTES = 8 * 1024 * 1024 class NoRedirects(urllib.request.HTTPRedirectHandler): """Refuse every redirect, because the address was already decided. **This is a hole that was open.** `netpolicy.validate_server_url` checks the URL an operator typed: is it this machine, and if it is not, did they say `--allow-remote-server`, and if it is plaintext, did they separately say `--allow-plaintext-server`. That check happens once, at pairing. `urllib.request.build_opener()` installs a redirect handler by default, and nothing re-checked a `Location`. So whoever answered the address the operator typed could reply `302 Location: http://169.254.169.254/latest/meta-data/` and the worker would fetch it. Reproduced against a loopback server with neither flag set: 301, 302 and 303 were all followed to another host, another port and another scheme, defeating both gates after the fact with one response. What the server gains is a request primitive pointed at whatever the volunteer's machine can reach and they cannot: cloud metadata, a home router, a printer, anything on the local network. Re-validating each `Location` instead was the alternative and it is worse. A redirect this client should follow does not exist: every route it calls is a fixed path on a server it has already paired with, and a server that wants to move has an operator who can retype the address. Refusing is one line and has no case it gets wrong. `urllib` raises the response as an `HTTPError` when the handler refuses, so the caller sees the 3xx and its body rather than a silent failure. """ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102 return None class HttpPredictClient: """Speak the agent API to a server that serves it as ordinary HTTP.""" def __init__( self, server_url: str, *, timeout: float = 30.0, opener: Any = None, ) -> None: if not isinstance(server_url, str) or not server_url.strip(): raise ValueError("server URL must be non-empty text") self.server_url = server_url.strip().rstrip("/") self.timeout = float(timeout) self._opener = opener or urllib.request.build_opener(NoRedirects) def predict(self, *args: Any, api_name: str) -> str: path = API_PATHS.get(api_name) if path is None: raise AgentTransportUnavailable(f"no HTTP route for {api_name!r}") fields = PAIR_FIELDS if api_name == PAIR_API else SIGNED_FIELDS if len(args) > len(fields): raise AgentTransportUnavailable( f"{api_name} takes at most {len(fields)} arguments, got {len(args)}" ) body = json.dumps(dict(zip(fields, args)), separators=(",", ":")).encode("utf-8") request = urllib.request.Request( f"{self.server_url}{path}", data=body, headers={"Content-Type": "application/json", "Accept": "application/json"}, method="POST", ) try: with self._opener.open(request, timeout=self.timeout) as response: raw = response.read(MAX_RESPONSE_BYTES + 1) except urllib.error.HTTPError as error: # A rejection still has a body worth reading: the server's # explanation, and its signature over that explanation. Throwing # it away here would turn "your pairing code expired" into a # transport error with no sentence in it. raw = error.read(MAX_RESPONSE_BYTES + 1) if not raw: raise AgentTransportUnavailable( f"{api_name} failed with HTTP {error.code} and no body" ) from error except OSError as error: raise AgentTransportUnavailable( f"{api_name} could not reach {self.server_url}: {type(error).__name__}" ) from error if len(raw) > MAX_RESPONSE_BYTES: raise AgentTransportUnavailable(f"{api_name} returned an oversized response") return raw.decode("utf-8", "replace") def close(self) -> None: self._opener.close() def client_factory(server_url: str) -> HttpPredictClient: """A `client_factory` for `GradioAgentTransport.pair`.""" return HttpPredictClient(server_url)