agent2learn 0.1.2__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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/auth/cdp.py
ADDED
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
"""Conservative Chrome DevTools Protocol authentication for a dedicated profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
from urllib.parse import urljoin, urlsplit
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
import websocket
|
|
22
|
+
|
|
23
|
+
from agent2learn import config, paths
|
|
24
|
+
from agent2learn.errors import AuthenticationError
|
|
25
|
+
from agent2learn.schools import School
|
|
26
|
+
from agent2learn.session import Session
|
|
27
|
+
|
|
28
|
+
from . import paste
|
|
29
|
+
|
|
30
|
+
CDP_TIMEOUT = 10.0
|
|
31
|
+
ENDPOINT_WAIT_SECONDS = 30.0
|
|
32
|
+
AUTH_WAIT_SECONDS = 300.0
|
|
33
|
+
RUNTIME_RETRY_SECONDS = 10.0
|
|
34
|
+
POLL_SECONDS = 0.5
|
|
35
|
+
|
|
36
|
+
_CHROMIUM_EXECUTABLE_BASENAMES = ("Google Chrome", "Microsoft Edge", "Chromium")
|
|
37
|
+
_CHROMIUM_METADATA_PRODUCTS = ("Chrome", "Google Chrome", "Chromium", "Microsoft Edge", "Edg")
|
|
38
|
+
_LOCK_MARKERS = ("SingletonLock", "SingletonSocket", "SingletonCookie")
|
|
39
|
+
_BROWSER_VERSION = r"\d+(?:\.\d+)*"
|
|
40
|
+
_BROWSER_METADATA_PATTERNS = tuple(
|
|
41
|
+
re.compile(rf"^{re.escape(product)}/{_BROWSER_VERSION}$", re.IGNORECASE)
|
|
42
|
+
for product in _CHROMIUM_METADATA_PRODUCTS
|
|
43
|
+
)
|
|
44
|
+
_AUTH_EXPRESSION = r"""
|
|
45
|
+
(async () => {
|
|
46
|
+
try {
|
|
47
|
+
const versionsResponse = await fetch("/d2l/api/versions/", {credentials: "include"});
|
|
48
|
+
if (!versionsResponse.ok) return {ok: false};
|
|
49
|
+
const products = await versionsResponse.json();
|
|
50
|
+
if (!Array.isArray(products)) return {ok: false};
|
|
51
|
+
const product = products.find(
|
|
52
|
+
(entry) => entry && typeof entry === "object" && entry.ProductCode === "lp"
|
|
53
|
+
);
|
|
54
|
+
if (!product) return {ok: false};
|
|
55
|
+
const supportedVersions = Array.isArray(product.SupportedVersions)
|
|
56
|
+
? product.SupportedVersions
|
|
57
|
+
: [];
|
|
58
|
+
const candidates = [product.LatestVersion, ...supportedVersions]
|
|
59
|
+
.filter(
|
|
60
|
+
(value, index, values) =>
|
|
61
|
+
typeof value === "string" &&
|
|
62
|
+
/^[0-9]+(?:\.[0-9]+)*$/.test(value) &&
|
|
63
|
+
values.indexOf(value) === index
|
|
64
|
+
);
|
|
65
|
+
for (const version of candidates) {
|
|
66
|
+
const response = await fetch(`/d2l/api/lp/${version}/users/whoami`, {
|
|
67
|
+
credentials: "include"
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) continue;
|
|
70
|
+
let payload;
|
|
71
|
+
try {
|
|
72
|
+
payload = await response.json();
|
|
73
|
+
} catch (_) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
payload &&
|
|
78
|
+
typeof payload === "object" &&
|
|
79
|
+
typeof payload.Identifier === "string" &&
|
|
80
|
+
payload.Identifier.trim().length > 0
|
|
81
|
+
) {
|
|
82
|
+
return {ok: true, identifier: payload.Identifier};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (_) {
|
|
86
|
+
return {ok: false};
|
|
87
|
+
}
|
|
88
|
+
return {ok: false};
|
|
89
|
+
})()
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class DebugEndpoint:
|
|
95
|
+
"""Validated loopback DevTools endpoint discovered from ``DevToolsActivePort``."""
|
|
96
|
+
|
|
97
|
+
port: int
|
|
98
|
+
browser_websocket_url: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _CDPCommandError(AuthenticationError):
|
|
102
|
+
"""A command-level CDP error with no unredacted browser message retained."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, method: str, *, retryable: bool = False) -> None:
|
|
105
|
+
super().__init__(f"browser DevTools command failed: {method}")
|
|
106
|
+
self.retryable = retryable
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class _ActivePortMarker:
|
|
111
|
+
"""The identity Chrome writes for one browser-level DevTools endpoint."""
|
|
112
|
+
|
|
113
|
+
port: int
|
|
114
|
+
browser_websocket_path: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _OwnedProcess(Protocol):
|
|
118
|
+
"""The bounded process lifecycle surface used for an Agent2Learn-owned browser."""
|
|
119
|
+
|
|
120
|
+
def poll(self) -> int | None: ...
|
|
121
|
+
|
|
122
|
+
def wait(self, timeout: float | None = None) -> int: ...
|
|
123
|
+
|
|
124
|
+
def terminate(self) -> None: ...
|
|
125
|
+
|
|
126
|
+
def kill(self) -> None: ...
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _CDPConnection:
|
|
130
|
+
def __init__(self, websocket_url: str) -> None:
|
|
131
|
+
try:
|
|
132
|
+
self._socket = websocket.create_connection(
|
|
133
|
+
websocket_url,
|
|
134
|
+
timeout=CDP_TIMEOUT,
|
|
135
|
+
suppress_origin=True,
|
|
136
|
+
)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
raise AuthenticationError(
|
|
139
|
+
"could not connect to the dedicated browser DevTools endpoint"
|
|
140
|
+
) from exc
|
|
141
|
+
self._next_id = 1
|
|
142
|
+
|
|
143
|
+
def send_without_wait(self, method: str, params: dict[str, object] | None = None) -> None:
|
|
144
|
+
message = {"id": self._next_id, "method": method}
|
|
145
|
+
self._next_id += 1
|
|
146
|
+
if params:
|
|
147
|
+
message["params"] = params
|
|
148
|
+
self._socket.send(json.dumps(message, separators=(",", ":")))
|
|
149
|
+
|
|
150
|
+
def call(
|
|
151
|
+
self,
|
|
152
|
+
method: str,
|
|
153
|
+
params: dict[str, object] | None = None,
|
|
154
|
+
*,
|
|
155
|
+
event_handler: Callable[[dict[str, Any]], None] | None = None,
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
message_id = self._next_id
|
|
158
|
+
self._next_id += 1
|
|
159
|
+
message: dict[str, object] = {"id": message_id, "method": method}
|
|
160
|
+
if params:
|
|
161
|
+
message["params"] = params
|
|
162
|
+
try:
|
|
163
|
+
self._socket.send(json.dumps(message, separators=(",", ":")))
|
|
164
|
+
while True:
|
|
165
|
+
raw = self._socket.recv()
|
|
166
|
+
if not raw:
|
|
167
|
+
raise AuthenticationError("dedicated browser DevTools connection closed")
|
|
168
|
+
decoded = json.loads(raw)
|
|
169
|
+
if not isinstance(decoded, dict):
|
|
170
|
+
continue
|
|
171
|
+
if event_handler is not None:
|
|
172
|
+
event_handler(decoded)
|
|
173
|
+
if decoded.get("id") != message_id:
|
|
174
|
+
continue
|
|
175
|
+
error = decoded.get("error")
|
|
176
|
+
if isinstance(error, dict):
|
|
177
|
+
raise _CDPCommandError(
|
|
178
|
+
method,
|
|
179
|
+
retryable=_runtime_error_is_transient(method, error),
|
|
180
|
+
)
|
|
181
|
+
result = decoded.get("result", {})
|
|
182
|
+
return result if isinstance(result, dict) else {}
|
|
183
|
+
except AuthenticationError:
|
|
184
|
+
raise
|
|
185
|
+
except (OSError, ValueError, TypeError, websocket.WebSocketException) as exc:
|
|
186
|
+
raise AuthenticationError(f"browser DevTools command failed: {method}") from exc
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
try:
|
|
190
|
+
self._socket.close()
|
|
191
|
+
except Exception:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _DedicatedPageConnection:
|
|
196
|
+
"""Pair one page socket with the browser-level handle that can force-close its target."""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
page: _CDPConnection,
|
|
201
|
+
browser: _CDPConnection,
|
|
202
|
+
target_id: str,
|
|
203
|
+
) -> None:
|
|
204
|
+
self._page = page
|
|
205
|
+
self._browser = browser
|
|
206
|
+
self._target_id = target_id
|
|
207
|
+
self._closed = False
|
|
208
|
+
|
|
209
|
+
def call(
|
|
210
|
+
self,
|
|
211
|
+
method: str,
|
|
212
|
+
params: dict[str, object] | None = None,
|
|
213
|
+
*,
|
|
214
|
+
event_handler: Callable[[dict[str, Any]], None] | None = None,
|
|
215
|
+
) -> dict[str, Any]:
|
|
216
|
+
return self._page.call(method, params, event_handler=event_handler)
|
|
217
|
+
|
|
218
|
+
def send_without_wait(self, method: str, params: dict[str, object] | None = None) -> None:
|
|
219
|
+
self._page.send_without_wait(method, params)
|
|
220
|
+
|
|
221
|
+
def close_target(self) -> None:
|
|
222
|
+
"""Force-close exactly this target instead of trusting page before-unload handlers."""
|
|
223
|
+
|
|
224
|
+
if self._closed:
|
|
225
|
+
return
|
|
226
|
+
self._closed = True
|
|
227
|
+
try:
|
|
228
|
+
result = self._browser.call("Target.closeTarget", {"targetId": self._target_id})
|
|
229
|
+
if result.get("success") is not True:
|
|
230
|
+
raise AuthenticationError("dedicated outline target could not be closed")
|
|
231
|
+
finally:
|
|
232
|
+
self._page.close()
|
|
233
|
+
self._browser.close()
|
|
234
|
+
|
|
235
|
+
def close(self) -> None:
|
|
236
|
+
self.close_target()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class DedicatedPageFactory:
|
|
240
|
+
"""Own the dedicated-profile endpoint and create one fresh page target per outline.
|
|
241
|
+
|
|
242
|
+
The factory never inspects or attaches to an everyday profile. It acquires only the
|
|
243
|
+
Agent2Learn ``browser-profile`` endpoint, creates an inert ``about:blank`` target for each
|
|
244
|
+
caller, and leaves closing that target to the returned page connection's owner.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def __init__(self) -> None:
|
|
248
|
+
self._endpoint: DebugEndpoint | None = None
|
|
249
|
+
self._process: _OwnedProcess | None = None
|
|
250
|
+
self._profile: Path | None = None
|
|
251
|
+
self._owned = False
|
|
252
|
+
self._closed = False
|
|
253
|
+
|
|
254
|
+
def open_page(self) -> _DedicatedPageConnection:
|
|
255
|
+
"""Create and connect to a fresh loopback page target."""
|
|
256
|
+
|
|
257
|
+
endpoint = self._ensure_endpoint()
|
|
258
|
+
browser: _CDPConnection | None = None
|
|
259
|
+
page: _CDPConnection | None = None
|
|
260
|
+
target_id: str | None = None
|
|
261
|
+
try:
|
|
262
|
+
browser = _CDPConnection(endpoint.browser_websocket_url)
|
|
263
|
+
result = browser.call(
|
|
264
|
+
"Target.createTarget",
|
|
265
|
+
{"url": "about:blank", "background": True},
|
|
266
|
+
)
|
|
267
|
+
raw_target_id = result.get("targetId")
|
|
268
|
+
if not isinstance(raw_target_id, str) or not raw_target_id:
|
|
269
|
+
raise AuthenticationError("dedicated browser did not create an outline target")
|
|
270
|
+
target_id = raw_target_id
|
|
271
|
+
websocket_url = _wait_for_target(endpoint.port, target_id)
|
|
272
|
+
page = _CDPConnection(websocket_url)
|
|
273
|
+
return _DedicatedPageConnection(page, browser, target_id)
|
|
274
|
+
except BaseException:
|
|
275
|
+
if page is not None:
|
|
276
|
+
page.close()
|
|
277
|
+
if browser is not None and target_id is not None:
|
|
278
|
+
with suppress(Exception):
|
|
279
|
+
browser.call("Target.closeTarget", {"targetId": target_id})
|
|
280
|
+
if browser is not None:
|
|
281
|
+
browser.close()
|
|
282
|
+
raise
|
|
283
|
+
|
|
284
|
+
def close(self) -> None:
|
|
285
|
+
"""Close only a browser process this factory launched, once all targets are gone."""
|
|
286
|
+
|
|
287
|
+
if self._closed:
|
|
288
|
+
return
|
|
289
|
+
self._closed = True
|
|
290
|
+
endpoint = self._endpoint
|
|
291
|
+
process = self._process
|
|
292
|
+
profile = self._profile
|
|
293
|
+
self._endpoint = None
|
|
294
|
+
self._process = None
|
|
295
|
+
self._profile = None
|
|
296
|
+
if self._owned and endpoint is not None:
|
|
297
|
+
_close_owned_browser(endpoint, process, profile=profile)
|
|
298
|
+
|
|
299
|
+
def _ensure_endpoint(self) -> DebugEndpoint:
|
|
300
|
+
if self._closed:
|
|
301
|
+
raise AuthenticationError("dedicated outline browser factory is closed")
|
|
302
|
+
if self._endpoint is not None:
|
|
303
|
+
return self._endpoint
|
|
304
|
+
profile = profile_path()
|
|
305
|
+
_validate_profile_path(profile)
|
|
306
|
+
paths.long_path(profile).mkdir(parents=True, exist_ok=True)
|
|
307
|
+
endpoint, process, owned = _acquire_endpoint(profile)
|
|
308
|
+
self._endpoint = endpoint
|
|
309
|
+
self._process = process
|
|
310
|
+
self._profile = profile
|
|
311
|
+
self._owned = owned
|
|
312
|
+
return endpoint
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _CDPRequestSender(Protocol):
|
|
316
|
+
"""The one CDP operation needed by the request-boundary auth gate."""
|
|
317
|
+
|
|
318
|
+
def send_without_wait(self, method: str, params: dict[str, object] | None = None) -> None: ...
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class _CDPCommandConnection(Protocol):
|
|
322
|
+
"""The command surface used by helpers that can be driven by CDP test doubles."""
|
|
323
|
+
|
|
324
|
+
def call(
|
|
325
|
+
self,
|
|
326
|
+
method: str,
|
|
327
|
+
params: dict[str, object] | None = None,
|
|
328
|
+
*,
|
|
329
|
+
event_handler: Callable[[dict[str, Any]], None] | None = None,
|
|
330
|
+
) -> dict[str, Any]: ...
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class _AuthGate:
|
|
334
|
+
def __init__(self, connection: _CDPRequestSender, school: School) -> None:
|
|
335
|
+
self.connection = connection
|
|
336
|
+
self.school = school
|
|
337
|
+
self.blocked_host: str | None = None
|
|
338
|
+
self._fatal_blocked_host: str | None = None
|
|
339
|
+
self._handled_request_ids: set[str] = set()
|
|
340
|
+
|
|
341
|
+
def handle(self, message: dict[str, Any]) -> None:
|
|
342
|
+
if message.get("method") != "Fetch.requestPaused":
|
|
343
|
+
return
|
|
344
|
+
params = message.get("params")
|
|
345
|
+
if not isinstance(params, dict):
|
|
346
|
+
return
|
|
347
|
+
request_id = params.get("requestId")
|
|
348
|
+
if not isinstance(request_id, str) or not request_id:
|
|
349
|
+
return
|
|
350
|
+
# Fetch keeps a request paused until it receives one terminal decision. A malformed
|
|
351
|
+
# event must not be left pending while Runtime.evaluate waits on this same socket, and a
|
|
352
|
+
# duplicate event must not receive a second decision.
|
|
353
|
+
if request_id in self._handled_request_ids:
|
|
354
|
+
return
|
|
355
|
+
self._handled_request_ids.add(request_id)
|
|
356
|
+
|
|
357
|
+
request = params.get("request")
|
|
358
|
+
if not isinstance(request, dict):
|
|
359
|
+
self._fail(request_id, "unknown-host", fatal=True)
|
|
360
|
+
return
|
|
361
|
+
target = request.get("url")
|
|
362
|
+
if not isinstance(target, str):
|
|
363
|
+
self._fail(request_id, "unknown-host", fatal=True)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
if _auth_url_allowed(target, self.school):
|
|
367
|
+
self.connection.send_without_wait("Fetch.continueRequest", {"requestId": request_id})
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
resource_type = params.get("resourceType")
|
|
371
|
+
self._fail(
|
|
372
|
+
request_id,
|
|
373
|
+
_safe_hostname(target),
|
|
374
|
+
fatal=resource_type is None or resource_type in {"Document", "Iframe"},
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _fail(self, request_id: str, host: str, *, fatal: bool = False) -> None:
|
|
378
|
+
self.blocked_host = self.blocked_host or host
|
|
379
|
+
if fatal:
|
|
380
|
+
self._fatal_blocked_host = self._fatal_blocked_host or host
|
|
381
|
+
self.connection.send_without_wait(
|
|
382
|
+
"Fetch.failRequest", {"requestId": request_id, "errorReason": "BlockedByClient"}
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def raise_if_blocked(self) -> None:
|
|
386
|
+
if self._fatal_blocked_host is not None:
|
|
387
|
+
raise AuthenticationError(
|
|
388
|
+
f"authentication stopped at undeclared host {self._fatal_blocked_host}; "
|
|
389
|
+
"fallback: a2l auth --paste"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def raise_if_any_blocked(self) -> None:
|
|
393
|
+
if self.blocked_host is not None:
|
|
394
|
+
raise AuthenticationError(
|
|
395
|
+
f"authentication stopped at undeclared host {self.blocked_host}; "
|
|
396
|
+
"fallback: a2l auth --paste"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _call_with_gate(
|
|
401
|
+
connection: _CDPCommandConnection,
|
|
402
|
+
method: str,
|
|
403
|
+
params: dict[str, object] | None,
|
|
404
|
+
gate: _AuthGate,
|
|
405
|
+
) -> dict[str, Any]:
|
|
406
|
+
"""Run one command while preserving a request-boundary failure over CDP errors."""
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
result = connection.call(method, params, event_handler=gate.handle)
|
|
410
|
+
except AuthenticationError:
|
|
411
|
+
# A request rejected by Fetch can invalidate navigation or the page execution context
|
|
412
|
+
# before Chromium sends the command response. The hostname decision is the useful,
|
|
413
|
+
# already-redacted error in that case; otherwise retain the generic CDP failure.
|
|
414
|
+
gate.raise_if_blocked()
|
|
415
|
+
raise
|
|
416
|
+
gate.raise_if_blocked()
|
|
417
|
+
return result
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def authenticate_browser(school: School) -> Session:
|
|
421
|
+
"""Harvest a verified session from a validated dedicated Chromium profile."""
|
|
422
|
+
|
|
423
|
+
profile = config.data_dir() / "browser-profile"
|
|
424
|
+
_validate_profile_path(profile)
|
|
425
|
+
paths.long_path(profile).mkdir(parents=True, exist_ok=True)
|
|
426
|
+
endpoint, process, owned = _acquire_endpoint(profile)
|
|
427
|
+
page_connection: _CDPConnection | None = None
|
|
428
|
+
try:
|
|
429
|
+
page_url = _wait_for_page(endpoint.port)
|
|
430
|
+
page_connection = _CDPConnection(page_url)
|
|
431
|
+
gate = _AuthGate(page_connection, school)
|
|
432
|
+
page_connection.call("Page.enable", event_handler=gate.handle)
|
|
433
|
+
page_connection.call("Network.enable", event_handler=gate.handle)
|
|
434
|
+
page_connection.call(
|
|
435
|
+
"Fetch.enable",
|
|
436
|
+
{"patterns": [{"urlPattern": "*", "requestStage": "Request"}]},
|
|
437
|
+
event_handler=gate.handle,
|
|
438
|
+
)
|
|
439
|
+
target = urljoin(school.base_url.rstrip("/") + "/", "d2l/home")
|
|
440
|
+
_call_with_gate(page_connection, "Page.navigate", {"url": target}, gate)
|
|
441
|
+
|
|
442
|
+
identifier = _wait_for_authenticated_page(page_connection, gate)
|
|
443
|
+
cookies_result = _call_with_gate(page_connection, "Storage.getCookies", None, gate)
|
|
444
|
+
raw_cookies = cookies_result.get("cookies")
|
|
445
|
+
if not isinstance(raw_cookies, list):
|
|
446
|
+
raise AuthenticationError("dedicated browser returned no cookie collection")
|
|
447
|
+
return paste.session_from_cookie_records(
|
|
448
|
+
raw_cookies,
|
|
449
|
+
base_url=school.base_url,
|
|
450
|
+
harvested_at=datetime_now(),
|
|
451
|
+
user_id=identifier,
|
|
452
|
+
)
|
|
453
|
+
finally:
|
|
454
|
+
try:
|
|
455
|
+
if page_connection is not None:
|
|
456
|
+
page_connection.close()
|
|
457
|
+
finally:
|
|
458
|
+
if owned:
|
|
459
|
+
_close_owned_browser(endpoint, process, profile=profile)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def locate_browser() -> Path:
|
|
463
|
+
"""Locate an installed Chrome/Edge binary without downloading or launching a shell."""
|
|
464
|
+
|
|
465
|
+
candidates: list[Path] = []
|
|
466
|
+
if os.name == "nt":
|
|
467
|
+
candidates.extend(_windows_browser_paths())
|
|
468
|
+
for name in ("chrome.exe", "msedge.exe", "chromium.exe"):
|
|
469
|
+
found = shutil.which(name)
|
|
470
|
+
if found:
|
|
471
|
+
candidates.append(Path(found))
|
|
472
|
+
elif sys.platform == "darwin":
|
|
473
|
+
candidates.extend(
|
|
474
|
+
Path(f"/Applications/{name}.app/Contents/MacOS/{name}")
|
|
475
|
+
for name in _CHROMIUM_EXECUTABLE_BASENAMES
|
|
476
|
+
)
|
|
477
|
+
for name in ("google-chrome", "microsoft-edge", "chromium"):
|
|
478
|
+
found = shutil.which(name)
|
|
479
|
+
if found:
|
|
480
|
+
candidates.append(Path(found))
|
|
481
|
+
else:
|
|
482
|
+
for name in (
|
|
483
|
+
"google-chrome",
|
|
484
|
+
"google-chrome-stable",
|
|
485
|
+
"chromium",
|
|
486
|
+
"chromium-browser",
|
|
487
|
+
"microsoft-edge",
|
|
488
|
+
):
|
|
489
|
+
found = shutil.which(name)
|
|
490
|
+
if found:
|
|
491
|
+
candidates.append(Path(found))
|
|
492
|
+
|
|
493
|
+
for candidate in candidates:
|
|
494
|
+
if paths.long_path(candidate).is_file():
|
|
495
|
+
return candidate
|
|
496
|
+
raise AuthenticationError(
|
|
497
|
+
"Chrome or Edge was not found; install one or use the fallback: a2l auth --paste"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def profile_path() -> Path:
|
|
502
|
+
"""Return the persistent dedicated browser profile location."""
|
|
503
|
+
|
|
504
|
+
return config.data_dir() / "browser-profile"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _acquire_endpoint(profile: Path) -> tuple[DebugEndpoint, _OwnedProcess | None, bool]:
|
|
508
|
+
_validate_profile_path(profile)
|
|
509
|
+
active_port = profile / "DevToolsActivePort"
|
|
510
|
+
if paths.is_link(active_port):
|
|
511
|
+
raise AuthenticationError("DevToolsActivePort must not be a symlink")
|
|
512
|
+
if paths.long_path(active_port).exists():
|
|
513
|
+
return _read_valid_endpoint(active_port, profile=profile), None, False
|
|
514
|
+
if any(paths.long_path(profile / marker).exists() for marker in _LOCK_MARKERS):
|
|
515
|
+
raise AuthenticationError(
|
|
516
|
+
f"dedicated browser profile is locked without a reachable DevTools endpoint: {profile}"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
browser = locate_browser()
|
|
520
|
+
try:
|
|
521
|
+
process = subprocess.Popen(
|
|
522
|
+
[
|
|
523
|
+
os.fspath(paths.long_path(browser)),
|
|
524
|
+
"--remote-debugging-address=127.0.0.1",
|
|
525
|
+
"--remote-debugging-port=0",
|
|
526
|
+
f"--user-data-dir={profile}",
|
|
527
|
+
"--no-first-run",
|
|
528
|
+
"--no-default-browser-check",
|
|
529
|
+
],
|
|
530
|
+
stdin=subprocess.DEVNULL,
|
|
531
|
+
stdout=subprocess.DEVNULL,
|
|
532
|
+
stderr=subprocess.DEVNULL,
|
|
533
|
+
start_new_session=True,
|
|
534
|
+
)
|
|
535
|
+
except OSError as exc:
|
|
536
|
+
raise AuthenticationError("could not launch the dedicated Chrome/Edge profile") from exc
|
|
537
|
+
|
|
538
|
+
deadline = time.monotonic() + ENDPOINT_WAIT_SECONDS
|
|
539
|
+
try:
|
|
540
|
+
while time.monotonic() < deadline:
|
|
541
|
+
if process.poll() is not None:
|
|
542
|
+
raise AuthenticationError(
|
|
543
|
+
"the dedicated browser exited before creating DevToolsActivePort; "
|
|
544
|
+
"use a2l auth --paste"
|
|
545
|
+
)
|
|
546
|
+
if paths.is_link(active_port):
|
|
547
|
+
raise AuthenticationError("DevToolsActivePort must not be a symlink")
|
|
548
|
+
if paths.long_path(active_port).exists():
|
|
549
|
+
try:
|
|
550
|
+
return (
|
|
551
|
+
_read_valid_endpoint(active_port, profile=profile, process=process),
|
|
552
|
+
process,
|
|
553
|
+
True,
|
|
554
|
+
)
|
|
555
|
+
except AuthenticationError:
|
|
556
|
+
pass
|
|
557
|
+
time.sleep(POLL_SECONDS)
|
|
558
|
+
raise AuthenticationError(
|
|
559
|
+
"the dedicated browser did not expose a reachable loopback DevTools endpoint; "
|
|
560
|
+
"use a2l auth --paste"
|
|
561
|
+
)
|
|
562
|
+
except BaseException:
|
|
563
|
+
try:
|
|
564
|
+
_terminate_owned_process(process)
|
|
565
|
+
except AuthenticationError as cleanup_error:
|
|
566
|
+
raise AuthenticationError(
|
|
567
|
+
"dedicated browser could not be cleaned up after endpoint acquisition failed"
|
|
568
|
+
) from cleanup_error
|
|
569
|
+
raise
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _read_valid_endpoint(
|
|
573
|
+
active_port: Path,
|
|
574
|
+
*,
|
|
575
|
+
profile: Path | None = None,
|
|
576
|
+
process: object | None = None,
|
|
577
|
+
) -> DebugEndpoint:
|
|
578
|
+
if profile is None:
|
|
579
|
+
raise AuthenticationError("DevTools endpoint cannot be used without a dedicated profile")
|
|
580
|
+
_validate_profile_path(profile)
|
|
581
|
+
marker = _read_active_port_marker(active_port)
|
|
582
|
+
if marker is None:
|
|
583
|
+
raise AuthenticationError(
|
|
584
|
+
"DevToolsActivePort is stale or invalid; close the dedicated browser normally"
|
|
585
|
+
)
|
|
586
|
+
port = marker.port
|
|
587
|
+
|
|
588
|
+
metadata = _get_json(port, "/json/version")
|
|
589
|
+
browser = metadata.get("Browser")
|
|
590
|
+
websocket_url = metadata.get("webSocketDebuggerUrl")
|
|
591
|
+
if not isinstance(browser, str) or not _browser_metadata_allowed(browser):
|
|
592
|
+
raise AuthenticationError("DevTools endpoint is not the expected Chrome/Edge process")
|
|
593
|
+
if not isinstance(websocket_url, str) or not _loopback_url(websocket_url, port):
|
|
594
|
+
raise AuthenticationError("DevTools endpoint is not a validated loopback browser endpoint")
|
|
595
|
+
if _websocket_path(websocket_url) != marker.browser_websocket_path:
|
|
596
|
+
raise AuthenticationError("DevToolsActivePort does not match the browser endpoint")
|
|
597
|
+
if process is None and not _process_owns_profile(profile, port):
|
|
598
|
+
raise AuthenticationError("DevTools endpoint is not owned by the dedicated profile")
|
|
599
|
+
return DebugEndpoint(port=port, browser_websocket_url=websocket_url)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _read_active_port_marker(active_port: Path) -> _ActivePortMarker | None:
|
|
603
|
+
"""Read Chrome's two-part endpoint marker without exposing its contents."""
|
|
604
|
+
|
|
605
|
+
if paths.is_link(active_port):
|
|
606
|
+
return None
|
|
607
|
+
try:
|
|
608
|
+
with open(os.fspath(paths.long_path(active_port)), encoding="utf-8", newline="") as handle:
|
|
609
|
+
lines = handle.read().splitlines()
|
|
610
|
+
port = int(lines[0].strip())
|
|
611
|
+
browser_websocket_path = _marker_websocket_path(lines[1].strip(), port)
|
|
612
|
+
except (OSError, ValueError, IndexError):
|
|
613
|
+
return None
|
|
614
|
+
if not 1 <= port <= 65535 or browser_websocket_path is None:
|
|
615
|
+
return None
|
|
616
|
+
return _ActivePortMarker(port=port, browser_websocket_path=browser_websocket_path)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _marker_websocket_path(value: str, port: int) -> str | None:
|
|
620
|
+
"""Normalize the path-shaped or full-URL form written by Chromium."""
|
|
621
|
+
|
|
622
|
+
if not value:
|
|
623
|
+
return None
|
|
624
|
+
if "://" in value:
|
|
625
|
+
if not _loopback_url(value, port):
|
|
626
|
+
return None
|
|
627
|
+
path = _websocket_path(value)
|
|
628
|
+
else:
|
|
629
|
+
path = value
|
|
630
|
+
if path is None or not path.startswith("/devtools/browser/"):
|
|
631
|
+
return None
|
|
632
|
+
return path
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _websocket_path(value: str) -> str | None:
|
|
636
|
+
try:
|
|
637
|
+
parsed = urlsplit(value)
|
|
638
|
+
except (TypeError, ValueError, UnicodeError):
|
|
639
|
+
return None
|
|
640
|
+
if parsed.scheme.casefold() not in {"ws", "wss"}:
|
|
641
|
+
return None
|
|
642
|
+
if parsed.query or parsed.fragment or not parsed.path:
|
|
643
|
+
return None
|
|
644
|
+
return parsed.path
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _browser_metadata_allowed(value: str) -> bool:
|
|
648
|
+
"""Accept Chromium's product tokens without substring-matching lookalikes."""
|
|
649
|
+
|
|
650
|
+
return any(
|
|
651
|
+
pattern.fullmatch(value.strip()) is not None for pattern in _BROWSER_METADATA_PATTERNS
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _validate_profile_path(profile: Path) -> None:
|
|
656
|
+
if paths.is_link(profile):
|
|
657
|
+
raise AuthenticationError("dedicated browser profile must not be a symlink")
|
|
658
|
+
if paths.long_path(profile).exists() and not paths.long_path(profile).is_dir():
|
|
659
|
+
raise AuthenticationError("dedicated browser profile is not a directory")
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _process_owns_profile(profile: Path, port: int) -> bool:
|
|
663
|
+
return any(
|
|
664
|
+
_command_matches_profile(command, profile, port) for command in _running_process_commands()
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _running_process_commands() -> list[str]:
|
|
669
|
+
if os.name == "nt":
|
|
670
|
+
executable = shutil.which("powershell") or shutil.which("pwsh")
|
|
671
|
+
if executable is None:
|
|
672
|
+
return []
|
|
673
|
+
command = "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine"
|
|
674
|
+
args = [executable, "-NoProfile", "-NonInteractive", "-Command", command]
|
|
675
|
+
else:
|
|
676
|
+
args = ["ps", "-axo", "args="]
|
|
677
|
+
try:
|
|
678
|
+
result = subprocess.run(
|
|
679
|
+
args,
|
|
680
|
+
check=False,
|
|
681
|
+
capture_output=True,
|
|
682
|
+
text=True,
|
|
683
|
+
encoding="utf-8",
|
|
684
|
+
timeout=5,
|
|
685
|
+
)
|
|
686
|
+
except (OSError, subprocess.SubprocessError):
|
|
687
|
+
return []
|
|
688
|
+
if result.returncode != 0 or not isinstance(result.stdout, str):
|
|
689
|
+
return []
|
|
690
|
+
return [line for line in result.stdout.splitlines() if line.strip()]
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _command_matches_profile(command: str, profile: Path, port: int) -> bool:
|
|
694
|
+
lowered = command.casefold()
|
|
695
|
+
if not any(marker.casefold() in lowered for marker in ("chrome", "chromium", "msedge")):
|
|
696
|
+
return False
|
|
697
|
+
normalized_command = command.replace("\\", "/")
|
|
698
|
+
expected_paths = {os.path.abspath(os.fspath(profile))}
|
|
699
|
+
with suppress(OSError, RuntimeError):
|
|
700
|
+
expected_paths.add(os.fspath(profile.resolve()))
|
|
701
|
+
normalized_expected_paths = {
|
|
702
|
+
expected.replace("\\", "/").casefold() for expected in expected_paths
|
|
703
|
+
}
|
|
704
|
+
profile_matches = any(
|
|
705
|
+
_has_exact_argument(
|
|
706
|
+
normalized_command.casefold(),
|
|
707
|
+
f"--user-data-dir={expected}",
|
|
708
|
+
)
|
|
709
|
+
or _has_exact_argument(
|
|
710
|
+
normalized_command.casefold(),
|
|
711
|
+
f'--user-data-dir="{expected}"',
|
|
712
|
+
)
|
|
713
|
+
for expected in normalized_expected_paths
|
|
714
|
+
)
|
|
715
|
+
if not profile_matches:
|
|
716
|
+
return False
|
|
717
|
+
remote_match = re.search(r"(?:^|\s)--remote-debugging-port(?:=|\s+)(\d+)(?=\s|$)", lowered)
|
|
718
|
+
if remote_match is None:
|
|
719
|
+
return False
|
|
720
|
+
return int(remote_match.group(1)) in {0, port}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _has_exact_argument(command: str, marker: str) -> bool:
|
|
724
|
+
"""Match an option value without accepting a longer lookalike path/value."""
|
|
725
|
+
start = 0
|
|
726
|
+
while (position := command.find(marker, start)) != -1:
|
|
727
|
+
before_ok = position == 0 or command[position - 1].isspace()
|
|
728
|
+
end = position + len(marker)
|
|
729
|
+
after_ok = end == len(command) or command[end].isspace() or command[end] in {'"', "'"}
|
|
730
|
+
if before_ok and after_ok:
|
|
731
|
+
return True
|
|
732
|
+
start = position + 1
|
|
733
|
+
return False
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _wait_for_target(port: int, target_id: str) -> str:
|
|
737
|
+
"""Return the validated page websocket for one newly-created target."""
|
|
738
|
+
|
|
739
|
+
deadline = time.monotonic() + ENDPOINT_WAIT_SECONDS
|
|
740
|
+
while time.monotonic() < deadline:
|
|
741
|
+
try:
|
|
742
|
+
targets = _get_json_list(port, "/json/list")
|
|
743
|
+
except AuthenticationError:
|
|
744
|
+
time.sleep(POLL_SECONDS)
|
|
745
|
+
continue
|
|
746
|
+
for target in targets:
|
|
747
|
+
if target.get("type") != "page" or target.get("id") != target_id:
|
|
748
|
+
continue
|
|
749
|
+
websocket_url = target.get("webSocketDebuggerUrl")
|
|
750
|
+
if isinstance(websocket_url, str) and _loopback_url(websocket_url, port):
|
|
751
|
+
return websocket_url
|
|
752
|
+
time.sleep(POLL_SECONDS)
|
|
753
|
+
raise AuthenticationError("dedicated browser outline target is not reachable")
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _wait_for_page(port: int) -> str:
|
|
757
|
+
deadline = time.monotonic() + ENDPOINT_WAIT_SECONDS
|
|
758
|
+
while time.monotonic() < deadline:
|
|
759
|
+
try:
|
|
760
|
+
targets = _get_json_list(port, "/json/list")
|
|
761
|
+
except AuthenticationError:
|
|
762
|
+
time.sleep(POLL_SECONDS)
|
|
763
|
+
continue
|
|
764
|
+
for target in targets:
|
|
765
|
+
if not isinstance(target, dict) or target.get("type") != "page":
|
|
766
|
+
continue
|
|
767
|
+
websocket_url = target.get("webSocketDebuggerUrl")
|
|
768
|
+
if isinstance(websocket_url, str) and _loopback_url(websocket_url, port):
|
|
769
|
+
return websocket_url
|
|
770
|
+
time.sleep(POLL_SECONDS)
|
|
771
|
+
raise AuthenticationError(
|
|
772
|
+
"dedicated browser has no reachable page target; use a2l auth --paste"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _wait_for_authenticated_page(connection: _CDPCommandConnection, gate: _AuthGate) -> str:
|
|
777
|
+
deadline = time.monotonic() + AUTH_WAIT_SECONDS
|
|
778
|
+
runtime_retry_deadline = min(deadline, time.monotonic() + RUNTIME_RETRY_SECONDS)
|
|
779
|
+
while time.monotonic() < deadline:
|
|
780
|
+
try:
|
|
781
|
+
result = _call_with_gate(
|
|
782
|
+
connection,
|
|
783
|
+
"Runtime.evaluate",
|
|
784
|
+
{
|
|
785
|
+
"expression": _AUTH_EXPRESSION,
|
|
786
|
+
"awaitPromise": True,
|
|
787
|
+
"returnByValue": True,
|
|
788
|
+
},
|
|
789
|
+
gate,
|
|
790
|
+
)
|
|
791
|
+
except _CDPCommandError as exc:
|
|
792
|
+
gate.raise_if_blocked()
|
|
793
|
+
if not exc.retryable or time.monotonic() >= runtime_retry_deadline:
|
|
794
|
+
raise
|
|
795
|
+
time.sleep(POLL_SECONDS)
|
|
796
|
+
continue
|
|
797
|
+
identifier = _evaluated_identifier(result)
|
|
798
|
+
if identifier is not None:
|
|
799
|
+
return identifier
|
|
800
|
+
time.sleep(POLL_SECONDS)
|
|
801
|
+
gate.raise_if_any_blocked()
|
|
802
|
+
raise AuthenticationError("LEARN login was not verified before the authentication timeout")
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _runtime_error_is_transient(method: str, error: dict[str, Any]) -> bool:
|
|
806
|
+
"""Recognize only Chromium's navigation/context race for bounded evaluation retry."""
|
|
807
|
+
|
|
808
|
+
if method != "Runtime.evaluate" or error.get("code") != -32000:
|
|
809
|
+
return False
|
|
810
|
+
message = error.get("message")
|
|
811
|
+
if not isinstance(message, str):
|
|
812
|
+
return False
|
|
813
|
+
lowered = message.casefold()
|
|
814
|
+
return "target" in lowered or "context" in lowered
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _evaluated_identifier(result: dict[str, Any]) -> str | None:
|
|
818
|
+
outer = result.get("result")
|
|
819
|
+
if not isinstance(outer, dict):
|
|
820
|
+
return None
|
|
821
|
+
value = outer.get("value")
|
|
822
|
+
if not isinstance(value, dict) or value.get("ok") is not True:
|
|
823
|
+
return None
|
|
824
|
+
identifier = value.get("identifier")
|
|
825
|
+
return identifier if isinstance(identifier, str) and identifier else None
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _close_owned_browser(
|
|
829
|
+
endpoint: DebugEndpoint,
|
|
830
|
+
process: _OwnedProcess | None,
|
|
831
|
+
*,
|
|
832
|
+
profile: Path | None = None,
|
|
833
|
+
) -> None:
|
|
834
|
+
if process is None:
|
|
835
|
+
return
|
|
836
|
+
connection: _CDPConnection | None = None
|
|
837
|
+
try:
|
|
838
|
+
connection = _CDPConnection(endpoint.browser_websocket_url)
|
|
839
|
+
connection.call("Browser.close")
|
|
840
|
+
except AuthenticationError:
|
|
841
|
+
# The DevTools connection can disappear before Browser.close reaches Chromium. The
|
|
842
|
+
# process is still ours, so always fall through to the bounded OS-level cleanup below.
|
|
843
|
+
pass
|
|
844
|
+
finally:
|
|
845
|
+
if connection is not None:
|
|
846
|
+
connection.close()
|
|
847
|
+
try:
|
|
848
|
+
process.wait(timeout=ENDPOINT_WAIT_SECONDS)
|
|
849
|
+
except subprocess.TimeoutExpired:
|
|
850
|
+
_terminate_owned_process(process)
|
|
851
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
852
|
+
raise AuthenticationError("dedicated browser process status could not be read") from exc
|
|
853
|
+
if profile is not None:
|
|
854
|
+
_remove_owned_active_port(profile, endpoint)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _remove_owned_active_port(profile: Path, endpoint: DebugEndpoint) -> None:
|
|
858
|
+
"""Remove only this launch's marker after its owned process has exited.
|
|
859
|
+
|
|
860
|
+
A marker seen before a launch is never cleaned here. This helper is reached only with an
|
|
861
|
+
endpoint that was validated while this process was the owner, and it additionally compares
|
|
862
|
+
both the assigned port and Chromium's browser websocket path immediately before unlinking.
|
|
863
|
+
"""
|
|
864
|
+
|
|
865
|
+
active_port = profile / "DevToolsActivePort"
|
|
866
|
+
if paths.is_link(active_port):
|
|
867
|
+
return
|
|
868
|
+
marker = _read_active_port_marker(active_port)
|
|
869
|
+
endpoint_path = _websocket_path(endpoint.browser_websocket_url)
|
|
870
|
+
if marker is None or endpoint_path is None:
|
|
871
|
+
return
|
|
872
|
+
if marker.port != endpoint.port or marker.browser_websocket_path != endpoint_path:
|
|
873
|
+
return
|
|
874
|
+
if paths.is_link(active_port):
|
|
875
|
+
return
|
|
876
|
+
try:
|
|
877
|
+
os.unlink(os.fspath(paths.long_path(active_port)))
|
|
878
|
+
except FileNotFoundError:
|
|
879
|
+
return
|
|
880
|
+
except OSError:
|
|
881
|
+
# Cleanup is best-effort. A foreign process or platform service may have replaced/opened
|
|
882
|
+
# the marker after our process exited; never turn that race into a broad deletion attempt.
|
|
883
|
+
return
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _terminate_owned_process(process: _OwnedProcess) -> None:
|
|
887
|
+
"""Bound cleanup for a browser process Agent2Learn launched itself."""
|
|
888
|
+
with suppress(OSError, subprocess.SubprocessError):
|
|
889
|
+
process.terminate()
|
|
890
|
+
try:
|
|
891
|
+
process.wait(timeout=ENDPOINT_WAIT_SECONDS)
|
|
892
|
+
return
|
|
893
|
+
except subprocess.TimeoutExpired:
|
|
894
|
+
pass
|
|
895
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
896
|
+
raise AuthenticationError("dedicated browser process status could not be read") from exc
|
|
897
|
+
|
|
898
|
+
try:
|
|
899
|
+
process.kill()
|
|
900
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
901
|
+
raise AuthenticationError(
|
|
902
|
+
"dedicated browser could not be terminated after session harvest"
|
|
903
|
+
) from exc
|
|
904
|
+
try:
|
|
905
|
+
process.wait(timeout=ENDPOINT_WAIT_SECONDS)
|
|
906
|
+
except subprocess.TimeoutExpired as exc:
|
|
907
|
+
raise AuthenticationError("dedicated browser did not close after session harvest") from exc
|
|
908
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
909
|
+
raise AuthenticationError("dedicated browser process status could not be read") from exc
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _get_json(port: int, route: str) -> dict[str, Any]:
|
|
913
|
+
response = _local_request(port, route)
|
|
914
|
+
try:
|
|
915
|
+
payload = response.json()
|
|
916
|
+
except ValueError as exc:
|
|
917
|
+
raise AuthenticationError("DevTools endpoint returned invalid metadata") from exc
|
|
918
|
+
finally:
|
|
919
|
+
response.close()
|
|
920
|
+
if not isinstance(payload, dict):
|
|
921
|
+
raise AuthenticationError("DevTools endpoint returned invalid metadata")
|
|
922
|
+
return payload
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _get_json_list(port: int, route: str) -> list[dict[str, Any]]:
|
|
926
|
+
response = _local_request(port, route)
|
|
927
|
+
try:
|
|
928
|
+
payload = response.json()
|
|
929
|
+
except ValueError as exc:
|
|
930
|
+
raise AuthenticationError("DevTools endpoint returned invalid target metadata") from exc
|
|
931
|
+
finally:
|
|
932
|
+
response.close()
|
|
933
|
+
if not isinstance(payload, list):
|
|
934
|
+
raise AuthenticationError("DevTools endpoint returned invalid target metadata")
|
|
935
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _local_request(port: int, route: str) -> requests.Response:
|
|
939
|
+
transport = requests.Session()
|
|
940
|
+
transport.trust_env = False
|
|
941
|
+
try:
|
|
942
|
+
response = transport.get(
|
|
943
|
+
f"http://127.0.0.1:{port}{route}",
|
|
944
|
+
timeout=CDP_TIMEOUT,
|
|
945
|
+
allow_redirects=False,
|
|
946
|
+
)
|
|
947
|
+
except requests.RequestException as exc:
|
|
948
|
+
raise AuthenticationError("dedicated browser DevTools endpoint is unreachable") from exc
|
|
949
|
+
if not 200 <= response.status_code < 300:
|
|
950
|
+
response.close()
|
|
951
|
+
raise AuthenticationError("dedicated browser DevTools endpoint rejected the request")
|
|
952
|
+
return response
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _loopback_url(value: str, expected_port: int) -> bool:
|
|
956
|
+
try:
|
|
957
|
+
parsed = urlsplit(value)
|
|
958
|
+
port = parsed.port
|
|
959
|
+
except ValueError:
|
|
960
|
+
return False
|
|
961
|
+
if parsed.scheme.casefold() not in {"ws", "wss"} or parsed.hostname is None:
|
|
962
|
+
return False
|
|
963
|
+
if parsed.hostname.casefold() not in {"127.0.0.1", "localhost", "::1"}:
|
|
964
|
+
return False
|
|
965
|
+
return port == expected_port
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _auth_url_allowed(value: str, school: School) -> bool:
|
|
969
|
+
try:
|
|
970
|
+
parsed = urlsplit(value)
|
|
971
|
+
if parsed.scheme.casefold() != "https" or parsed.hostname is None:
|
|
972
|
+
return False
|
|
973
|
+
host = _canonical_hostname(parsed.hostname)
|
|
974
|
+
base = urlsplit(school.base_url)
|
|
975
|
+
base_port = base.port or 443
|
|
976
|
+
target_port = parsed.port or 443
|
|
977
|
+
if host == _canonical_hostname(base.hostname or "") and target_port == base_port:
|
|
978
|
+
return True
|
|
979
|
+
return target_port == 443 and any(
|
|
980
|
+
_host_boundary_match(host, _canonical_hostname(_host_from_value(item)))
|
|
981
|
+
for item in school.auth_hosts()
|
|
982
|
+
)
|
|
983
|
+
except (TypeError, ValueError, UnicodeError):
|
|
984
|
+
return False
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _host_from_value(value: str) -> str:
|
|
988
|
+
candidate = value if "://" in value else f"https://{value}"
|
|
989
|
+
parsed = urlsplit(candidate)
|
|
990
|
+
if parsed.hostname is None:
|
|
991
|
+
raise ValueError
|
|
992
|
+
return parsed.hostname
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _host_boundary_match(host: str, allowed: str) -> bool:
|
|
996
|
+
return host == allowed or host.endswith(f".{allowed}")
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _safe_hostname(value: str) -> str:
|
|
1000
|
+
try:
|
|
1001
|
+
parsed = urlsplit(value)
|
|
1002
|
+
if parsed.hostname is None:
|
|
1003
|
+
return "unknown-host"
|
|
1004
|
+
return _canonical_hostname(parsed.hostname)
|
|
1005
|
+
except (TypeError, ValueError, UnicodeError):
|
|
1006
|
+
return "unknown-host"
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _canonical_hostname(value: str) -> str:
|
|
1010
|
+
return value.rstrip(".").encode("idna").decode("ascii").casefold()
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _windows_browser_paths() -> list[Path]:
|
|
1014
|
+
if os.name != "nt":
|
|
1015
|
+
return []
|
|
1016
|
+
paths: list[Path] = []
|
|
1017
|
+
try:
|
|
1018
|
+
import importlib
|
|
1019
|
+
|
|
1020
|
+
winreg: Any = importlib.import_module("winreg")
|
|
1021
|
+
|
|
1022
|
+
for executable in ("chrome.exe", "msedge.exe"):
|
|
1023
|
+
for root in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
|
|
1024
|
+
for subkey in (
|
|
1025
|
+
rf"Software\Microsoft\Windows\CurrentVersion\App Paths\{executable}",
|
|
1026
|
+
rf"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths"
|
|
1027
|
+
f"\\{executable}",
|
|
1028
|
+
):
|
|
1029
|
+
try:
|
|
1030
|
+
with winreg.OpenKey(root, subkey) as key:
|
|
1031
|
+
value, _ = winreg.QueryValueEx(key, None)
|
|
1032
|
+
except OSError:
|
|
1033
|
+
continue
|
|
1034
|
+
if isinstance(value, str):
|
|
1035
|
+
paths.append(Path(value))
|
|
1036
|
+
except ImportError: # pragma: no cover - winreg exists on Windows
|
|
1037
|
+
return paths
|
|
1038
|
+
for variable in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
|
1039
|
+
root = os.environ.get(variable)
|
|
1040
|
+
if not root:
|
|
1041
|
+
continue
|
|
1042
|
+
paths.extend(
|
|
1043
|
+
Path(root) / relative
|
|
1044
|
+
for relative in (
|
|
1045
|
+
"Google/Chrome/Application/chrome.exe",
|
|
1046
|
+
"Microsoft/Edge/Application/msedge.exe",
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
return paths
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def datetime_now() -> datetime:
|
|
1053
|
+
"""Keep the timestamp construction in one tiny seam for deterministic unit tests."""
|
|
1054
|
+
|
|
1055
|
+
return datetime.now(UTC)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
__all__ = [
|
|
1059
|
+
"AUTH_WAIT_SECONDS",
|
|
1060
|
+
"CDP_TIMEOUT",
|
|
1061
|
+
"ENDPOINT_WAIT_SECONDS",
|
|
1062
|
+
"DebugEndpoint",
|
|
1063
|
+
"DedicatedPageFactory",
|
|
1064
|
+
"authenticate_browser",
|
|
1065
|
+
"locate_browser",
|
|
1066
|
+
"profile_path",
|
|
1067
|
+
]
|