ghimera 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. chimera/__init__.py +11 -0
  2. chimera/__main__.py +5 -0
  3. ghimera/__init__.py +8 -0
  4. ghimera/__main__.py +5 -0
  5. ghimera/browser.py +363 -0
  6. ghimera/browser_config.py +69 -0
  7. ghimera/browser_types.py +174 -0
  8. ghimera/browser_worker.py +348 -0
  9. ghimera/budget.py +89 -0
  10. ghimera/collector.py +157 -0
  11. ghimera/command.py +198 -0
  12. ghimera/config.py +173 -0
  13. ghimera/content_dedup.py +205 -0
  14. ghimera/dedup_config.py +35 -0
  15. ghimera/dedup_types.py +40 -0
  16. ghimera/document_acceptance.py +131 -0
  17. ghimera/document_config.py +108 -0
  18. ghimera/document_models.py +103 -0
  19. ghimera/document_order.py +99 -0
  20. ghimera/document_pipeline.py +179 -0
  21. ghimera/document_types.py +56 -0
  22. ghimera/document_worker.py +246 -0
  23. ghimera/documents.py +137 -0
  24. ghimera/doubles.py +126 -0
  25. ghimera/embedding.py +149 -0
  26. ghimera/embedding_types.py +151 -0
  27. ghimera/evidence_context.py +175 -0
  28. ghimera/extraction.py +316 -0
  29. ghimera/extraction_attempts.py +140 -0
  30. ghimera/extraction_config.py +81 -0
  31. ghimera/extraction_types.py +93 -0
  32. ghimera/fetch.py +461 -0
  33. ghimera/graph.py +392 -0
  34. ghimera/graph_types.py +190 -0
  35. ghimera/html_worker.py +322 -0
  36. ghimera/http.py +240 -0
  37. ghimera/journal.py +350 -0
  38. ghimera/journal_config.py +26 -0
  39. ghimera/journal_types.py +86 -0
  40. ghimera/ledger.py +43 -0
  41. ghimera/locator_health.py +195 -0
  42. ghimera/locator_types.py +47 -0
  43. ghimera/loop.py +561 -0
  44. ghimera/model_citations.py +107 -0
  45. ghimera/model_client.py +476 -0
  46. ghimera/model_config.py +131 -0
  47. ghimera/model_http.py +146 -0
  48. ghimera/model_types.py +47 -0
  49. ghimera/models.py +660 -0
  50. ghimera/passive_worker.py +125 -0
  51. ghimera/politeness.py +117 -0
  52. ghimera/ports.py +54 -0
  53. ghimera/reference_config.py +54 -0
  54. ghimera/reference_types.py +148 -0
  55. ghimera/references.py +293 -0
  56. ghimera/refusals.py +142 -0
  57. ghimera/research.py +689 -0
  58. ghimera/research_config.py +62 -0
  59. ghimera/research_types.py +306 -0
  60. ghimera/response.py +39 -0
  61. ghimera/result_archive.py +167 -0
  62. ghimera/scoring.py +60 -0
  63. ghimera/scoring_config.py +42 -0
  64. ghimera/scoring_types.py +75 -0
  65. ghimera/scoring_validation.py +51 -0
  66. ghimera/search.py +117 -0
  67. ghimera/search_config.py +45 -0
  68. ghimera/search_history.py +33 -0
  69. ghimera/search_html_types.py +26 -0
  70. ghimera/searx_html_worker.py +69 -0
  71. ghimera/searxng.py +180 -0
  72. ghimera/semantic_scoring.py +363 -0
  73. ghimera/source_session_types.py +153 -0
  74. ghimera/source_sessions.py +76 -0
  75. ghimera/transport.py +410 -0
  76. ghimera/transport_types.py +105 -0
  77. ghimera-0.3.0.dist-info/METADATA +314 -0
  78. ghimera-0.3.0.dist-info/RECORD +81 -0
  79. ghimera-0.3.0.dist-info/WHEEL +4 -0
  80. ghimera-0.3.0.dist-info/entry_points.txt +2 -0
  81. ghimera-0.3.0.dist-info/licenses/LICENSE +21 -0
chimera/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Legacy root facade; all objects belong to the single ghimera implementation.
2
+
3
+ Nested imports must migrate to ghimera. This facade does not load duplicate
4
+ submodules, install import hooks, or change saved configuration/result schemas.
5
+ """
6
+
7
+ from ghimera import Collector, GhimeraConfig, Goal, GoalLoop, Harvest, Scope
8
+
9
+ ChimeraConfig = GhimeraConfig
10
+
11
+ __all__ = ["ChimeraConfig", "Collector", "Goal", "GoalLoop", "Harvest", "Scope"]
chimera/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Legacy module command using the same explicit ghimera entry point."""
2
+
3
+ from ghimera.command import main
4
+
5
+ raise SystemExit(main())
ghimera/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Ghimera core: no network, model, registry, or service is constructed on import."""
2
+
3
+ from ghimera.collector import Collector
4
+ from ghimera.config import GhimeraConfig
5
+ from ghimera.loop import GoalLoop
6
+ from ghimera.models import Goal, Harvest, Scope
7
+
8
+ __all__ = ["GhimeraConfig", "Collector", "Goal", "GoalLoop", "Harvest", "Scope"]
ghimera/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Standalone entry point; services and credentials are selected by the caller."""
2
+
3
+ from ghimera.command import main
4
+
5
+ raise SystemExit(main())
ghimera/browser.py ADDED
@@ -0,0 +1,363 @@
1
+ """Networkless browser worker; the parent exclusively owns every HTTP/Tor fetch."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import sys
9
+ import tempfile
10
+ from importlib.metadata import PackageNotFoundError, version
11
+ from pathlib import Path
12
+ from typing import Protocol
13
+
14
+ from ghimera.browser_config import BrowserConfig
15
+ from ghimera.browser_types import (
16
+ RenderResource,
17
+ RenderResult,
18
+ ResourceRequest,
19
+ ResourceResponse,
20
+ WorkerResult,
21
+ )
22
+ from ghimera.config import GhimeraConfig
23
+ from ghimera.models import Page, Record, Scope
24
+ from ghimera.passive_worker import private_directory, read_bounded
25
+ from ghimera.refusals import GhimeraRefused, RefusalCode
26
+ from ghimera.response import REDIRECT_STATUSES, redirect_target
27
+
28
+
29
+ class ResourceFetcher(Protocol):
30
+ async def fetch(self, url: str) -> Page: ...
31
+
32
+
33
+ class PageRenderer(Protocol):
34
+ @property
35
+ def name(self) -> str: ...
36
+
37
+ def validate_config(self, config: GhimeraConfig) -> None: ...
38
+
39
+ async def render(
40
+ self,
41
+ page: Page,
42
+ scope: Scope,
43
+ resources: ResourceFetcher,
44
+ *,
45
+ timeout_seconds: float,
46
+ ) -> RenderResult: ...
47
+
48
+
49
+ class BrowserRequest(Record):
50
+ config: BrowserConfig
51
+ page: Page
52
+ user_agent: str
53
+ parent_network_namespace: str
54
+
55
+
56
+ class IsolatedBrowserRenderer:
57
+ name = "patchright_isolated"
58
+
59
+ def __init__(self, config: GhimeraConfig) -> None:
60
+ if config.browser is None:
61
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
62
+ self.config, self._user_agent = config.browser, config.user_agent
63
+ self._redirect_limit = config.http.max_redirects if config.http is not None else 0
64
+ try:
65
+ if version("patchright") != "1.63.0":
66
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
67
+ self._check_artifacts()
68
+ except (OSError, PackageNotFoundError):
69
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT) from None
70
+ self._slots = asyncio.Semaphore(self.config.max_workers)
71
+
72
+ def _check_artifacts(self) -> None:
73
+ for path in (self.config.executable, self.config.isolator):
74
+ if not path.is_file() or not os.access(path, os.X_OK):
75
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
76
+ with self.config.executable.open("rb") as stream:
77
+ if hashlib.file_digest(stream, "sha256").hexdigest() != self.config.executable_sha256:
78
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
79
+
80
+ def validate_config(self, config: GhimeraConfig) -> None:
81
+ if (
82
+ config.browser != self.config
83
+ or config.user_agent != self._user_agent
84
+ or (config.http.max_redirects if config.http is not None else 0) != self._redirect_limit
85
+ ):
86
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
87
+
88
+ async def render(
89
+ self,
90
+ page: Page,
91
+ scope: Scope,
92
+ resources: ResourceFetcher,
93
+ *,
94
+ timeout_seconds: float,
95
+ ) -> RenderResult:
96
+ if (
97
+ page.rendered is not None
98
+ or not scope.permits(page.final_url)
99
+ or page.content_type != "text/html"
100
+ or not page.body
101
+ ):
102
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
103
+ if len(page.body) > self.config.max_input_bytes:
104
+ raise GhimeraRefused(RefusalCode.BUDGET_EXHAUSTED)
105
+ request = (
106
+ BrowserRequest(
107
+ config=self.config,
108
+ page=page,
109
+ user_agent=self._user_agent,
110
+ parent_network_namespace=os.readlink("/proc/self/ns/net"),
111
+ )
112
+ .model_dump_json()
113
+ .encode()
114
+ + b"\n"
115
+ )
116
+ if len(request) > self.config.max_protocol_bytes:
117
+ raise GhimeraRefused(RefusalCode.BUDGET_EXHAUSTED)
118
+ try:
119
+ async with (
120
+ asyncio.timeout(min(timeout_seconds, self.config.timeout_seconds)),
121
+ self._slots,
122
+ ):
123
+ self._check_artifacts()
124
+ return await self._run(request, page, scope, resources)
125
+ except TimeoutError:
126
+ raise GhimeraRefused(RefusalCode.BUDGET_EXHAUSTED) from None
127
+ except (OSError, ValueError):
128
+ raise GhimeraRefused(RefusalCode.FETCH_FAILED) from None
129
+
130
+ async def _resource(
131
+ self,
132
+ request: ResourceRequest,
133
+ scope: Scope,
134
+ resources: ResourceFetcher,
135
+ retained: list[RenderResource],
136
+ ) -> RenderResource:
137
+ if (
138
+ request.method != "GET"
139
+ or request.resource_type not in self.config.resource_types
140
+ or not scope.permits(request.url)
141
+ ):
142
+ return RenderResource(
143
+ url=request.url,
144
+ method=request.method,
145
+ resource_type=request.resource_type,
146
+ refusal=RefusalCode.OUT_OF_SCOPE,
147
+ redirected_from=request.redirected_from,
148
+ )
149
+ try:
150
+ # The untrusted worker supplies an index, not authority. Reconstruct
151
+ # the chain from parent-retained responses before any new source I/O.
152
+ previous_index = request.redirected_from
153
+ urls = {request.url}
154
+ hops = 0
155
+ while previous_index is not None:
156
+ if previous_index >= len(retained):
157
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
158
+ previous = retained[previous_index]
159
+ if (
160
+ previous.refusal is not None
161
+ or previous.status not in REDIRECT_STATUSES
162
+ or previous.resource_type != request.resource_type
163
+ or (
164
+ hops == 0 and redirect_target(previous.url, previous.headers) != request.url
165
+ )
166
+ ):
167
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
168
+ hops += 1
169
+ if hops > self._redirect_limit or previous.url in urls:
170
+ raise GhimeraRefused(RefusalCode.FETCH_FAILED)
171
+ urls.add(previous.url)
172
+ previous_index = previous.redirected_from
173
+ page = await resources.fetch(request.url)
174
+ if not scope.permits(page.final_url):
175
+ raise GhimeraRefused(RefusalCode.OUT_OF_SCOPE)
176
+ if page.final_url != request.url:
177
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
178
+ if page.status in REDIRECT_STATUSES:
179
+ target = redirect_target(page.final_url, page.headers)
180
+ if target is None:
181
+ raise GhimeraRefused(RefusalCode.FETCH_FAILED)
182
+ if not scope.permits(target):
183
+ raise GhimeraRefused(RefusalCode.OUT_OF_SCOPE)
184
+ elif page.status >= 300:
185
+ raise GhimeraRefused(RefusalCode.FETCH_FAILED)
186
+ elif page.content_type not in self.config.resource_content_types:
187
+ raise GhimeraRefused(RefusalCode.CONTENT_TYPE_UNWANTED)
188
+ if len(page.body) > self.config.max_input_bytes:
189
+ raise GhimeraRefused(RefusalCode.BUDGET_EXHAUSTED)
190
+ return RenderResource(
191
+ url=request.url,
192
+ method=request.method,
193
+ resource_type=request.resource_type,
194
+ final_url=page.final_url,
195
+ status=page.status,
196
+ content_type=page.content_type,
197
+ body=page.body,
198
+ source_sha256=hashlib.sha256(page.body).hexdigest(),
199
+ transport=page.transport,
200
+ source_session=page.source_session,
201
+ headers=page.headers,
202
+ redirected_from=request.redirected_from,
203
+ )
204
+ except GhimeraRefused as exc:
205
+ return RenderResource(
206
+ url=request.url,
207
+ method=request.method,
208
+ resource_type=request.resource_type,
209
+ refusal=exc.code,
210
+ redirected_from=request.redirected_from,
211
+ )
212
+
213
+ async def _run(
214
+ self,
215
+ request: bytes,
216
+ page: Page,
217
+ scope: Scope,
218
+ resources: ResourceFetcher,
219
+ ) -> RenderResult:
220
+ private_directory(self.config.work_directory)
221
+ scratch = Path(tempfile.mkdtemp(prefix="render-", dir=self.config.work_directory))
222
+ process: asyncio.subprocess.Process | None = None
223
+ starting: asyncio.Task[asyncio.subprocess.Process] | None = None
224
+ diagnostic: asyncio.Task[bytes] | None = None
225
+ retained: list[RenderResource] = []
226
+ total = len(request)
227
+ try:
228
+ # Browser AND driver inherit a new OS network namespace. No browser
229
+ # socket, DNS, service worker or WebRTC can reach the parent's network.
230
+ starting = asyncio.create_task(
231
+ asyncio.create_subprocess_exec(
232
+ str(self.config.isolator),
233
+ "--unshare-net",
234
+ "--unshare-pid",
235
+ "--die-with-parent",
236
+ "--new-session",
237
+ "--ro-bind",
238
+ "/",
239
+ "/",
240
+ "--tmpfs",
241
+ str(self.config.sandbox_work_directory.parent),
242
+ "--bind",
243
+ str(scratch),
244
+ str(self.config.sandbox_work_directory),
245
+ "--proc",
246
+ "/proc",
247
+ "--dev",
248
+ "/dev",
249
+ "--chdir",
250
+ str(self.config.sandbox_work_directory),
251
+ "--",
252
+ sys.executable,
253
+ "-m",
254
+ "ghimera.browser_worker",
255
+ stdin=asyncio.subprocess.PIPE,
256
+ stdout=asyncio.subprocess.PIPE,
257
+ stderr=asyncio.subprocess.PIPE,
258
+ start_new_session=True,
259
+ limit=self.config.max_protocol_bytes + 1,
260
+ env={
261
+ "PYTHONPATH": str(Path(__file__).resolve().parents[1]),
262
+ "PYTHONNOUSERSITE": "1",
263
+ "PYTHONDONTWRITEBYTECODE": "1",
264
+ "TMPDIR": str(self.config.sandbox_work_directory),
265
+ "XDG_CACHE_HOME": str(self.config.sandbox_work_directory / "cache"),
266
+ "XDG_CONFIG_HOME": str(self.config.sandbox_work_directory / "config"),
267
+ },
268
+ )
269
+ )
270
+ process = await asyncio.shield(starting)
271
+ if process.stdin is None or process.stdout is None:
272
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
273
+ diagnostic = asyncio.create_task(
274
+ read_bounded(
275
+ process.stderr,
276
+ self.config.max_diagnostic_bytes,
277
+ )
278
+ )
279
+ process.stdin.write(request)
280
+ await process.stdin.drain()
281
+ while True:
282
+ line = await process.stdout.readline()
283
+ total += len(line)
284
+ if total > self.config.max_protocol_bytes:
285
+ raise GhimeraRefused(RefusalCode.BUDGET_EXHAUSTED)
286
+ if not line:
287
+ raise GhimeraRefused(RefusalCode.FETCH_FAILED)
288
+ if diagnostic.done():
289
+ diagnostic.result() # Refuse overflow rather than logging vendor stderr.
290
+ try:
291
+ requested = ResourceRequest.model_validate_json(line)
292
+ except ValueError:
293
+ outcome = WorkerResult.model_validate_json(line)
294
+ if outcome.refusal is not None:
295
+ raise GhimeraRefused(outcome.refusal) from None
296
+ result = outcome.result
297
+ if result is None or (
298
+ result.resources != tuple(retained)
299
+ or result.source_url != page.final_url
300
+ or result.source_sha256 != hashlib.sha256(page.body).hexdigest()
301
+ or result.config_digest != self.config.content_digest()
302
+ or result.browser_sha256 != self.config.executable_sha256
303
+ or result.parent_network_namespace != os.readlink("/proc/self/ns/net")
304
+ or len(result.html) > self.config.max_rendered_bytes
305
+ ):
306
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT) from None
307
+ try:
308
+ result.validate_policy(self.config, max_redirects=self._redirect_limit)
309
+ except ValueError:
310
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT) from None
311
+ process.stdin.close()
312
+ await diagnostic
313
+ if await process.wait() != 0:
314
+ raise GhimeraRefused(RefusalCode.FETCH_FAILED) from None
315
+ return result
316
+ if requested.sequence != len(retained):
317
+ raise GhimeraRefused(RefusalCode.ADAPTER_CONTRACT)
318
+ if len(retained) >= self.config.max_resources:
319
+ raise GhimeraRefused(RefusalCode.BUDGET_EXHAUSTED)
320
+ fetched = await self._resource(requested, scope, resources, retained)
321
+ retained.append(fetched)
322
+ response = (
323
+ ResourceResponse(
324
+ sequence=requested.sequence,
325
+ result=fetched,
326
+ )
327
+ .model_dump_json()
328
+ .encode()
329
+ + b"\n"
330
+ )
331
+ total += len(response)
332
+ if total > self.config.max_protocol_bytes:
333
+ raise GhimeraRefused(RefusalCode.BUDGET_EXHAUSTED)
334
+ process.stdin.write(response)
335
+ await process.stdin.drain()
336
+ finally:
337
+ if process is None and starting is not None:
338
+ async with asyncio.timeout(self.config.cleanup_timeout_seconds):
339
+ process = await asyncio.shield(starting)
340
+ if diagnostic is not None and not diagnostic.done():
341
+ diagnostic.cancel()
342
+ if diagnostic is not None:
343
+ await asyncio.gather(diagnostic, return_exceptions=True)
344
+ if process is not None:
345
+ if process.stdin is not None and not process.stdin.is_closing():
346
+ process.stdin.transport.abort()
347
+ # Only this subprocess's fresh session is targeted. Descendants
348
+ # live in the child PID namespace, not shared browser sessions.
349
+ if process.returncode is None:
350
+ try:
351
+ os.killpg(process.pid, signal.SIGKILL)
352
+ except ProcessLookupError:
353
+ pass
354
+
355
+ async def discard(stream: asyncio.StreamReader | None) -> None:
356
+ if stream is not None:
357
+ while await stream.read(65536):
358
+ pass
359
+
360
+ async with asyncio.timeout(self.config.cleanup_timeout_seconds):
361
+ await asyncio.gather(discard(process.stdout), discard(process.stderr))
362
+ await process.wait()
363
+ shutil.rmtree(scratch) # Exactly the owned mkdtemp directory, after reaping.
@@ -0,0 +1,69 @@
1
+ """Explicit, pinned browser execution policy; no inferred appliance paths."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+
9
+ Positive = Annotated[int, Field(strict=True, gt=0)]
10
+ Seconds = Annotated[float, Field(gt=0, allow_inf_nan=False)]
11
+
12
+
13
+ class BrowserConfig(BaseModel):
14
+ model_config = ConfigDict(extra="forbid", frozen=True, serialize_by_alias=True)
15
+ schema_version: Literal["chimera.browser/1"] = Field(alias="schema")
16
+ engine: Literal["patchright"]
17
+ executable: Path
18
+ executable_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
19
+ isolator: Path
20
+ work_directory: Path
21
+ sandbox_work_directory: Path
22
+ max_workers: Positive
23
+ timeout_seconds: Seconds
24
+ cleanup_timeout_seconds: Seconds
25
+ max_input_bytes: Positive
26
+ max_rendered_bytes: Positive
27
+ max_protocol_bytes: Positive
28
+ max_diagnostic_bytes: Positive
29
+ max_resources: Positive
30
+ resource_types: tuple[
31
+ Literal["script", "stylesheet", "image", "font", "fetch", "xhr", "media"], ...
32
+ ]
33
+ resource_content_types: Annotated[tuple[str, ...], Field(min_length=1)]
34
+ ready_selector: Annotated[str, Field(min_length=1)] | None = None
35
+ settle_seconds: Annotated[float, Field(ge=0, allow_inf_nan=False)]
36
+ viewport_width: Positive
37
+ viewport_height: Positive
38
+
39
+ @model_validator(mode="after")
40
+ def coherent(self) -> "BrowserConfig":
41
+ if not all(
42
+ path.is_absolute()
43
+ for path in (
44
+ self.executable,
45
+ self.isolator,
46
+ self.work_directory,
47
+ self.sandbox_work_directory,
48
+ )
49
+ ):
50
+ raise ValueError("browser paths must be explicit absolute paths")
51
+ if (
52
+ self.sandbox_work_directory.parent != Path("/run")
53
+ or len(str(self.sandbox_work_directory).encode()) > 32
54
+ ):
55
+ raise ValueError("sandbox scratch must be a short immediate child of /run")
56
+ if len(set(self.resource_types)) != len(self.resource_types):
57
+ raise ValueError("resource types cannot repeat")
58
+ if any(not value or ";" in value for value in self.resource_content_types):
59
+ raise ValueError("resource MIME types must be normalized types without parameters")
60
+ if self.ready_selector is not None and not self.ready_selector.strip():
61
+ raise ValueError("ready selector must be nonblank")
62
+ if self.settle_seconds >= self.timeout_seconds:
63
+ raise ValueError("settle time must fit within the render deadline")
64
+ if self.max_protocol_bytes < 2 * max(self.max_input_bytes, self.max_rendered_bytes):
65
+ raise ValueError("protocol budget must hold encoded input and rendered output")
66
+ return self
67
+
68
+ def content_digest(self) -> str:
69
+ return hashlib.sha256(self.model_dump_json(by_alias=True).encode()).hexdigest()
@@ -0,0 +1,174 @@
1
+ """Source-bound rendering and resource evidence; DOM is not original source bytes."""
2
+
3
+ import hashlib
4
+ from typing import Annotated, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
+
8
+ from ghimera.browser_config import BrowserConfig
9
+ from ghimera.refusals import RefusalCode
10
+ from ghimera.response import REDIRECT_STATUSES, redirect_target
11
+ from ghimera.source_session_types import SourceSessionUse
12
+ from ghimera.transport_types import TransportEvidence
13
+
14
+ Digest = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
15
+ Text = Annotated[str, Field(min_length=1)]
16
+
17
+
18
+ class BrowserRecord(BaseModel):
19
+ model_config = ConfigDict(
20
+ extra="forbid",
21
+ frozen=True,
22
+ serialize_by_alias=True,
23
+ ser_json_bytes="base64",
24
+ val_json_bytes="base64",
25
+ )
26
+
27
+
28
+ class ResourceRequest(BrowserRecord):
29
+ event: Literal["resource"] = "resource"
30
+ sequence: Annotated[int, Field(strict=True, ge=0)]
31
+ url: Text
32
+ method: Text
33
+ resource_type: Text
34
+ redirected_from: Annotated[int, Field(strict=True, ge=0)] | None = None
35
+
36
+
37
+ class RenderResource(BrowserRecord):
38
+ url: Text
39
+ method: Text
40
+ resource_type: Text
41
+ final_url: str | None = None
42
+ status: Annotated[int, Field(strict=True, ge=100, le=399)] | None = None
43
+ content_type: str | None = None
44
+ body: bytes = b""
45
+ source_sha256: Digest | None = None
46
+ transport: TransportEvidence | None = None
47
+ headers: tuple[tuple[str, str], ...] = ()
48
+ refusal: RefusalCode | None = None
49
+ redirected_from: Annotated[int, Field(strict=True, ge=0)] | None = None
50
+ source_session: SourceSessionUse | None = None
51
+
52
+ @model_validator(mode="after")
53
+ def bound(self) -> "RenderResource":
54
+ if self.source_session is not None and self.source_session.request_url != self.final_url:
55
+ raise ValueError("source session selection must bind this resource URL")
56
+ if self.refusal is not None:
57
+ if (
58
+ any(
59
+ value is not None
60
+ for value in (
61
+ self.status,
62
+ self.source_sha256,
63
+ self.content_type,
64
+ self.final_url,
65
+ self.transport,
66
+ self.source_session,
67
+ )
68
+ )
69
+ or self.body
70
+ or self.headers
71
+ ):
72
+ raise ValueError("refused resource cannot claim retained fetched content")
73
+ elif (
74
+ self.method != "GET"
75
+ or self.status is None
76
+ or not self.final_url
77
+ or not self.content_type
78
+ or self.source_sha256 != hashlib.sha256(self.body).hexdigest()
79
+ ):
80
+ raise ValueError("successful resource requires original content and digest")
81
+ elif self.status in REDIRECT_STATUSES and redirect_target(self.url, self.headers) is None:
82
+ raise ValueError("redirect resource requires one nonempty Location")
83
+ return self
84
+
85
+
86
+ class ResourceResponse(BrowserRecord):
87
+ sequence: Annotated[int, Field(strict=True, ge=0)]
88
+ result: RenderResource
89
+
90
+
91
+ class RenderResult(BrowserRecord):
92
+ schema_version: Literal["chimera.browser-render/1"] = Field(alias="schema")
93
+ source_url: Text
94
+ source_sha256: Digest
95
+ html: bytes
96
+ html_sha256: Digest
97
+ config_digest: Digest
98
+ browser_sha256: Digest
99
+ driver_revision: Literal["patchright@1.63.0"]
100
+ network_isolation: Literal["linux_network_namespace"]
101
+ parent_network_namespace: Annotated[str, Field(pattern=r"^net:\[\d+\]$")]
102
+ worker_network_namespace: Annotated[str, Field(pattern=r"^net:\[\d+\]$")]
103
+ resources: tuple[RenderResource, ...]
104
+
105
+ def validate_policy(self, policy: BrowserConfig, *, max_redirects: int | None = None) -> None:
106
+ if (
107
+ self.config_digest != policy.content_digest()
108
+ or self.browser_sha256 != policy.executable_sha256
109
+ or len(self.html) > policy.max_rendered_bytes
110
+ or len(self.resources) > policy.max_resources
111
+ ):
112
+ raise ValueError("rendering must bind its artifact, effective policy and limits")
113
+ if any(
114
+ item.refusal is None
115
+ and (
116
+ item.resource_type not in policy.resource_types
117
+ or (
118
+ item.status not in REDIRECT_STATUSES
119
+ and item.content_type not in policy.resource_content_types
120
+ )
121
+ or len(item.body) > policy.max_input_bytes
122
+ or item.url != item.final_url
123
+ )
124
+ for item in self.resources
125
+ ):
126
+ raise ValueError("successful rendering resources must obey the declared policy")
127
+ if max_redirects is not None:
128
+ for item in self.resources:
129
+ if item.refusal is not None:
130
+ continue # Over-cap refused hops are retained, but never fetched.
131
+ ancestor = item.redirected_from
132
+ hops = 0
133
+ urls = {item.url}
134
+ while ancestor is not None:
135
+ previous = self.resources[ancestor]
136
+ hops += 1
137
+ if hops > max_redirects or previous.url in urls:
138
+ raise ValueError("successful redirect resources exceed their HTTP policy")
139
+ urls.add(previous.url)
140
+ ancestor = previous.redirected_from
141
+
142
+ @model_validator(mode="after")
143
+ def bound(self) -> "RenderResult":
144
+ if not self.html or self.html_sha256 != hashlib.sha256(self.html).hexdigest():
145
+ raise ValueError("rendered DOM requires retained content and digest")
146
+ if self.parent_network_namespace == self.worker_network_namespace:
147
+ raise ValueError("browser worker must have its own network namespace")
148
+ for index, resource in enumerate(self.resources):
149
+ if resource.redirected_from is None:
150
+ continue
151
+ if resource.redirected_from >= index:
152
+ raise ValueError("redirect resource must follow a retained earlier resource")
153
+ previous = self.resources[resource.redirected_from]
154
+ if (
155
+ previous.refusal is not None
156
+ or previous.status not in REDIRECT_STATUSES
157
+ or redirect_target(previous.url, previous.headers) != resource.url
158
+ or previous.resource_type != resource.resource_type
159
+ or resource.method != "GET"
160
+ ):
161
+ raise ValueError("redirect chain must bind the actual retained Location and kind")
162
+ return self
163
+
164
+
165
+ class WorkerResult(BrowserRecord):
166
+ event: Literal["result"] = "result"
167
+ result: RenderResult | None = None
168
+ refusal: RefusalCode | None = None
169
+
170
+ @model_validator(mode="after")
171
+ def outcome(self) -> "WorkerResult":
172
+ if (self.result is None) == (self.refusal is None):
173
+ raise ValueError("browser worker must report exactly one outcome")
174
+ return self