Download distinct_agent/httpapi.py from User1342/distinct: direct link, hf CLI and curl.
- Browser
- Download file 6.82 kB
-
https://huggingface.co/spaces/User1342/distinct/resolve/main/distinct_agent/httpapi.py
- Command line
-
hf download hf://spaces/User1342/distinct/distinct_agent/httpapi.py
-
curl -L -o httpapi.py https://huggingface.co/spaces/User1342/distinct/resolve/main/distinct_agent/httpapi.py
6.82 kB
| """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) | |