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.
Files changed (46) hide show
  1. agent2learn/__init__.py +3 -0
  2. agent2learn/_release.py +19 -0
  3. agent2learn/aipolicy.py +182 -0
  4. agent2learn/api.py +590 -0
  5. agent2learn/audit.py +358 -0
  6. agent2learn/auth/__init__.py +282 -0
  7. agent2learn/auth/cdp.py +1067 -0
  8. agent2learn/auth/paste.py +378 -0
  9. agent2learn/calendar.py +525 -0
  10. agent2learn/calibrate.py +347 -0
  11. agent2learn/check.py +1091 -0
  12. agent2learn/cli.py +2039 -0
  13. agent2learn/clock.py +39 -0
  14. agent2learn/config.py +205 -0
  15. agent2learn/console.py +229 -0
  16. agent2learn/convert.py +1223 -0
  17. agent2learn/doctor.py +1167 -0
  18. agent2learn/errors.py +32 -0
  19. agent2learn/ground.py +735 -0
  20. agent2learn/index.py +614 -0
  21. agent2learn/ingest.py +3229 -0
  22. agent2learn/locations.py +247 -0
  23. agent2learn/outlines.py +754 -0
  24. agent2learn/paths.py +683 -0
  25. agent2learn/pipeline.py +392 -0
  26. agent2learn/privacy.py +1123 -0
  27. agent2learn/schools/__init__.py +29 -0
  28. agent2learn/schools/_base.py +194 -0
  29. agent2learn/schools/generic.py +78 -0
  30. agent2learn/schools/uwaterloo.py +66 -0
  31. agent2learn/session.py +373 -0
  32. agent2learn/skills.py +1081 -0
  33. agent2learn/snapshot.py +399 -0
  34. agent2learn/submit.py +1047 -0
  35. agent2learn/transactions.py +157 -0
  36. agent2learn/upgrade.py +288 -0
  37. agent2learn/vault.py +1134 -0
  38. agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
  39. agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
  40. agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
  41. agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
  42. agent2learn-0.1.2.dist-info/METADATA +186 -0
  43. agent2learn-0.1.2.dist-info/RECORD +46 -0
  44. agent2learn-0.1.2.dist-info/WHEEL +4 -0
  45. agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
  46. agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
@@ -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
+ ]