aurival 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aurival/__init__.py +134 -0
- aurival/auth.py +416 -0
- aurival/bot.py +316 -0
- aurival/cli.py +130 -0
- aurival/errors.py +269 -0
- aurival/events.py +138 -0
- aurival/http.py +192 -0
- aurival/socket.py +366 -0
- aurival-0.1.0.dist-info/METADATA +166 -0
- aurival-0.1.0.dist-info/RECORD +14 -0
- aurival-0.1.0.dist-info/WHEEL +5 -0
- aurival-0.1.0.dist-info/entry_points.txt +2 -0
- aurival-0.1.0.dist-info/licenses/LICENSE +202 -0
- aurival-0.1.0.dist-info/top_level.txt +1 -0
aurival/__init__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Aurival bot SDK.
|
|
2
|
+
|
|
3
|
+
Declare commands, call run(), and the SDK holds the socket::
|
|
4
|
+
|
|
5
|
+
from aurival import Bot
|
|
6
|
+
|
|
7
|
+
bot = Bot()
|
|
8
|
+
|
|
9
|
+
@bot.command("ping")
|
|
10
|
+
async def ping(ctx):
|
|
11
|
+
await ctx.reply("pong")
|
|
12
|
+
|
|
13
|
+
bot.run()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .bot import Bot
|
|
19
|
+
from .errors import (
|
|
20
|
+
CODE_CLASSES,
|
|
21
|
+
DOC_URL_PREFIX,
|
|
22
|
+
TYPE_CLASSES,
|
|
23
|
+
AccessTokenExpired,
|
|
24
|
+
AccessTokenInvalid,
|
|
25
|
+
AckUnknownEvent,
|
|
26
|
+
APIError,
|
|
27
|
+
AssertionExpired,
|
|
28
|
+
AssertionReplay,
|
|
29
|
+
AurivalAPIError,
|
|
30
|
+
AurivalError,
|
|
31
|
+
AuthenticationError,
|
|
32
|
+
BadAssertion,
|
|
33
|
+
BadProof,
|
|
34
|
+
BadPublicKey,
|
|
35
|
+
BotLinkNotAllowed,
|
|
36
|
+
BotPlaygroundOnly,
|
|
37
|
+
BotSuspended,
|
|
38
|
+
EmptyText,
|
|
39
|
+
FrameInvalid,
|
|
40
|
+
FrameTooLarge,
|
|
41
|
+
IdempotencyKeyInvalid,
|
|
42
|
+
IdempotencyKeyReused,
|
|
43
|
+
IdleTimeout,
|
|
44
|
+
InternalError,
|
|
45
|
+
InvalidCommandName,
|
|
46
|
+
InvalidJSON,
|
|
47
|
+
InvalidRequestError,
|
|
48
|
+
KeyAlreadyPaired,
|
|
49
|
+
KeyRevoked,
|
|
50
|
+
NotFound,
|
|
51
|
+
PairRateLimited,
|
|
52
|
+
ParameterInvalid,
|
|
53
|
+
ParameterMissing,
|
|
54
|
+
PermissionDeniedError,
|
|
55
|
+
ProtocolError,
|
|
56
|
+
RateLimited,
|
|
57
|
+
RateLimitError,
|
|
58
|
+
ServerRestarting,
|
|
59
|
+
SessionSuperseded,
|
|
60
|
+
SyncRateLimited,
|
|
61
|
+
TextTooLong,
|
|
62
|
+
TooManyProblems,
|
|
63
|
+
TransportError,
|
|
64
|
+
UnknownOperation,
|
|
65
|
+
UnknownParameter,
|
|
66
|
+
from_envelope,
|
|
67
|
+
)
|
|
68
|
+
from .events import Chat, Command, Context, Event, User
|
|
69
|
+
from .socket import BYE_ACTIONS, ByeAction, action_for_bye
|
|
70
|
+
|
|
71
|
+
__version__ = "0.1.0"
|
|
72
|
+
|
|
73
|
+
# Everything a developer can name. The JS package mirrors this list file for file
|
|
74
|
+
# (SDK-7), so a name missing here becomes a permanent asymmetry: adding an export
|
|
75
|
+
# later is free, removing one is a break.
|
|
76
|
+
__all__ = [
|
|
77
|
+
"BYE_ACTIONS",
|
|
78
|
+
"CODE_CLASSES",
|
|
79
|
+
"DOC_URL_PREFIX",
|
|
80
|
+
"TYPE_CLASSES",
|
|
81
|
+
"APIError",
|
|
82
|
+
"AccessTokenExpired",
|
|
83
|
+
"AccessTokenInvalid",
|
|
84
|
+
"AckUnknownEvent",
|
|
85
|
+
"AssertionExpired",
|
|
86
|
+
"AssertionReplay",
|
|
87
|
+
"AurivalAPIError",
|
|
88
|
+
"AurivalError",
|
|
89
|
+
"AuthenticationError",
|
|
90
|
+
"BadAssertion",
|
|
91
|
+
"BadProof",
|
|
92
|
+
"BadPublicKey",
|
|
93
|
+
"Bot",
|
|
94
|
+
"BotLinkNotAllowed",
|
|
95
|
+
"BotPlaygroundOnly",
|
|
96
|
+
"BotSuspended",
|
|
97
|
+
"ByeAction",
|
|
98
|
+
"Chat",
|
|
99
|
+
"Command",
|
|
100
|
+
"Context",
|
|
101
|
+
"EmptyText",
|
|
102
|
+
"Event",
|
|
103
|
+
"FrameInvalid",
|
|
104
|
+
"FrameTooLarge",
|
|
105
|
+
"IdempotencyKeyInvalid",
|
|
106
|
+
"IdempotencyKeyReused",
|
|
107
|
+
"IdleTimeout",
|
|
108
|
+
"InternalError",
|
|
109
|
+
"InvalidCommandName",
|
|
110
|
+
"InvalidJSON",
|
|
111
|
+
"InvalidRequestError",
|
|
112
|
+
"KeyAlreadyPaired",
|
|
113
|
+
"KeyRevoked",
|
|
114
|
+
"NotFound",
|
|
115
|
+
"PairRateLimited",
|
|
116
|
+
"ParameterInvalid",
|
|
117
|
+
"ParameterMissing",
|
|
118
|
+
"PermissionDeniedError",
|
|
119
|
+
"ProtocolError",
|
|
120
|
+
"RateLimitError",
|
|
121
|
+
"RateLimited",
|
|
122
|
+
"ServerRestarting",
|
|
123
|
+
"SessionSuperseded",
|
|
124
|
+
"SyncRateLimited",
|
|
125
|
+
"TextTooLong",
|
|
126
|
+
"TooManyProblems",
|
|
127
|
+
"TransportError",
|
|
128
|
+
"UnknownOperation",
|
|
129
|
+
"UnknownParameter",
|
|
130
|
+
"User",
|
|
131
|
+
"__version__",
|
|
132
|
+
"action_for_bye",
|
|
133
|
+
"from_envelope",
|
|
134
|
+
]
|
aurival/auth.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""The key file, first-run pairing, assertion signing, access-token exchange.
|
|
2
|
+
|
|
3
|
+
SDK-33: the seed lives in exactly one object (`MachineKey`) and never leaves
|
|
4
|
+
it — not an attribute anywhere else, not a local in dispatch, not in a log
|
|
5
|
+
line or exception message. The only path the b64 seed travels is
|
|
6
|
+
`KeyFile.load` -> `MachineKey.from_seed_b64` (decoded immediately) and
|
|
7
|
+
`MachineKey._seed_for_save` -> `KeyFile.save` (encoded only at the moment of
|
|
8
|
+
writing).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import base64
|
|
15
|
+
import contextlib
|
|
16
|
+
import dataclasses
|
|
17
|
+
import hashlib
|
|
18
|
+
import ipaddress
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
import socket as _socket
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
import urllib.parse
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
32
|
+
Ed25519PrivateKey,
|
|
33
|
+
Ed25519PublicKey,
|
|
34
|
+
)
|
|
35
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
36
|
+
Encoding,
|
|
37
|
+
NoEncryption,
|
|
38
|
+
PrivateFormat,
|
|
39
|
+
PublicFormat,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from . import errors
|
|
43
|
+
from .http import DEFAULT_HOST
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from .http import HttpClient
|
|
47
|
+
|
|
48
|
+
# Re-exported, not redefined: bot.py compares the resolved host against http's
|
|
49
|
+
# copy, and two spellings of one default drift silently.
|
|
50
|
+
__all__ = ["DEFAULT_HOST"]
|
|
51
|
+
|
|
52
|
+
# token.go: AssertionMaxAge. auth owns 60s of refresh headroom ahead of a
|
|
53
|
+
# 15-minute access-token TTL (SDK-37).
|
|
54
|
+
_ASSERTION_MAX_AGE = 120
|
|
55
|
+
_TOKEN_REFRESH_HEADROOM = 60.0
|
|
56
|
+
_DEFAULT_PAIR_RETRY_AFTER = 60.0
|
|
57
|
+
|
|
58
|
+
# botapi.go:158 — a custom base32 alphabet, no padding. Not base64.b32encode.
|
|
59
|
+
_B32_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _b32encode_nopad(data: bytes) -> str:
|
|
63
|
+
bits = 0
|
|
64
|
+
value = 0
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
for byte in data:
|
|
67
|
+
value = (value << 8) | byte
|
|
68
|
+
bits += 8
|
|
69
|
+
while bits >= 5:
|
|
70
|
+
index = (value >> (bits - 5)) & 0x1F
|
|
71
|
+
out.append(_B32_ALPHABET[index])
|
|
72
|
+
bits -= 5
|
|
73
|
+
if bits > 0:
|
|
74
|
+
out.append(_B32_ALPHABET[(value << (5 - bits)) & 0x1F])
|
|
75
|
+
return "".join(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fingerprint_of(canonical_b64: str) -> str:
|
|
79
|
+
# botapi.go:206 — hashes the canonical base64 STRING, not the raw bytes.
|
|
80
|
+
digest = hashlib.sha256(canonical_b64.encode("ascii")).digest()
|
|
81
|
+
s = _b32encode_nopad(digest)[:8]
|
|
82
|
+
return (s[:4] + "-" + s[4:]).upper()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _b64url_nopad(data: bytes) -> str:
|
|
86
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_rfc3339(s: str) -> float:
|
|
90
|
+
# time.RFC3339 in Go always has a "Z" or numeric offset, never a bare
|
|
91
|
+
# local time; datetime.fromisoformat wants "+00:00", not "Z" (py3.10).
|
|
92
|
+
if s.endswith("Z"):
|
|
93
|
+
s = s[:-1] + "+00:00"
|
|
94
|
+
return datetime.fromisoformat(s).timestamp()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class MachineKey:
|
|
98
|
+
"""The ONLY object that holds the seed (SDK-33)."""
|
|
99
|
+
|
|
100
|
+
__slots__ = ("_private_key",)
|
|
101
|
+
|
|
102
|
+
def __init__(self, private_key: Ed25519PrivateKey) -> None:
|
|
103
|
+
self._private_key = private_key
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def generate(cls) -> MachineKey:
|
|
107
|
+
return cls(Ed25519PrivateKey.generate())
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_seed_b64(cls, seed_b64: str) -> MachineKey:
|
|
111
|
+
seed = base64.b64decode(seed_b64)
|
|
112
|
+
return cls(Ed25519PrivateKey.from_private_bytes(seed))
|
|
113
|
+
|
|
114
|
+
def sign(self, payload: bytes) -> bytes:
|
|
115
|
+
return self._private_key.sign(payload)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def public_key_b64(self) -> str:
|
|
119
|
+
pub: Ed25519PublicKey = self._private_key.public_key()
|
|
120
|
+
raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
121
|
+
return base64.b64encode(raw).decode("ascii")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def fingerprint(self) -> str:
|
|
125
|
+
return _fingerprint_of(self.public_key_b64)
|
|
126
|
+
|
|
127
|
+
def _seed_b64_for_save(self) -> str:
|
|
128
|
+
"""The seed's one legitimate exit. Called ONLY by `KeyFile.save`."""
|
|
129
|
+
raw = self._private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
|
130
|
+
return base64.b64encode(raw).decode("ascii")
|
|
131
|
+
|
|
132
|
+
def __repr__(self) -> str:
|
|
133
|
+
return f"<MachineKey fingerprint={self.fingerprint}>"
|
|
134
|
+
|
|
135
|
+
__str__ = __repr__
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclasses.dataclass(frozen=True)
|
|
139
|
+
class Machine:
|
|
140
|
+
"""machine.json minus the seed. Safe to log."""
|
|
141
|
+
|
|
142
|
+
bot: str
|
|
143
|
+
machine: str
|
|
144
|
+
host: str
|
|
145
|
+
created: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class KeyFile:
|
|
149
|
+
def __init__(self, path: pathlib.Path | None = None) -> None:
|
|
150
|
+
if path is not None:
|
|
151
|
+
self.path = path
|
|
152
|
+
self.is_default = False
|
|
153
|
+
else:
|
|
154
|
+
override = os.environ.get("AURIVAL_KEY_PATH")
|
|
155
|
+
if override:
|
|
156
|
+
self.path = pathlib.Path(override)
|
|
157
|
+
self.is_default = False
|
|
158
|
+
else:
|
|
159
|
+
self.path = self.default_path()
|
|
160
|
+
self.is_default = True
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def default_path() -> pathlib.Path:
|
|
164
|
+
return pathlib.Path(".aurival") / "machine.json"
|
|
165
|
+
|
|
166
|
+
def load(self) -> tuple[MachineKey, Machine] | None:
|
|
167
|
+
if not self.path.exists():
|
|
168
|
+
return None
|
|
169
|
+
with self.path.open("r", encoding="utf-8") as f:
|
|
170
|
+
doc = json.load(f)
|
|
171
|
+
key = MachineKey.from_seed_b64(doc["seed"])
|
|
172
|
+
machine = Machine(
|
|
173
|
+
bot=doc["bot"], machine=doc["machine"], host=doc["host"], created=doc["created"]
|
|
174
|
+
)
|
|
175
|
+
return key, machine
|
|
176
|
+
|
|
177
|
+
def save(self, key: MachineKey, machine: Machine) -> None:
|
|
178
|
+
directory = self.path.parent
|
|
179
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
os.chmod(directory, 0o700)
|
|
181
|
+
|
|
182
|
+
doc = {
|
|
183
|
+
"seed": key._seed_b64_for_save(),
|
|
184
|
+
"bot": machine.bot,
|
|
185
|
+
"machine": machine.machine,
|
|
186
|
+
"host": machine.host,
|
|
187
|
+
"created": machine.created,
|
|
188
|
+
}
|
|
189
|
+
fd, tmp_name = tempfile.mkstemp(dir=directory, prefix=".machine-", suffix=".tmp")
|
|
190
|
+
try:
|
|
191
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
192
|
+
json.dump(doc, f)
|
|
193
|
+
os.chmod(tmp_name, 0o600)
|
|
194
|
+
os.replace(tmp_name, self.path)
|
|
195
|
+
except BaseException:
|
|
196
|
+
with contextlib.suppress(OSError):
|
|
197
|
+
os.unlink(tmp_name)
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
if self.is_default:
|
|
201
|
+
gitignore = directory / ".gitignore"
|
|
202
|
+
gitignore.write_text("*\n", encoding="utf-8")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Auth:
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
http: HttpClient,
|
|
209
|
+
key: MachineKey,
|
|
210
|
+
machine: Machine,
|
|
211
|
+
logger: logging.Logger | None = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
self._http = http
|
|
214
|
+
self._key = key
|
|
215
|
+
self._machine_id = machine.machine
|
|
216
|
+
self.bot = machine.bot
|
|
217
|
+
self._logger = logger or logging.getLogger("aurival")
|
|
218
|
+
self._token: str | None = None
|
|
219
|
+
self._expires_at: float | None = None
|
|
220
|
+
self._lock = asyncio.Lock()
|
|
221
|
+
|
|
222
|
+
def _fresh(self) -> bool:
|
|
223
|
+
return (
|
|
224
|
+
self._token is not None
|
|
225
|
+
and self._expires_at is not None
|
|
226
|
+
and time.time() < self._expires_at - _TOKEN_REFRESH_HEADROOM
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def token(self) -> str:
|
|
230
|
+
"""The current access token, minting one only if we have no fresh one.
|
|
231
|
+
|
|
232
|
+
THE RE-CHECK INSIDE THE LOCK IS THE POINT. Handlers run concurrently and
|
|
233
|
+
every one of them reaches this through `ctx.reply`, so N callers arrive
|
|
234
|
+
together the moment a token goes stale. Without it each waiter takes the
|
|
235
|
+
lock in turn and performs its own exchange: N assertions, N `/v1/token`
|
|
236
|
+
round trips, and N-1 tokens thrown away — a self-inflicted burst against
|
|
237
|
+
the endpoint whose rate limit would take the bot offline.
|
|
238
|
+
"""
|
|
239
|
+
if self._fresh():
|
|
240
|
+
assert self._token is not None
|
|
241
|
+
return self._token
|
|
242
|
+
async with self._lock:
|
|
243
|
+
# The first waiter minted one while we queued. Reuse it.
|
|
244
|
+
if self._fresh():
|
|
245
|
+
assert self._token is not None
|
|
246
|
+
return self._token
|
|
247
|
+
return await self._exchange()
|
|
248
|
+
|
|
249
|
+
async def refresh(self) -> str:
|
|
250
|
+
"""Force a new exchange, whatever we currently hold.
|
|
251
|
+
|
|
252
|
+
DELIBERATELY NOT FRESHNESS-CHECKED, and it is not the same function as
|
|
253
|
+
`token()`. Its one caller is the HTTP 401 path (SDK-37): the server has
|
|
254
|
+
just REJECTED the token we hold, so "it still looks fresh by our clock"
|
|
255
|
+
is precisely the wrong answer — clock skew or a server-side rotation
|
|
256
|
+
both produce it, and returning the rejected token would burn the single
|
|
257
|
+
permitted retry and raise on a request that a real refresh would have
|
|
258
|
+
completed.
|
|
259
|
+
"""
|
|
260
|
+
async with self._lock:
|
|
261
|
+
return await self._exchange()
|
|
262
|
+
|
|
263
|
+
async def _exchange(self) -> str:
|
|
264
|
+
"""One assertion, one `/v1/token`. Callers hold the lock."""
|
|
265
|
+
assertion = self.build_assertion()
|
|
266
|
+
resp = await self._http.request(
|
|
267
|
+
"POST",
|
|
268
|
+
"/v1/token",
|
|
269
|
+
body={"assertion": assertion},
|
|
270
|
+
authenticated=False,
|
|
271
|
+
)
|
|
272
|
+
token = resp["access_token"]
|
|
273
|
+
if not isinstance(token, str):
|
|
274
|
+
raise errors.ProtocolError("token response missing access_token")
|
|
275
|
+
self._token = token
|
|
276
|
+
self._expires_at = _parse_rfc3339(str(resp["expires_at"]))
|
|
277
|
+
self._logger.debug("access token refreshed")
|
|
278
|
+
return self._token
|
|
279
|
+
|
|
280
|
+
def build_assertion(self, *, now: float | None = None) -> str:
|
|
281
|
+
iat = int(now if now is not None else time.time())
|
|
282
|
+
exp = iat + _ASSERTION_MAX_AGE
|
|
283
|
+
nonce = _b64url_nopad(os.urandom(16))
|
|
284
|
+
payload = {
|
|
285
|
+
"key_id": self._machine_id,
|
|
286
|
+
"bot_id": self.bot,
|
|
287
|
+
"iat": iat,
|
|
288
|
+
"exp": exp,
|
|
289
|
+
"nonce": nonce,
|
|
290
|
+
}
|
|
291
|
+
raw = json.dumps(payload, separators=(",", ":"), sort_keys=False).encode("utf-8")
|
|
292
|
+
sig = self._key.sign(raw)
|
|
293
|
+
return f"{_b64url_nopad(raw)}.{_b64url_nopad(sig)}"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def machine_label() -> str:
|
|
297
|
+
# pairing.go:64 falls back on the server side past 64 chars anyway; we
|
|
298
|
+
# truncate up front so the sent label and the signed label always match.
|
|
299
|
+
return _socket.gethostname()[:64]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# Captured here so `pair`'s `machine_label` parameter can shadow the name
|
|
303
|
+
# without losing the default implementation.
|
|
304
|
+
_default_machine_label = machine_label
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _is_loopback_hostname(hostname: str) -> bool:
|
|
308
|
+
if hostname == "localhost":
|
|
309
|
+
return True
|
|
310
|
+
try:
|
|
311
|
+
return ipaddress.ip_address(hostname).is_loopback
|
|
312
|
+
except ValueError:
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def resolve_host() -> str:
|
|
317
|
+
host = os.environ.get("AURIVAL_API")
|
|
318
|
+
if not host:
|
|
319
|
+
return DEFAULT_HOST
|
|
320
|
+
parsed = urllib.parse.urlsplit(host)
|
|
321
|
+
hostname = parsed.hostname or ""
|
|
322
|
+
if parsed.scheme != "https" and not _is_loopback_hostname(hostname):
|
|
323
|
+
# An SDK exception, never a bare ValueError. The README promises one
|
|
324
|
+
# `except AurivalError` catches everything this library raises, and a
|
|
325
|
+
# builtin leaking out of the very first call in `run()` breaks that
|
|
326
|
+
# promise at the least convenient moment.
|
|
327
|
+
raise errors.AurivalError(
|
|
328
|
+
f"AURIVAL_API must be https:// unless the host is loopback, got {host!r}"
|
|
329
|
+
)
|
|
330
|
+
return host
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def pair(
|
|
334
|
+
http: HttpClient,
|
|
335
|
+
*,
|
|
336
|
+
machine_label: str | None = None,
|
|
337
|
+
host: str,
|
|
338
|
+
out: Callable[[str], None] = print,
|
|
339
|
+
logger: logging.Logger | None = None,
|
|
340
|
+
) -> tuple[MachineKey, Machine]:
|
|
341
|
+
log = logger or logging.getLogger("aurival")
|
|
342
|
+
label = machine_label if machine_label is not None else _default_machine_label()
|
|
343
|
+
key = MachineKey.generate()
|
|
344
|
+
|
|
345
|
+
while True:
|
|
346
|
+
iat = int(time.time())
|
|
347
|
+
proof = key.sign(f"pair-start:{label}:{iat}".encode())
|
|
348
|
+
try:
|
|
349
|
+
start = await http.request(
|
|
350
|
+
"POST",
|
|
351
|
+
"/v1/pair/start",
|
|
352
|
+
authenticated=False,
|
|
353
|
+
body={
|
|
354
|
+
"machine_label": label,
|
|
355
|
+
"public_key": key.public_key_b64,
|
|
356
|
+
"proof": base64.b64encode(proof).decode("ascii"),
|
|
357
|
+
"iat": iat,
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
except errors.PairRateLimited as exc:
|
|
361
|
+
wait = exc.retry_after if exc.retry_after is not None else _DEFAULT_PAIR_RETRY_AFTER
|
|
362
|
+
log.info("pairing rate limited, waiting %.0fs", wait)
|
|
363
|
+
await asyncio.sleep(wait)
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
user_code = str(start["user_code"])
|
|
367
|
+
fingerprint = str(start["fingerprint"])
|
|
368
|
+
poll_token = str(start["poll_token"])
|
|
369
|
+
interval_s = float(start["interval_ms"]) / 1000.0
|
|
370
|
+
|
|
371
|
+
# Directly to stdout, never the logger (SDK-31) — a dev with the
|
|
372
|
+
# logger at WARNING must still see this. The lead's e2e matches these
|
|
373
|
+
# two lines verbatim: `r"pairing code\s+([0-9A-Z]{4}-[0-9A-Z]{4})"`.
|
|
374
|
+
out(f"aurival: pairing code {user_code}")
|
|
375
|
+
out(f"aurival: fingerprint {fingerprint}")
|
|
376
|
+
out("Compare the fingerprint in the app, then approve. Waiting...")
|
|
377
|
+
|
|
378
|
+
machine, expired = await _poll_until_settled(http, poll_token, interval_s, host)
|
|
379
|
+
if machine is not None:
|
|
380
|
+
return key, machine
|
|
381
|
+
assert expired
|
|
382
|
+
log.info("pairing code expired, starting a new one")
|
|
383
|
+
out("That code expired. Here is a new one:")
|
|
384
|
+
# SDK-36: unbounded restart, one new code per restart. Loop again.
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def _poll_until_settled(
|
|
388
|
+
http: HttpClient, poll_token: str, interval_s: float, host: str
|
|
389
|
+
) -> tuple[Machine | None, bool]:
|
|
390
|
+
"""Returns (machine, expired). Exactly one of the two paths is non-empty:
|
|
391
|
+
a `Machine` on approval, or `expired=True` telling `pair` to restart.
|
|
392
|
+
"""
|
|
393
|
+
headers = {"Authorization": f"Bearer {poll_token}"}
|
|
394
|
+
while True:
|
|
395
|
+
await asyncio.sleep(interval_s)
|
|
396
|
+
try:
|
|
397
|
+
resp = await http.request("POST", "/v1/pair/poll", authenticated=False, headers=headers)
|
|
398
|
+
except errors.PairRateLimited as exc:
|
|
399
|
+
wait = exc.retry_after if exc.retry_after is not None else _DEFAULT_PAIR_RETRY_AFTER
|
|
400
|
+
await asyncio.sleep(wait)
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
state = resp["state"]
|
|
404
|
+
if state == "pending":
|
|
405
|
+
continue
|
|
406
|
+
if state == "expired":
|
|
407
|
+
return None, True
|
|
408
|
+
if state == "approved":
|
|
409
|
+
machine = Machine(
|
|
410
|
+
bot=str(resp["bot"]),
|
|
411
|
+
machine=str(resp["machine"]),
|
|
412
|
+
host=host,
|
|
413
|
+
created=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
414
|
+
)
|
|
415
|
+
return machine, False
|
|
416
|
+
raise errors.ProtocolError(f"unknown pairing state: {state!r}")
|