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,754 @@
1
+ """Bounded outline rendering through Agent2Learn's dedicated local CDP profile.
2
+
3
+ The production factory may acquire or launch only the persistent Agent2Learn browser profile; it
4
+ never reads or attaches to an everyday profile. Every outline gets a fresh page target and CDP
5
+ connection. Top-level and subresource URLs are checked at the request boundary, and a blocked
6
+ dependency makes the outline unavailable instead of producing a false "no policy" conclusion.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import json
13
+ import os
14
+ import re
15
+ import time
16
+ from collections.abc import Mapping, Sequence
17
+ from dataclasses import dataclass, replace
18
+ from hashlib import sha256
19
+ from pathlib import Path
20
+ from typing import Any, Protocol, cast
21
+ from urllib.parse import urlsplit, urlunsplit
22
+
23
+ from agent2learn import aipolicy, clock, paths, transactions
24
+ from agent2learn import index as course_index
25
+ from agent2learn.errors import AuthenticationError
26
+ from agent2learn.ingest import (
27
+ CourseMetadata,
28
+ MetadataReport,
29
+ OutlineReport,
30
+ TopicRecord,
31
+ _content_directory,
32
+ _sanitize_richtext,
33
+ _topic_from_row,
34
+ _write_content_map,
35
+ _write_index,
36
+ )
37
+ from agent2learn.schools import School
38
+ from agent2learn.vault import DerivedArtifact, ManifestEntry, Vault
39
+
40
+ OUTLINE_TIMEOUT_SECONDS = 30.0
41
+ OUTLINE_TOOL_VERSION = "1"
42
+ _OUTLINE_WORDS = re.compile(r"\b(?:outline|syllabus|course\s+schedule)\b", re.IGNORECASE)
43
+ _LOGIN_MARKERS = (
44
+ re.compile(r"<title[^>]*>\s*(?:sign\s*in|log\s*in|login)", re.IGNORECASE),
45
+ re.compile(r"<form[^>]+(?:action|id|class)=[^>]*(?:login|signin|d2l)", re.IGNORECASE),
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class OutlinePage:
51
+ """A rendered DOM and the request audit returned by a CDP transport."""
52
+
53
+ html: str
54
+ canonical_url: str
55
+ pdf: bytes | None = None
56
+ subresources: tuple[str, ...] = ()
57
+ top_level_requests: tuple[str, ...] = ()
58
+ popups: tuple[str, ...] = ()
59
+
60
+
61
+ class OutlineBrowser(Protocol):
62
+ """The narrow one-target browser capability required by :func:`ingest_outlines`."""
63
+
64
+ def render_outline(
65
+ self,
66
+ url: str,
67
+ *,
68
+ allowed_hosts: Sequence[str],
69
+ timeout: float,
70
+ ) -> OutlinePage | Mapping[str, object]:
71
+ """Render one target and return the final DOM plus its request audit."""
72
+
73
+ def close_target(self) -> None:
74
+ """Close this target and its page connection before another target is opened."""
75
+
76
+
77
+ class OutlineBrowserFactory(Protocol):
78
+ """Create a new one-target browser connection for every discovered outline."""
79
+
80
+ def open_browser(self) -> OutlineBrowser:
81
+ """Return a browser backed by a newly-created page target."""
82
+
83
+ def close(self) -> None:
84
+ """Release factory-owned dedicated-profile resources after all targets are closed."""
85
+
86
+
87
+ def ingest_outlines(
88
+ factory: OutlineBrowserFactory,
89
+ vault: Vault,
90
+ school: School,
91
+ metadata: MetadataReport,
92
+ ) -> OutlineReport:
93
+ """Render discovered outlines after metadata, using a fresh target for every attempt."""
94
+
95
+ if not callable(getattr(factory, "open_browser", None)) or not callable(
96
+ getattr(factory, "close", None)
97
+ ):
98
+ raise TypeError("ingest_outlines requires an OutlineBrowserFactory")
99
+ rendered = 0
100
+ unavailable = 0
101
+ errors: list[str] = []
102
+ stop_after_cleanup_failure = False
103
+ try:
104
+ for course_metadata in metadata.courses:
105
+ status_rows: list[dict[str, object]] = []
106
+ for topic in _outline_topics(course_metadata.topics):
107
+ if stop_after_cleanup_failure:
108
+ unavailable += 1
109
+ status_rows.append(
110
+ _status(
111
+ topic,
112
+ "outline_unavailable",
113
+ "previous target cleanup failed",
114
+ )
115
+ )
116
+ continue
117
+
118
+ url = _topic_outline_url(topic, school)
119
+ if url is None:
120
+ unavailable += 1
121
+ status_rows.append(_status(topic, "outline_unavailable", "unsafe URL"))
122
+ continue
123
+
124
+ try:
125
+ target: OutlineBrowser = factory.open_browser()
126
+ except AuthenticationError as exc:
127
+ # The dedicated profile is deliberately not enabled; that is a declined
128
+ # capability, not a broken renderer.
129
+ unavailable += 1
130
+ status_rows.append(_status(topic, "outline_unavailable", type(exc).__name__))
131
+ continue
132
+ except Exception as exc:
133
+ unavailable += 1
134
+ errors.append(f"outline: browser target failed ({type(exc).__name__})")
135
+ status_rows.append(_status(topic, "outline_unavailable", type(exc).__name__))
136
+ continue
137
+
138
+ status: dict[str, object]
139
+ install_error: str | None = None
140
+ try:
141
+ try:
142
+ page = _render(target, url, school)
143
+ page = _validate_page(page, school)
144
+ except Exception as exc:
145
+ # A remote render or validation failure is a per-topic coverage gap,
146
+ # not evidence that the local vault or renderer is broken.
147
+ unavailable += 1
148
+ status = _status(topic, "outline_unavailable", type(exc).__name__)
149
+ else:
150
+ try:
151
+ source_path, markdown_path = _install_outline(
152
+ vault, school, course_metadata, topic, page
153
+ )
154
+ except Exception as exc:
155
+ # The outline rendered but could not be persisted locally; the
156
+ # vault write failure must fail the sync, not hide as a gap.
157
+ unavailable += 1
158
+ install_error = type(exc).__name__
159
+ status = _status(topic, "outline_unavailable", type(exc).__name__)
160
+ else:
161
+ rendered += 1
162
+ status = {
163
+ "source_key": topic.source_key,
164
+ "url": page.canonical_url,
165
+ "status": "rendered",
166
+ "source_path": source_path,
167
+ "path": markdown_path,
168
+ }
169
+ finally:
170
+ close_error = _close_target(target)
171
+
172
+ if install_error is not None:
173
+ errors.append(f"outline: local install failed ({install_error})")
174
+
175
+ if close_error is not None:
176
+ errors.append(f"outline: target cleanup failed ({close_error})")
177
+ status["cleanup_error"] = "target could not be closed"
178
+ stop_after_cleanup_failure = True
179
+ status_rows.append(status)
180
+
181
+ _write_json(
182
+ course_metadata.directory / "_meta" / "outlines.json",
183
+ status_rows,
184
+ root=vault.root,
185
+ )
186
+ rendered_paths = [
187
+ vault.root / path
188
+ for row in status_rows
189
+ if row.get("status") == "rendered" and isinstance((path := row.get("path")), str)
190
+ ]
191
+ aipolicy.surface_course_ai_policy(
192
+ course_metadata.directory, rendered_paths, root=vault.root
193
+ )
194
+ finally:
195
+ factory_error = _close_factory(factory)
196
+
197
+ if factory_error is not None:
198
+ errors.append(f"outline: browser cleanup failed ({factory_error})")
199
+ return OutlineReport(rendered=rendered, unavailable=unavailable, errors=tuple(errors))
200
+
201
+
202
+ def _close_factory(factory: OutlineBrowserFactory) -> str | None:
203
+ try:
204
+ factory.close()
205
+ except Exception as exc:
206
+ return type(exc).__name__
207
+ return None
208
+
209
+
210
+ def _outline_topics(topics: Sequence[TopicRecord]) -> list[TopicRecord]:
211
+ return sorted(
212
+ (
213
+ topic
214
+ for topic in topics
215
+ if topic.availability != "external_link"
216
+ and (_OUTLINE_WORDS.search(topic.title) or topic.outline_url)
217
+ ),
218
+ key=lambda topic: topic.source_key,
219
+ )
220
+
221
+
222
+ def _topic_outline_url(topic: TopicRecord, school: School) -> str | None:
223
+ if topic.outline_url:
224
+ return _normalize_allowed_url(topic.outline_url, school)
225
+ if topic.url_path:
226
+ return _normalize_allowed_url(
227
+ f"{school.base_url.rstrip('/')}/{topic.url_path.lstrip('/')}", school
228
+ )
229
+ return _normalize_allowed_url(topic.view_url, school)
230
+
231
+
232
+ def _normalize_allowed_url(value: str, school: School) -> str | None:
233
+ try:
234
+ parsed = urlsplit(value)
235
+ if (
236
+ parsed.scheme.casefold() != "https"
237
+ or parsed.username is not None
238
+ or parsed.password is not None
239
+ or parsed.hostname is None
240
+ ):
241
+ return None
242
+ if not _host_allowed(value, school):
243
+ return None
244
+ host = parsed.hostname.encode("idna").decode("ascii").casefold().rstrip(".")
245
+ port = parsed.port
246
+ netloc = host if port is None else f"{host}:{port}"
247
+ return urlunsplit(("https", netloc, parsed.path or "/", "", ""))
248
+ except (TypeError, UnicodeError, ValueError):
249
+ return None
250
+
251
+
252
+ def _host_allowed(value: str, school: School) -> bool:
253
+ return _allowed_host_from_list(value, (school.base_url, *school.outline_hosts()))
254
+
255
+
256
+ def _render(browser: OutlineBrowser, url: str, school: School) -> OutlinePage:
257
+ result = browser.render_outline(
258
+ url,
259
+ allowed_hosts=(school.base_url, *school.outline_hosts()),
260
+ timeout=OUTLINE_TIMEOUT_SECONDS,
261
+ )
262
+ return _coerce_page(result)
263
+
264
+
265
+ def _coerce_page(value: OutlinePage | Mapping[str, object]) -> OutlinePage:
266
+ if isinstance(value, OutlinePage):
267
+ return value
268
+ if not isinstance(value, Mapping):
269
+ raise ValueError("browser returned an invalid outline page")
270
+ body = value.get("html")
271
+ canonical_url = value.get("canonical_url", value.get("url"))
272
+ if not isinstance(body, str) or not isinstance(canonical_url, str):
273
+ raise ValueError("browser returned an incomplete outline page")
274
+ pdf = value.get("pdf")
275
+ if pdf is not None and not isinstance(pdf, bytes):
276
+ raise ValueError("browser returned an invalid outline PDF")
277
+ return OutlinePage(
278
+ html=body,
279
+ canonical_url=canonical_url,
280
+ pdf=pdf,
281
+ subresources=_string_tuple(value.get("subresources"), "subresources"),
282
+ top_level_requests=_string_tuple(value.get("top_level_requests"), "top_level_requests"),
283
+ popups=_string_tuple(value.get("popups"), "popups"),
284
+ )
285
+
286
+
287
+ def _string_tuple(value: object, label: str) -> tuple[str, ...]:
288
+ if value is None:
289
+ return ()
290
+ if not isinstance(value, (list, tuple)) or any(not isinstance(item, str) for item in value):
291
+ raise ValueError(f"browser returned invalid {label}")
292
+ return tuple(value)
293
+
294
+
295
+ def _validate_page(page: OutlinePage, school: School) -> OutlinePage:
296
+ canonical_url = _normalize_allowed_url(page.canonical_url, school)
297
+ if canonical_url is None:
298
+ raise ValueError("outline redirected to an undeclared origin")
299
+ requests = (*page.subresources, *page.top_level_requests)
300
+ if any(_normalize_allowed_url(value, school) is None for value in requests):
301
+ raise ValueError("outline requested an undeclared dependency")
302
+ if page.popups:
303
+ raise ValueError("outline opened a popup")
304
+ if any(marker.search(page.html) for marker in _LOGIN_MARKERS):
305
+ raise ValueError("outline reached the sign-in wall")
306
+ if not page.html.strip() and page.pdf is None:
307
+ raise ValueError("outline render was empty")
308
+ # Persist the normalized URL, not the browser's raw location, because a same-origin redirect
309
+ # can still carry signed query strings or fragments that are not part of the citation.
310
+ return replace(page, canonical_url=canonical_url)
311
+
312
+
313
+ def _install_outline(
314
+ vault: Vault,
315
+ school: School,
316
+ metadata: CourseMetadata,
317
+ topic: TopicRecord,
318
+ page: OutlinePage,
319
+ ) -> tuple[str, str]:
320
+ transactions.recover_generated(vault, topic.source_key)
321
+ prior = vault.entry(topic.source_key)
322
+ if page.pdf is None:
323
+ # A rendered outline is untrusted course HTML. Persist the inert canonical form, not
324
+ # scripts, event handlers, remote-image loads, credentials, or signed query strings.
325
+ page = replace(page, html=_sanitize_richtext(page.html, school.base_url))
326
+ source_bytes = page.pdf if page.pdf is not None else page.html.encode("utf-8")
327
+ source_hash = sha256(source_bytes).hexdigest()
328
+ source_destination = _source_destination(vault, metadata, topic, prior, page)
329
+ markdown_destination = vault.derived_destination(
330
+ topic.source_key, _markdown_destination(vault, source_destination, prior)
331
+ )
332
+ markdown_bytes = _outline_markdown(topic.title, page)
333
+ markdown_hash = sha256(markdown_bytes).hexdigest()
334
+ if prior is not None and _outline_is_current(
335
+ vault,
336
+ prior,
337
+ source_destination=source_destination,
338
+ source_hash=source_hash,
339
+ markdown_destination=markdown_destination,
340
+ markdown_hash=markdown_hash,
341
+ ):
342
+ _update_topic_map(vault, metadata, topic, prior, school)
343
+ artifact = prior.derived["markdown"]
344
+ return prior.path, artifact.path
345
+
346
+ preserve = prior is not None and _outline_needs_preservation(
347
+ vault, prior, source_hash, markdown_hash
348
+ )
349
+ derived = DerivedArtifact(
350
+ path=paths.rel_posix(markdown_destination, vault.root),
351
+ sha256=markdown_hash,
352
+ source_sha256=source_hash,
353
+ tool="outline-renderer",
354
+ tool_version=OUTLINE_TOOL_VERSION,
355
+ created_at=_now(),
356
+ )
357
+ entry = ManifestEntry(
358
+ path=paths.rel_posix(source_destination, vault.root),
359
+ sha256=source_hash,
360
+ source_id=topic.source_id,
361
+ etag=topic.etag,
362
+ last_modified=topic.last_modified,
363
+ size=len(source_bytes),
364
+ fetched_at=_now(),
365
+ derived={"markdown": derived},
366
+ )
367
+ transactions.install_generated(
368
+ vault,
369
+ topic.source_key,
370
+ entry,
371
+ source_bytes,
372
+ {"markdown": markdown_bytes},
373
+ preserve=preserve,
374
+ )
375
+ _update_topic_map(vault, metadata, topic, entry, school)
376
+ return entry.path, derived.path
377
+
378
+
379
+ def _outline_is_current(
380
+ vault: Vault,
381
+ prior: ManifestEntry,
382
+ *,
383
+ source_destination: Path,
384
+ source_hash: str,
385
+ markdown_destination: Path,
386
+ markdown_hash: str,
387
+ ) -> bool:
388
+ """Require both generated files and their manifest provenance to match exactly."""
389
+
390
+ artifact = prior.derived.get("markdown")
391
+ if artifact is None:
392
+ return False
393
+ if (
394
+ prior.path != paths.rel_posix(source_destination, vault.root)
395
+ or prior.sha256 != source_hash
396
+ or artifact.path != paths.rel_posix(markdown_destination, vault.root)
397
+ or artifact.sha256 != markdown_hash
398
+ or artifact.source_sha256 != source_hash
399
+ or artifact.tool != "outline-renderer"
400
+ or artifact.tool_version != OUTLINE_TOOL_VERSION
401
+ ):
402
+ return False
403
+ source_fingerprint = _file_fingerprint(source_destination)
404
+ markdown_fingerprint = _file_fingerprint(markdown_destination)
405
+ return source_fingerprint == (prior.sha256, prior.size) and (
406
+ markdown_fingerprint is not None and markdown_fingerprint[0] == artifact.sha256
407
+ )
408
+
409
+
410
+ def _outline_needs_preservation(
411
+ vault: Vault,
412
+ prior: ManifestEntry,
413
+ source_hash: str,
414
+ markdown_hash: str,
415
+ ) -> bool:
416
+ """Preserve changed generated bytes or a locally modified current artifact before overwrite."""
417
+
418
+ if source_hash != prior.sha256:
419
+ return True
420
+ source = vault.materialized(prior)
421
+ actual_source = _file_fingerprint(source)
422
+ if actual_source is not None and actual_source != (prior.sha256, prior.size):
423
+ return True
424
+
425
+ artifact = prior.derived.get("markdown")
426
+ if artifact is None:
427
+ return False
428
+ if markdown_hash != artifact.sha256:
429
+ return True
430
+ markdown = _markdown_destination(vault, source, prior)
431
+ actual_markdown = _file_fingerprint(markdown)
432
+ return actual_markdown is not None and actual_markdown[0] != artifact.sha256
433
+
434
+
435
+ def _file_fingerprint(source: Path) -> tuple[str, int] | None:
436
+ if paths.is_link(source):
437
+ raise ValueError("outline artifact must not be a symlink")
438
+ digest = sha256()
439
+ size = 0
440
+ try:
441
+ with open(os.fspath(paths.long_path(source)), "rb") as handle:
442
+ while chunk := handle.read(1024 * 1024):
443
+ digest.update(chunk)
444
+ size += len(chunk)
445
+ except FileNotFoundError:
446
+ return None
447
+ except IsADirectoryError as exc:
448
+ raise ValueError("outline artifact must be a regular file") from exc
449
+ return digest.hexdigest(), size
450
+
451
+
452
+ def _source_destination(
453
+ vault: Vault,
454
+ metadata: CourseMetadata,
455
+ topic: TopicRecord,
456
+ prior: ManifestEntry | None,
457
+ page: OutlinePage,
458
+ ) -> Path:
459
+ if prior is not None:
460
+ return vault.materialized(prior)
461
+ suffix = ".pdf" if page.pdf is not None else ".html"
462
+ candidate = _content_directory(metadata.directory, ("Outlines",)) / (
463
+ f"{paths.safe_name(topic.title)}{suffix}"
464
+ )
465
+ return paths.unique_path(candidate, reserved=vault.claimed_paths())
466
+
467
+
468
+ def _markdown_destination(vault: Vault, source: Path, prior: ManifestEntry | None) -> Path:
469
+ if prior is not None:
470
+ artifact = prior.derived.get("markdown")
471
+ if artifact is not None:
472
+ # Resolve the already-validated manifest artifact through the vault boundary instead
473
+ # of treating a persisted relative path as an untrusted filesystem path.
474
+ return vault.materialized(
475
+ ManifestEntry(
476
+ path=artifact.path,
477
+ sha256=artifact.sha256,
478
+ source_id="derived",
479
+ etag=None,
480
+ last_modified=None,
481
+ size=0,
482
+ fetched_at=_now(),
483
+ )
484
+ )
485
+ return source.with_suffix(".md")
486
+
487
+
488
+ def _outline_markdown(title: str, page: OutlinePage) -> bytes:
489
+ if page.pdf is not None:
490
+ text = f"# {title}\n\nRendered outline source: `{paths.safe_name(title)}.pdf`\n"
491
+ else:
492
+ body = re.sub(r"(?is)<(script|style|iframe|object|embed).*?</\1\s*>", " ", page.html)
493
+ body = re.sub(r"(?s)<[^>]+>", "\n", body)
494
+ body = html.unescape(body)
495
+ body = re.sub(r"\n{3,}", "\n\n", body).strip()
496
+ text = f"# {title}\n\n{body}\n"
497
+ return text.encode("utf-8")
498
+
499
+
500
+ def _install_bytes(destination: Path, data: bytes, *, root: Path | None = None) -> None:
501
+ # Outline bytes are generated locally and cheap to recreate. The generated-writer cleanup
502
+ # semantics are therefore intentional; downloaded course .part files use ingest's
503
+ # atomic_install_temp path and survive a failed install.
504
+ paths.ensure_dir(destination.parent, root=root)
505
+ paths.atomic_write_bytes(destination, data, root=root)
506
+
507
+
508
+ def _update_topic_map(
509
+ vault: Vault,
510
+ metadata: CourseMetadata,
511
+ topic: TopicRecord,
512
+ entry: ManifestEntry,
513
+ school: School,
514
+ ) -> None:
515
+ rows = course_index.read_content_map(metadata.directory).get("topics")
516
+ if not isinstance(rows, list):
517
+ return
518
+ derived = entry.derived.get("markdown")
519
+ for row in rows:
520
+ if isinstance(row, dict) and row.get("source_key") == topic.source_key:
521
+ row.update(
522
+ {
523
+ "availability": "markdown_ready",
524
+ "source_path": entry.path,
525
+ "path": derived.path if derived else None,
526
+ "sha256": entry.sha256,
527
+ "source_sha256": entry.sha256,
528
+ "size": entry.size,
529
+ "next_action": "ready for citation",
530
+ }
531
+ )
532
+ rows = course_index.reconcile_content_map(vault, rows)
533
+ _write_content_map(metadata.directory, rows, root=vault.root)
534
+ topics = tuple(
535
+ _topic_from_row(row, course=metadata.course) for row in rows if isinstance(row, dict)
536
+ )
537
+ _write_index(
538
+ metadata.directory,
539
+ school=school,
540
+ course=metadata.course,
541
+ topics=topics,
542
+ root=vault.root,
543
+ )
544
+
545
+
546
+ def _status(topic: TopicRecord, status: str, reason: str) -> dict[str, object]:
547
+ return {"source_key": topic.source_key, "status": status, "reason": reason}
548
+
549
+
550
+ def _close_target(browser: OutlineBrowser) -> str | None:
551
+ try:
552
+ browser.close_target()
553
+ except Exception as exc:
554
+ return type(exc).__name__
555
+ return None
556
+
557
+
558
+ def _now() -> str:
559
+ return clock.stamp()
560
+
561
+
562
+ def _write_json(destination: Path, payload: object, *, root: Path | None = None) -> None:
563
+ paths.ensure_dir(destination.parent, root=root)
564
+ text = (
565
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2, separators=(",", ": "))
566
+ + "\n"
567
+ )
568
+ paths.atomic_write_text(destination, text, root=root)
569
+
570
+
571
+ class _PageConnectionFactory(Protocol):
572
+ def open_page(self) -> Any:
573
+ """Return a fresh page-target CDP connection."""
574
+
575
+ def close(self) -> None:
576
+ """Release endpoint/process resources after all page targets are closed."""
577
+
578
+
579
+ class CDPOutlineBrowserFactory:
580
+ """Adapt a dedicated-profile page factory to one fresh outline browser per target."""
581
+
582
+ def __init__(self, pages: _PageConnectionFactory) -> None:
583
+ self._pages = pages
584
+
585
+ def open_browser(self) -> OutlineBrowser:
586
+ return CDPOutlineBrowser(self._pages.open_page())
587
+
588
+ def close(self) -> None:
589
+ self._pages.close()
590
+
591
+
592
+ def dedicated_profile_outline_factory() -> OutlineBrowserFactory:
593
+ """Build the production factory for Agent2Learn's dedicated persistent profile."""
594
+
595
+ from agent2learn.auth.cdp import DedicatedPageFactory
596
+
597
+ return CDPOutlineBrowserFactory(DedicatedPageFactory())
598
+
599
+
600
+ class CDPOutlineBrowser:
601
+ """Render one outline through one newly-created dedicated-profile page connection."""
602
+
603
+ def __init__(self, connection: Any) -> None:
604
+ self.connection = connection
605
+ self._blocked: str | None = None
606
+ self._popup: str | None = None
607
+
608
+ def render_outline(
609
+ self,
610
+ url: str,
611
+ *,
612
+ allowed_hosts: Sequence[str],
613
+ timeout: float,
614
+ ) -> OutlinePage:
615
+ gate = _CDPGate(self.connection, allowed_hosts)
616
+ self.connection.call("Page.enable", event_handler=gate.handle)
617
+ self.connection.call("Network.enable", event_handler=gate.handle)
618
+ self.connection.call(
619
+ "Fetch.enable",
620
+ {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]},
621
+ event_handler=gate.handle,
622
+ )
623
+ self.connection.call("Page.navigate", {"url": url}, event_handler=gate.handle)
624
+ deadline = time.monotonic() + timeout
625
+ while time.monotonic() < deadline:
626
+ gate.raise_if_blocked()
627
+ result = self.connection.call(
628
+ "Runtime.evaluate",
629
+ {"expression": "document.readyState", "returnByValue": True},
630
+ event_handler=gate.handle,
631
+ )
632
+ value = result.get("result") if isinstance(result, dict) else None
633
+ if isinstance(value, dict) and value.get("value") in {"interactive", "complete"}:
634
+ break
635
+ time.sleep(0.05)
636
+ else:
637
+ raise TimeoutError("outline render timed out")
638
+ gate.raise_if_blocked()
639
+ result = self.connection.call(
640
+ "Runtime.evaluate",
641
+ {
642
+ "expression": "({html: document.documentElement.outerHTML, url: location.href})",
643
+ "returnByValue": True,
644
+ },
645
+ event_handler=gate.handle,
646
+ )
647
+ value = result.get("result", {}).get("value") if isinstance(result, dict) else None
648
+ if not isinstance(value, dict):
649
+ raise ValueError("browser returned no outline DOM")
650
+ return OutlinePage(
651
+ html=cast(str, value.get("html", "")),
652
+ canonical_url=cast(str, value.get("url", url)),
653
+ )
654
+
655
+ def close_target(self) -> None:
656
+ force_close = getattr(self.connection, "close_target", None)
657
+ if callable(force_close):
658
+ force_close()
659
+ return
660
+ try:
661
+ self.connection.call("Page.close")
662
+ finally:
663
+ self.connection.close()
664
+
665
+
666
+ class _CDPGate:
667
+ def __init__(self, connection: Any, allowed_hosts: Sequence[str]) -> None:
668
+ self.connection = connection
669
+ self.allowed_hosts = tuple(allowed_hosts)
670
+ self.blocked: str | None = None
671
+ self.popup: str | None = None
672
+
673
+ def handle(self, message: dict[str, Any]) -> None:
674
+ if message.get("method") == "Fetch.requestPaused":
675
+ params = message.get("params")
676
+ request = params.get("request") if isinstance(params, dict) else None
677
+ request_id = params.get("requestId") if isinstance(params, dict) else None
678
+ target = request.get("url") if isinstance(request, dict) else None
679
+ if not isinstance(request_id, str) or not isinstance(target, str):
680
+ return
681
+ if _allowed_host_from_list(target, self.allowed_hosts):
682
+ self.connection.send_without_wait(
683
+ "Fetch.continueRequest", {"requestId": request_id}
684
+ )
685
+ else:
686
+ self.blocked = target
687
+ self.connection.send_without_wait(
688
+ "Fetch.failRequest",
689
+ {"requestId": request_id, "errorReason": "BlockedByClient"},
690
+ )
691
+ elif message.get("method") == "Target.targetCreated":
692
+ params = message.get("params")
693
+ info = params.get("targetInfo") if isinstance(params, dict) else None
694
+ if isinstance(info, dict) and info.get("type") == "page":
695
+ url = info.get("url")
696
+ if isinstance(url, str):
697
+ self.popup = url
698
+
699
+ def raise_if_blocked(self) -> None:
700
+ if self.blocked is not None:
701
+ raise ValueError("outline requested an undeclared dependency")
702
+ if self.popup is not None:
703
+ raise ValueError("outline opened a popup")
704
+
705
+
706
+ def _allowed_host_from_list(value: str, allowed_hosts: Sequence[str]) -> bool:
707
+ """Apply the exact LEARN origin plus declared-host boundary policy at request time."""
708
+
709
+ if not allowed_hosts:
710
+ return False
711
+ try:
712
+ parsed = urlsplit(value)
713
+ if (
714
+ parsed.scheme.casefold() != "https"
715
+ or parsed.hostname is None
716
+ or parsed.username is not None
717
+ or parsed.password is not None
718
+ ):
719
+ return False
720
+ host = parsed.hostname.encode("idna").decode("ascii").casefold().rstrip(".")
721
+ port = parsed.port or 443
722
+ base_host, base_port = _declared_host(allowed_hosts[0])
723
+ if host == base_host and port == base_port:
724
+ return True
725
+ for declared in allowed_hosts[1:]:
726
+ declared_host, declared_port = _declared_host(declared)
727
+ if port == declared_port and (
728
+ host == declared_host or host.endswith(f".{declared_host}")
729
+ ):
730
+ return True
731
+ except (TypeError, UnicodeError, ValueError):
732
+ return False
733
+ return False
734
+
735
+
736
+ def _declared_host(value: str) -> tuple[str, int]:
737
+ candidate = value if "://" in value else f"https://{value}"
738
+ parsed = urlsplit(candidate)
739
+ if parsed.scheme.casefold() != "https" or parsed.hostname is None:
740
+ raise ValueError("outline host declaration must be HTTPS")
741
+ host = parsed.hostname.encode("idna").decode("ascii").casefold().rstrip(".")
742
+ return host, parsed.port or 443
743
+
744
+
745
+ __all__ = [
746
+ "CDPOutlineBrowser",
747
+ "CDPOutlineBrowserFactory",
748
+ "OUTLINE_TIMEOUT_SECONDS",
749
+ "OutlineBrowser",
750
+ "OutlineBrowserFactory",
751
+ "OutlinePage",
752
+ "dedicated_profile_outline_factory",
753
+ "ingest_outlines",
754
+ ]