File size: 6,819 Bytes
2aa8b3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
"""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)