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.
- chimera/__init__.py +11 -0
- chimera/__main__.py +5 -0
- ghimera/__init__.py +8 -0
- ghimera/__main__.py +5 -0
- ghimera/browser.py +363 -0
- ghimera/browser_config.py +69 -0
- ghimera/browser_types.py +174 -0
- ghimera/browser_worker.py +348 -0
- ghimera/budget.py +89 -0
- ghimera/collector.py +157 -0
- ghimera/command.py +198 -0
- ghimera/config.py +173 -0
- ghimera/content_dedup.py +205 -0
- ghimera/dedup_config.py +35 -0
- ghimera/dedup_types.py +40 -0
- ghimera/document_acceptance.py +131 -0
- ghimera/document_config.py +108 -0
- ghimera/document_models.py +103 -0
- ghimera/document_order.py +99 -0
- ghimera/document_pipeline.py +179 -0
- ghimera/document_types.py +56 -0
- ghimera/document_worker.py +246 -0
- ghimera/documents.py +137 -0
- ghimera/doubles.py +126 -0
- ghimera/embedding.py +149 -0
- ghimera/embedding_types.py +151 -0
- ghimera/evidence_context.py +175 -0
- ghimera/extraction.py +316 -0
- ghimera/extraction_attempts.py +140 -0
- ghimera/extraction_config.py +81 -0
- ghimera/extraction_types.py +93 -0
- ghimera/fetch.py +461 -0
- ghimera/graph.py +392 -0
- ghimera/graph_types.py +190 -0
- ghimera/html_worker.py +322 -0
- ghimera/http.py +240 -0
- ghimera/journal.py +350 -0
- ghimera/journal_config.py +26 -0
- ghimera/journal_types.py +86 -0
- ghimera/ledger.py +43 -0
- ghimera/locator_health.py +195 -0
- ghimera/locator_types.py +47 -0
- ghimera/loop.py +561 -0
- ghimera/model_citations.py +107 -0
- ghimera/model_client.py +476 -0
- ghimera/model_config.py +131 -0
- ghimera/model_http.py +146 -0
- ghimera/model_types.py +47 -0
- ghimera/models.py +660 -0
- ghimera/passive_worker.py +125 -0
- ghimera/politeness.py +117 -0
- ghimera/ports.py +54 -0
- ghimera/reference_config.py +54 -0
- ghimera/reference_types.py +148 -0
- ghimera/references.py +293 -0
- ghimera/refusals.py +142 -0
- ghimera/research.py +689 -0
- ghimera/research_config.py +62 -0
- ghimera/research_types.py +306 -0
- ghimera/response.py +39 -0
- ghimera/result_archive.py +167 -0
- ghimera/scoring.py +60 -0
- ghimera/scoring_config.py +42 -0
- ghimera/scoring_types.py +75 -0
- ghimera/scoring_validation.py +51 -0
- ghimera/search.py +117 -0
- ghimera/search_config.py +45 -0
- ghimera/search_history.py +33 -0
- ghimera/search_html_types.py +26 -0
- ghimera/searx_html_worker.py +69 -0
- ghimera/searxng.py +180 -0
- ghimera/semantic_scoring.py +363 -0
- ghimera/source_session_types.py +153 -0
- ghimera/source_sessions.py +76 -0
- ghimera/transport.py +410 -0
- ghimera/transport_types.py +105 -0
- ghimera-0.3.0.dist-info/METADATA +314 -0
- ghimera-0.3.0.dist-info/RECORD +81 -0
- ghimera-0.3.0.dist-info/WHEEL +4 -0
- ghimera-0.3.0.dist-info/entry_points.txt +2 -0
- 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
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
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()
|
ghimera/browser_types.py
ADDED
|
@@ -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
|