Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9bdb0175 | ||
|
|
f762790e30 | ||
|
|
528c05e2fa | ||
|
|
c00739bb1f | ||
|
|
2d1e021782 | ||
|
|
5a7ccefc68 | ||
|
|
5231e95c19 | ||
|
|
be76f8090b | ||
|
|
c2e64a42c8 | ||
|
|
d39f98d06d | ||
|
|
446a1e5799 | ||
|
|
ae0197d160 | ||
|
|
246868232e | ||
|
|
ef7f2f9374 | ||
|
|
6a3fd5b4ce | ||
|
|
f840e70749 | ||
|
|
ae8b1c5ca9 | ||
|
|
31b40f39e4 | ||
|
|
2575b30dfc | ||
|
|
e8ed3073ac |
@@ -0,0 +1,33 @@
|
||||
name: Process cleanup stress
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/process-cleanup-stress.yml"
|
||||
- "scripts/verify-process-cleanup-stress.mjs"
|
||||
- "src/application/ProcessCaptureLifecycle.ts"
|
||||
- "src/application/ProcessHarness.ts"
|
||||
- "src/application/ProcessOwnership.ts"
|
||||
- "tests/fixtures/processFidelity.mjs"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
real-forkpty-cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run verify:process:stress
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.4.0"
|
||||
".": "1.5.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [1.5.0](https://github.com/morluto/rea/compare/rea-agents-1.4.0...rea-agents-1.5.0) (2026-07-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add passive website reverse engineering ([9986971](https://github.com/morluto/rea/commit/998697155dcc733be73e75bc77c069638942f111))
|
||||
* add passive website reverse engineering ([2c1ceab](https://github.com/morluto/rea/commit/2c1ceabbf1f117daafe166e1134b47737b38e1f6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **browser:** drop disallowed redirect evidence ([8481be9](https://github.com/morluto/rea/commit/8481be9c9709b13f572fefefcf8508d9e2605386))
|
||||
* **browser:** scope CDP events and fail closed ([5279d77](https://github.com/morluto/rea/commit/5279d7755c74df579028ce51b4bc53b089bb2749))
|
||||
* **browser:** scope workers and binary frame sizes ([dfc06cf](https://github.com/morluto/rea/commit/dfc06cf0ad4a39719ef74846cb83f05e7b6f6289))
|
||||
* harden PTY events and configured roots ([ae8b1c5](https://github.com/morluto/rea/commit/ae8b1c5ca9c2d122d7fc30ebfa25e08d57ca7a51))
|
||||
* harden PTY events and configured roots ([31b40f3](https://github.com/morluto/rea/commit/31b40f39e4c29a077deebefbe29e90c30e50cd0c))
|
||||
* **permission:** defer cache write grants ([6a3fd5b](https://github.com/morluto/rea/commit/6a3fd5b4cec7569eebfe8a7ca6a7c0c1e3a7340d))
|
||||
* **permission:** defer cache write grants ([f840e70](https://github.com/morluto/rea/commit/f840e70749ffadfe34cf8b7e464b54e8ffebd816))
|
||||
* resolve triaged correctness issues ([2575b30](https://github.com/morluto/rea/commit/2575b30dfcb79c2e26ed16311f780a8723578c6a))
|
||||
* resolve triaged correctness issues ([e8ed307](https://github.com/morluto/rea/commit/e8ed3073ac915e32b5ea630446ed6e14bc788119))
|
||||
* resolve validation and artifact edge cases ([2468682](https://github.com/morluto/rea/commit/246868232e4196394fbc6e67cfae4d3ca214fe60))
|
||||
* resolve validation and artifact edge cases ([ef7f2f9](https://github.com/morluto/rea/commit/ef7f2f93747f0a4d9f70c9fc2e252456d20843f3))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add Hopper screenshot to README ([a699fdd](https://github.com/morluto/rea/commit/a699fdd6a5cc9c9812c7d78fd98b51469488866f))
|
||||
* add Hopper screenshot to README ([08981a3](https://github.com/morluto/rea/commit/08981a31b5e9b76e15914c0570244158d491e047))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* **cli:** allow cold-start integration timing ([7d5621d](https://github.com/morluto/rea/commit/7d5621dfbc4333f99846cbe52227d3b4f53ee0cd))
|
||||
|
||||
## [1.4.0](https://github.com/morluto/rea/compare/rea-agents-1.3.0...rea-agents-1.4.0) (2026-07-14)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
Keep dependencies flowing inward through the existing domain, contracts, provider, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, 2 browser, and 18 session tools (70 total) unless a deliberate contract change updates every verifier and snapshot.
|
||||
Keep dependencies flowing inward through the existing domain, contracts, provider, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, 8 browser, 2 Electron, and 18 session tools (78 total) unless a deliberate contract change updates every verifier and snapshot.
|
||||
|
||||
Before submitting a pull request, run:
|
||||
|
||||
@@ -70,7 +70,7 @@ Publish the public package:
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
After npm registry propagation, verify the published CLI and connect a beta.3 MCP client to the published server to confirm all 70 tools:
|
||||
After npm registry propagation, verify the published CLI and connect a beta.3 MCP client to the published server to confirm all 78 tools:
|
||||
|
||||
```bash
|
||||
npx -y rea-agents --help
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#70-tools-for-investigation)
|
||||
[](#78-tools-for-investigation)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [70 tools](#70-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
|
||||
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [78 tools](#78-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
See a feature in an app that you want in your own product? Give the app to your agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
|
||||
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis through Hopper, complete function dossiers, reproducible Evidence v2 records, and controlled process capture. The longer-term toolkit extends the same agent workflow to packaged apps, JavaScript bundles, websites, APIs, protocols, mobile artifacts, firmware, runtime behavior, and differences between versions.
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis through Hopper, complete function dossiers, reproducible Evidence v2 records, controlled process capture, passive website and Electron observation, and bounded JavaScript/source-map reconstruction. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions.
|
||||
|
||||
Reverse engineering normally makes the operator choose a tool, learn its API, move evidence between programs, and decide what to inspect next. REA gives that work to the agent through commands, skills, structured results, and repeatable investigation workflows.
|
||||
|
||||
@@ -294,16 +294,17 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
|
||||
- Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
|
||||
- Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
|
||||
|
||||
## 70 tools for investigation
|
||||
## 78 tools for investigation
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
|
||||
| Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
|
||||
| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
|
||||
| Browser observation | 2 | exact-origin CDP page discovery and passive DOM, accessibility, script, resource, network, console, worker, and storage inspection |
|
||||
| Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
|
||||
| Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
|
||||
| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
|
||||
| Browser observation | 8 | exact-origin CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, and visual evidence |
|
||||
| Electron observation | 2 | canonical-root-confined file-page discovery and passive DOM, resource, and optionally approved script-source inspection |
|
||||
| Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
|
||||
|
||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture.
|
||||
|
||||
@@ -312,7 +313,8 @@ The public interface describes what the agent is trying to learn. Providers deci
|
||||
REA is already useful for native application investigation on macOS:
|
||||
|
||||
- Open Mach-O, ELF, PE, `.app`, ZIP, APK, IPA, ASAR, plist, JavaScript, source-map, and Hopper database targets.
|
||||
- Attach to a user-owned Chrome-family browser over a configured loopback CDP endpoint, list only pages on approved exact origins, and capture bounded passive web evidence without navigation or JavaScript evaluation.
|
||||
- Attach to a user-owned Chrome-family browser over a configured loopback CDP endpoint; capture exact-origin web structure, safe metadata, approved value-free payload shapes, bundle/source-map evidence, WebMCP declarations, user-action timelines, capture diffs, and explicitly approved screenshots without navigation or JavaScript evaluation.
|
||||
- Inspect Electron `file://` renderer pages through a separate canonical-root permission boundary without invoking Electron APIs; script contents remain separately approved and byte bounded.
|
||||
- Traverse content-addressed artifact graphs without extraction; on macOS, read-only DMG traversal additionally requires `native_mount_approved: true` and `REA_ARTIFACT_NATIVE_MOUNT_ENABLED=true`. Materialize only approved occurrences into absent output roots.
|
||||
- Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names.
|
||||
- Search and trace features across symbols, strings, metadata, references, and call paths.
|
||||
@@ -346,14 +348,14 @@ rea list-browser-targets http://127.0.0.1:9222 --approved --json
|
||||
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID --approved --json
|
||||
```
|
||||
|
||||
`list_browser_targets` and `inspect_web_page` expose the same Evidence v2 contracts over MCP. Inspection is passive: REA does not evaluate page JavaScript, navigate, click, close the page, or close the browser. It redacts query values and never retains headers, bodies, cookies, storage values, console argument values, or WebSocket payloads. Existing activity before attach is explicitly unavailable. See [Website observation with CDP](docs/browser-observation.md) for browser startup, schemas, limits, and the threat model.
|
||||
All eight browser tools expose the same Evidence v2 contracts over CLI and MCP. Inspection is passive: REA does not evaluate page JavaScript, navigate, click, close the page, or close the browser. Query values, credentials, cookies, authorization headers, storage values, and raw JSON or WebSocket values are never retained. Separately approved captures can retain bounded redacted console primitives, value-free JSON/WebSocket shapes, script sources, accessibility text, or screenshot pixels. Existing activity before attach is explicitly unavailable. See [Website observation with CDP](docs/browser-observation.md) for browser startup, schemas, limits, and the threat model.
|
||||
|
||||
## Roadmap
|
||||
|
||||
REA is growing into a toolkit for understanding software across static artifacts and observed behavior. The next capability families are:
|
||||
|
||||
1. **Artifact decomposition** — DMG, ASAR, ZIP, packages, universal-binary slices, application resources, embedded frameworks, mobile packages, and artifact graphs.
|
||||
2. **Web and Electron investigation** — extend the shipped passive CDP observation with approved interaction, screenshots, Electron IPC, route discovery, controlled replay, and visual or structural differences.
|
||||
2. **Web and Electron investigation** — extend the shipped passive CDP, bundle, visual-diff, and file-page observation with approved interaction, controlled replay, Electron IPC observation, semantic UI differences, and deeper JavaScript reconstruction.
|
||||
3. **Deterministic behavior harnesses** — stronger process-tree ownership, protocol fixtures, network policy, filesystem tracing, signals, reconnects, and cross-version comparison.
|
||||
4. **JavaScript and source recovery** — bundle indexing, AST/module reconstruction, source-map discovery, historical-source matching, and CodeDB-backed cross-references.
|
||||
5. **Runtime observation** — approval-gated LLDB, Frida, system logs, process and filesystem observers, and native API tracing.
|
||||
|
||||
+5
-4
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#منصة-من-70-أداة)
|
||||
[](#منصة-من-78-أداة)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [70 أداة](#منصة-من-70-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [78 أداة](#منصة-من-78-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -155,7 +155,7 @@ rea uninstall --purge-data
|
||||
|
||||
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
|
||||
|
||||
## منصة من 70 أداة
|
||||
## منصة من 78 أداة
|
||||
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | ---------------------------------------------------------------------------------------- |
|
||||
@@ -163,7 +163,8 @@ rea uninstall --purge-data
|
||||
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
|
||||
| رسم القطع الأثرية | 2 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
|
||||
| مراقبة المتصفح | 2 | اكتشاف صفحات CDP ضمن أصول دقيقة وفحص سلبي لـ DOM وإتاحة الوصول والبرامج والشبكة والتخزين |
|
||||
| مراقبة المتصفح | 8 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية |
|
||||
| مراقبة Electron | 2 | اكتشاف صفحات الملفات ضمن جذور canonical وفحص DOM والموارد والمصادر المعتمدة |
|
||||
| جلسة الملف التنفيذي | 18 | فتح الأهداف وحزم الأدلة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
|
||||
|
||||
## استخدام REA مع وكلاء برمجة آخرين
|
||||
|
||||
+5
-4
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#70-ツールのワークベンチ)
|
||||
[](#78-ツールのワークベンチ)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [70 ツール](#70-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [78 ツール](#78-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -145,7 +145,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
- Swift / Objective-C のメタデータを解析する。
|
||||
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
|
||||
|
||||
## 70 ツールのワークベンチ
|
||||
## 78 ツールのワークベンチ
|
||||
|
||||
| ツール群 | 数 | 例 |
|
||||
| ---------------------- | --: | ------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -153,7 +153,8 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
|
||||
| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 |
|
||||
| アーティファクトグラフ | 2 | ディレクトリ、ZIP/APK/IPA、ASAR の決定的一覧と明示選択されたトランザクション抽出 |
|
||||
| ブラウザ観察 | 2 | 完全一致 origin に限定した CDP ページ検出と受動的な DOM、アクセシビリティ、スクリプト、通信、保存領域の調査 |
|
||||
| ブラウザ観察 | 8 | origin 限定 CDP 取得、bundle/source map 解析、WebMCP 検出、セッション、capture diff、視覚証拠 |
|
||||
| Electron 観察 | 2 | canonical ファイルルート内のページ検出と受動的 DOM、リソース、承認済みスクリプトソース調査 |
|
||||
| バイナリセッション | 18 | `open_binary`、`binary_session`、証拠バンドル、プロセス/成果物/関数比較、残余未知レジストリ |
|
||||
|
||||
## 他のコーディングエージェントで使う
|
||||
|
||||
+5
-4
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#70개-도구로-구성된-워크벤치)
|
||||
[](#78개-도구로-구성된-워크벤치)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [70개 도구](#70개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [78개 도구](#78개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -145,7 +145,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
- Swift 및 Objective-C 메타데이터를 분석합니다.
|
||||
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
|
||||
|
||||
## 70개 도구로 구성된 워크벤치
|
||||
## 78개 도구로 구성된 워크벤치
|
||||
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------------- | --: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -153,7 +153,8 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
|
||||
| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 |
|
||||
| 아티팩트 그래프 | 2 | 디렉터리, ZIP/APK/IPA, ASAR 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 |
|
||||
| 브라우저 관찰 | 2 | 정확한 origin 범위의 CDP 페이지 검색과 수동 DOM, 접근성, 스크립트, 네트워크, 콘솔 및 저장소 조사 |
|
||||
| 브라우저 관찰 | 8 | origin 제한 CDP 캡처, bundle/source map 분석, WebMCP 검색, 세션 타임라인, capture diff 및 시각 증거 |
|
||||
| Electron 관찰 | 2 | canonical 파일 루트 내 페이지 검색과 수동 DOM, 리소스 및 별도 승인된 스크립트 소스 조사 |
|
||||
| 바이너리 세션 | 18 | `open_binary`, `binary_session`, 증거 번들, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 |
|
||||
|
||||
## 다른 코딩 에이전트에서 사용하기
|
||||
|
||||
+5
-4
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#70-个工具组成的工作台)
|
||||
[](#78-个工具组成的工作台)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [70 个工具](#70-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [78 个工具](#78-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -145,7 +145,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
- 分析 Swift 和 Objective-C 元数据。
|
||||
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
|
||||
|
||||
## 70 个工具组成的工作台
|
||||
## 78 个工具组成的工作台
|
||||
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| -------------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -153,7 +153,8 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
|
||||
| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
|
||||
| 制品图谱 | 2 | 确定性目录、ZIP/APK/IPA 与 ASAR 清单,以及显式选择的事务式提取 |
|
||||
| 浏览器观察 | 2 | 基于精确来源范围的 CDP 页面发现,以及被动 DOM、无障碍树、脚本、网络、控制台与存储检查 |
|
||||
| 浏览器观察 | 8 | 精确来源 CDP 抓取、bundle/source map 分析、WebMCP 发现、会话时间线、capture diff 与视觉证据 |
|
||||
| Electron 观察 | 2 | 限定 canonical 文件根的页面发现,以及被动 DOM、资源与另行批准的脚本源码检查 |
|
||||
| 二进制会话 | 18 | `open_binary`、`binary_session`、证据包、进程、制品与函数比较、残余未知项注册表 |
|
||||
|
||||
## 与其他编程智能体一起使用
|
||||
|
||||
@@ -25,7 +25,7 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph "Contracts Layer"
|
||||
CONTRACTS["src/contracts/<br/>Caller-visible tool schemas<br/>70 analysis, browser, and session tools"]
|
||||
CONTRACTS["src/contracts/<br/>Caller-visible tool schemas<br/>78 analysis, browser, Electron, and session tools"]
|
||||
end
|
||||
|
||||
subgraph "Domain Layer"
|
||||
|
||||
+50
-11
@@ -5,10 +5,18 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools
|
||||
## Shipped surfaces
|
||||
|
||||
- `list_browser_targets` / `rea list-browser-targets` discovers page targets whose current URL matches an approved exact origin.
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures bounded DOM structure, accessibility nodes, scripts, resources, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names or script sources.
|
||||
- Both surfaces return the same provider-neutral normalized results and Evidence v2 provenance.
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures bounded DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names or script sources.
|
||||
- `analyze_web_bundle` / `rea analyze-web-bundle` parses approved script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally approved source-map/original-source evidence.
|
||||
- `observe_web_session` / `rea observe-web-session` arms a bounded window for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to inventory page registrations. REA never exposes `WebMCP.invokeTool`.
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares stable DOM, script, resource, network, metadata, and optional WebMCP identities without treating incomplete absence as equivalence.
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns an explicitly approved, bounded, content-addressed visible-viewport PNG.
|
||||
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
||||
- Every surface has equivalent CLI and MCP contracts and returns Evidence v2 provenance.
|
||||
- MCP session results are retained as `rea://evidence/{evidenceId}` resources. One-shot CLI output is not retained by a long-lived REA session.
|
||||
|
||||
Electron `file://` pages use a separate provider, permission capability, and root model; see [electron-observation.md](electron-observation.md).
|
||||
|
||||
## Start a browser
|
||||
|
||||
Start a separate browser profile with an explicit debugging port. The exact executable name varies by platform and installation:
|
||||
@@ -60,17 +68,39 @@ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
|
||||
--json
|
||||
```
|
||||
|
||||
Script content and storage key names require separate opt-ins:
|
||||
Static bundle analysis and a user-driven observation window are separate operations:
|
||||
|
||||
```bash
|
||||
rea analyze-web-bundle http://127.0.0.1:9222 TARGET_ID \
|
||||
--approved --source-capture-approved --json
|
||||
rea observe-web-session http://127.0.0.1:9222 TARGET_ID \
|
||||
--approved --observation-ms 10000 --json
|
||||
rea discover-webmcp-tools http://127.0.0.1:9222 TARGET_ID \
|
||||
--approved --json
|
||||
```
|
||||
|
||||
Accessibility names/descriptions, console primitive text, JSON body shapes, WebSocket shapes, script content, and storage key names require separate opt-ins. Console/body/WebSocket capture additionally requires its matching approval flag:
|
||||
|
||||
```bash
|
||||
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
|
||||
--approved \
|
||||
--include-accessibility-text \
|
||||
--include-console-text --console-text-approved \
|
||||
--include-json-body-shapes --json-body-schema-approved \
|
||||
--include-websocket-shapes --websocket-shape-approved \
|
||||
--include-script-sources \
|
||||
--include-storage-keys \
|
||||
--json
|
||||
```
|
||||
|
||||
Script content may contain application secrets and becomes part of the returned Evidence. Storage values remain redacted even when key-name capture is approved.
|
||||
Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and truncation only, never values or examples. Storage values remain redacted even when key-name capture is approved.
|
||||
|
||||
Screenshot pixels require both `--approved` and `--screenshot-approved`:
|
||||
|
||||
```bash
|
||||
rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \
|
||||
--approved --screenshot-approved --json
|
||||
```
|
||||
|
||||
## MCP input
|
||||
|
||||
@@ -81,6 +111,13 @@ Script content may contain application secrets and becomes part of the returned
|
||||
"target_id": "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
"approved": true,
|
||||
"observation_ms": 500,
|
||||
"include_accessibility_text": false,
|
||||
"include_console_text": false,
|
||||
"console_text_approved": false,
|
||||
"include_json_body_shapes": false,
|
||||
"json_body_schema_approved": false,
|
||||
"include_websocket_shapes": false,
|
||||
"websocket_shape_approved": false,
|
||||
"include_script_sources": false,
|
||||
"include_storage_keys": false
|
||||
}
|
||||
@@ -96,11 +133,13 @@ REA removes sensitive values before normalized event data is retained:
|
||||
|
||||
- URLs retain a bounded path and at most 256 bounded query parameter names, replace every retained query value with `[REDACTED]`, and remove credentials and fragments.
|
||||
- DOM snapshots retain node types, node names, value lengths, and attribute names, but not text or attribute values.
|
||||
- Network observations retain method, status, MIME type, size, type, and redacted URLs, but not request/response headers, bodies, post data, cookies, or response content.
|
||||
- Console observations with an approved stack source retain call type, argument types, timestamp, and redacted source location, but not argument values or descriptions. Events without a source URL are excluded because their origin cannot be established.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length, but not payload content.
|
||||
- Accessibility structure and roles are retained by default, but names and descriptions require `include_accessibility_text: true` and remain byte-bounded.
|
||||
- Network observations retain method, status, MIME type, size, type, initiator stack location, and redacted URLs. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained.
|
||||
- Request/response bodies are not requested or parsed by default. With independent approval, only allowed-origin JSON media types are read within per-body and aggregate budgets, converted immediately to value-free property paths/types, and discarded. `Network.getResponseBody` is never sent without that approval.
|
||||
- Console observations with an approved stack source retain call type, argument types, timestamp, and redacted source location. With independent approval, only already-delivered primitive values are retained after credential redaction and byte bounds; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. With independent approval, bounded text frames are classified as text or value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names are omitted unless explicitly requested.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. URL-less scripts are excluded because their origin cannot be established. Source content is omitted unless explicitly requested and remains subject to per-script and aggregate byte limits.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. Source content is omitted unless explicitly requested and becomes a self-verifying `rea://web-content/sha256/...` artifact subject to per-script and aggregate byte limits.
|
||||
|
||||
Cross-origin frames, resources, scripts, events, and workers are excluded unless their exact origins are also approved. Excluded target details are counted without being exposed.
|
||||
|
||||
@@ -108,14 +147,14 @@ Cross-origin frames, resources, scripts, events, and workers are excluded unless
|
||||
|
||||
The default observation window is 500 ms and the maximum is 10 seconds. Frames (200), DOM nodes (2,000), accessibility nodes (2,000), scripts (200), resources (2,000), workers (500), and each storage key/name inventory (1,000) have conservative defaults and caller-visible hard bounds. Script source bytes, network events, console events, and WebSocket events are bounded separately. The CLI exposes the same limits as kebab-case options such as `--max-frames` and `--max-storage-keys`.
|
||||
|
||||
A bounded result reports `completeness.status: "truncated"`, named truncated sections, and dropped event counts rather than implying exhaustive capture. Disallowed-origin entries are filtered before collection-limit accounting, so they cannot consume an approved origin's output budget.
|
||||
Every result distinguishes `complete_within_window`, `policy_filtered`, `attach_limited`, and `truncated` coverage. It names affected sections and reports sparse excluded-item and per-event-type dropped counts rather than implying exhaustive capture. Disallowed-origin entries are filtered before collection-limit accounting, so they cannot consume an approved origin's output budget.
|
||||
|
||||
CDP discovery, WebSocket connection, and each command have a 5-second timeout. Version discovery is capped at 64 KiB, target discovery at 2 MiB and 1,000 targets, each WebSocket message at 16 MiB, and correlated pending commands at 128. Malformed unsolicited protocol events poison the connection so later commands fail closed.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are reported only as redacted declarative URLs and are never fetched.
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis can fetch them only after separate approval, with exact-origin redirect checks, no credentials/cookies/referrer, per-map and aggregate byte limits, a total map-count limit, v3 validation, and bounded generated/original mappings. Source-map results report requested, processed, and dropped counts rather than hiding budget truncation.
|
||||
|
||||
## Non-goals and threat model
|
||||
|
||||
The first browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, navigation, input, downloads, screenshots, page closure, or browser closure. REA disables the domains it enabled, detaches the target session, and closes only its own WebSocket.
|
||||
The browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, WebMCP invocation, navigation, input, downloads, page closure, or browser closure. Screenshot capture is the sole pixel surface and requires separate literal approval. REA disables the domains it enabled, detaches the target session, and closes only its own WebSocket.
|
||||
|
||||
This feature is not a browser sandbox or network containment mechanism. The attached page and browser continue running with their existing privileges and may make external requests independently of REA. CDP gives deep access to the selected browser profile, so use a dedicated profile, approve only origins you intend to inspect, and treat other same-user processes as outside this boundary.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Electron file-page observation
|
||||
|
||||
REA can attach to a user-owned Electron/Chromium CDP endpoint and inspect existing `file://` renderer pages without evaluating JavaScript or invoking Electron APIs. Electron observation is separate from website observation because filesystem roots, not HTTP origins, define its authority.
|
||||
|
||||
## Configure authority
|
||||
|
||||
The capability is disabled by default:
|
||||
|
||||
```bash
|
||||
export REA_ELECTRON_OBSERVE_ENABLED=true
|
||||
export REA_ELECTRON_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9223"]'
|
||||
export REA_ELECTRON_FILE_ROOTS_JSON='["/Applications/Example.app/Contents/Resources"]'
|
||||
```
|
||||
|
||||
Endpoints accept only explicit-port loopback HTTP URLs. Roots are canonicalized by the shared permission authority. Each target, frame, script, and resource is independently converted from a hostless `file://` URL to a real path and checked after symlink resolution. UNC hosts, percent-encoded path separators, nonexistent paths, and root escapes are rejected.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
rea list-electron-targets http://127.0.0.1:9223 --approved --json
|
||||
rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \
|
||||
--approved --observation-ms 100 --json
|
||||
```
|
||||
|
||||
Script content is excluded by default. Capturing it requires both flags and remains subject to per-script and aggregate byte budgets:
|
||||
|
||||
```bash
|
||||
rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \
|
||||
--approved \
|
||||
--include-script-sources \
|
||||
--source-capture-approved \
|
||||
--json
|
||||
```
|
||||
|
||||
The normalized result contains canonical local paths, bounded frame and DOM structure, resource metadata, stable script/resource identities, explicit completeness, and content-addressed approved source artifacts. It does not retain DOM values, execute renderer code, navigate, click, invoke Electron IPC, close a target, or terminate the application.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Guided MCP prompts and completion
|
||||
|
||||
REA exposes six provider-neutral investigation workflows through the MCP
|
||||
`prompts` capability. They complement the 70 tools; they do not add, remove, or
|
||||
`prompts` capability. They complement the 78 tools; they do not add, remove, or
|
||||
invoke tools by themselves.
|
||||
|
||||
## Prompt inventory
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
{
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"entry": [
|
||||
"src/main.ts",
|
||||
"src/cli.ts",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/scan-todos.mjs"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts",
|
||||
"scripts/**/*.mjs",
|
||||
"vitest.config.ts"
|
||||
],
|
||||
"ignore": ["dist/**", "docs/**", ".codex/**", ".devin/**"],
|
||||
"entry": ["src/main.ts", "src/cli.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
|
||||
"ignoreDependencies": [],
|
||||
"rules": {
|
||||
"files": "error",
|
||||
|
||||
Generated
+3
-5
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "8.0.4",
|
||||
"@babel/types": "8.0.4",
|
||||
"@electron/asar": "4.2.0",
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.4",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
@@ -221,7 +222,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -231,14 +231,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -71,6 +71,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
|
||||
"verify:browser": "npm run build && node scripts/verify-real-browser.mjs",
|
||||
"verify:process:stress": "npm run build && node scripts/verify-process-cleanup-stress.mjs",
|
||||
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
|
||||
"verify:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs",
|
||||
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
||||
@@ -92,6 +93,7 @@
|
||||
"@babel/parser": "8.0.4",
|
||||
"@babel/types": "8.0.4",
|
||||
"@electron/asar": "4.2.0",
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.4",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
/** Start the local HTTP/WS application used by real-browser verification. */
|
||||
export async function startBrowserVerifierSite() {
|
||||
let port = 0;
|
||||
let sessionGeneration = 0;
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === "/app.js.map") {
|
||||
response.setHeader("content-type", "application/source-map+json");
|
||||
response.end(sourceMap());
|
||||
return;
|
||||
}
|
||||
if (request.url === "/session-generation") {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ generation: sessionGeneration }));
|
||||
return;
|
||||
}
|
||||
if (request.url?.startsWith("/app.js") === true) {
|
||||
response.setHeader("content-type", "text/javascript");
|
||||
response.end(browserScript(port));
|
||||
return;
|
||||
}
|
||||
if (request.url?.startsWith("/api") === true) {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.setHeader(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; connect-src 'self' ws:",
|
||||
);
|
||||
response.setHeader(
|
||||
"link",
|
||||
'</.well-known/mcp.json>; rel="service-desc"; type="application/json"',
|
||||
);
|
||||
response.setHeader("permissions-policy", "geolocation=(), camera=()");
|
||||
response.end('{"ok":true,"secret":"response-secret-value"}');
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(
|
||||
'<!doctype html><html><head><title>REA browser verifier</title></head><body><main><h1>Browser evidence</h1><button aria-label="ax-private-label-value">Verify</button></main><script type="module" src="/app.js?token=script-query-secret"></script></body></html>',
|
||||
);
|
||||
});
|
||||
const webSockets = new WebSocketServer({ noServer: true });
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
webSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
||||
webSocket.on("message", () =>
|
||||
webSocket.send("websocket-response-secret"),
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Local browser verifier did not bind a TCP port");
|
||||
port = address.port;
|
||||
return {
|
||||
origin: `http://127.0.0.1:${String(port)}`,
|
||||
triggerSessionNavigation() {
|
||||
sessionGeneration += 1;
|
||||
},
|
||||
async close() {
|
||||
for (const client of webSockets.clients) client.terminate();
|
||||
await new Promise((resolve) => webSockets.close(resolve));
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const browserScript = (port) => `
|
||||
export const reaSourceMarker = "approved-source-marker";
|
||||
export const verifiedRoute = { path: "/verified-route" };
|
||||
localStorage.setItem("rea-storage-key", "storage-secret-value");
|
||||
indexedDB.open("rea-browser-db", 1);
|
||||
caches.open("rea-browser-cache");
|
||||
const observe = () => {
|
||||
console.log("rea-browser-observation", "authorization=Bearer console-secret-value");
|
||||
fetch("/api?token=network-secret-value", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer request-secret-value",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ token: "request-body-secret-value", active: true })
|
||||
});
|
||||
const socket = new WebSocket("ws://127.0.0.1:${String(port)}/live?token=websocket-url-secret");
|
||||
socket.addEventListener("open", () => socket.send(JSON.stringify({ token: "websocket-secret-value" })));
|
||||
socket.addEventListener("message", () => socket.close());
|
||||
};
|
||||
observe();
|
||||
setInterval(observe, 150);
|
||||
let observedGeneration = 0;
|
||||
setInterval(async () => {
|
||||
const response = await fetch("/session-generation");
|
||||
const { generation } = await response.json();
|
||||
if (generation <= observedGeneration) return;
|
||||
observedGeneration = generation;
|
||||
history.pushState({}, "", "/app/session-" + String(generation));
|
||||
}, 100);
|
||||
//# sourceMappingURL=/app.js.map
|
||||
`;
|
||||
|
||||
const sourceMap = () =>
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
file: "app.js",
|
||||
names: ["reaSourceMarker"],
|
||||
sources: ["../src/main.ts"],
|
||||
sourcesContent: [
|
||||
"import './dependency.ts';\nexport const reaSourceMarker = 'approved-source-marker';",
|
||||
],
|
||||
mappings: "AAAAA",
|
||||
});
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
||||
// such as `mcp add`, while only dist/main.js may serve the 70-tool stdio server.
|
||||
// such as `mcp add`, while only dist/main.js may serve the 78-tool stdio server.
|
||||
const args = process.argv.slice(2);
|
||||
const { default: packageJson } = await import("../package.json", {
|
||||
with: { type: "json" },
|
||||
|
||||
@@ -345,17 +345,22 @@ try {
|
||||
);
|
||||
const planned = json(plannedExecution.stdout);
|
||||
if (supportedSetupHost) {
|
||||
const plannedClaudeConfig = await readFile(claudeConfig, "utf8");
|
||||
const plannedCursorConfig = await readFile(cursorConfig, "utf8");
|
||||
const plannedSkill = await readFile(skillPath, "utf8");
|
||||
if (
|
||||
planned.status !== "needs_confirmation" ||
|
||||
plannedExecution.status !== 1 ||
|
||||
planned.appliedActions.length !== 0 ||
|
||||
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
|
||||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
|
||||
(await readFile(claudeConfig, "utf8")) !== '{"existing":true}\n' ||
|
||||
(await readFile(cursorConfig, "utf8")) !== '{"existing":true}\n' ||
|
||||
(await readFile(skillPath, "utf8")) !== "stale managed skill\n"
|
||||
plannedClaudeConfig !== '{"existing":true}\n' ||
|
||||
plannedCursorConfig !== '{"existing":true}\n' ||
|
||||
plannedSkill !== "stale managed skill\n"
|
||||
)
|
||||
throw new Error("packaged setup plan mutated files before approval");
|
||||
throw new Error(
|
||||
`packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, cursorConfig: plannedCursorConfig, skill: plannedSkill })}`,
|
||||
);
|
||||
}
|
||||
const firstExecution = await runWithStatus(
|
||||
cli,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
captureProcessScenario,
|
||||
probeProcessCaptureCapability,
|
||||
} from "../dist/application/ProcessHarness.js";
|
||||
import { parseProcessScenario } from "../dist/domain/processScenario.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const fixture = fileURLToPath(
|
||||
new URL("../tests/fixtures/processFidelity.mjs", import.meta.url),
|
||||
);
|
||||
const iterations = Number.parseInt(
|
||||
process.env.REA_PROCESS_STRESS_RUNS ?? "25",
|
||||
10,
|
||||
);
|
||||
|
||||
if (process.platform !== "linux")
|
||||
throw new Error("Real forkpty cleanup stress verification requires Linux");
|
||||
if (!Number.isSafeInteger(iterations) || iterations < 1 || iterations > 250)
|
||||
throw new Error("REA_PROCESS_STRESS_RUNS must be an integer from 1 to 250");
|
||||
const capability = await probeProcessCaptureCapability();
|
||||
if (!capability.available)
|
||||
throw new Error(`Process capture unavailable: ${capability.reason}`);
|
||||
|
||||
for (let iteration = 1; iteration <= iterations; iteration += 1) {
|
||||
const result = await captureProcessScenario(
|
||||
parseProcessScenario({
|
||||
approved: true,
|
||||
executable: process.execPath,
|
||||
arguments: [fixture, "tree"],
|
||||
working_directory: dirname(fixture),
|
||||
timeout_ms: 1_000,
|
||||
idle_timeout_ms: 2_000,
|
||||
settle_ms: 50,
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
executableRoots: [dirname(process.execPath)],
|
||||
workingRoots: [dirname(fixture)],
|
||||
allowedEnvironment: [],
|
||||
allowExternalNetwork: true,
|
||||
},
|
||||
);
|
||||
if (!result.ok)
|
||||
throw new Error(`forkpty cleanup run ${String(iteration)} failed`, {
|
||||
cause: result.error,
|
||||
});
|
||||
if (
|
||||
result.value.cleanup.owned_process_group !== "verified" ||
|
||||
result.value.cleanup.temporary_root !== "removed"
|
||||
)
|
||||
throw new Error(`forkpty cleanup run ${String(iteration)} was incomplete`);
|
||||
const commands = result.value.process_samples.map(({ command }) => command);
|
||||
if (!commands.some((command) => command.includes("tree-child")))
|
||||
throw new Error(
|
||||
`forkpty cleanup run ${String(iteration)} missed its child`,
|
||||
);
|
||||
|
||||
const { stdout } = await execFileAsync("ps", ["-axo", "command="]);
|
||||
if (
|
||||
stdout.includes(`${fixture} tree-child`) ||
|
||||
stdout.includes(`${fixture} tree-grandchild`)
|
||||
)
|
||||
throw new Error(
|
||||
`forkpty cleanup run ${String(iteration)} leaked a process`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ platform: process.platform, iterations, verified: true })}\n`,
|
||||
);
|
||||
@@ -22,7 +22,7 @@ transport.stderr?.on("data", (chunk) => {
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
if (tools.tools.length !== 70)
|
||||
if (tools.tools.length !== 78)
|
||||
throw new Error(
|
||||
`Published MCP exposed ${String(tools.tools.length)} tools`,
|
||||
);
|
||||
@@ -40,5 +40,5 @@ try {
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ package: "rea-agents", version, mcpTools: 70 })}\n`,
|
||||
`${JSON.stringify({ package: "rea-agents", version, mcpTools: 78 })}\n`,
|
||||
);
|
||||
|
||||
+250
-70
@@ -2,28 +2,34 @@
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
} from "../dist/domain/browserObservation.js";
|
||||
import { analyzeWebBundleInputSchema } from "../dist/domain/webBundleAnalysis.js";
|
||||
import { observeWebSessionInputSchema } from "../dist/domain/browserSession.js";
|
||||
import { compareWebCapturesInputSchema } from "../dist/domain/webCaptureDiff.js";
|
||||
import {
|
||||
captureWebScreenshotInputSchema,
|
||||
compareWebScreenshotsInputSchema,
|
||||
} from "../dist/domain/webScreenshot.js";
|
||||
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
|
||||
|
||||
const SECRET_VALUES = [
|
||||
"network-secret-value",
|
||||
"console-secret-value",
|
||||
"storage-secret-value",
|
||||
"websocket-secret-value",
|
||||
"ax-private-label-value",
|
||||
];
|
||||
|
||||
const executable = await browserExecutable();
|
||||
const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-"));
|
||||
const site = await startSite();
|
||||
const site = await startBrowserVerifierSite();
|
||||
let browser;
|
||||
try {
|
||||
browser = spawn(
|
||||
@@ -77,19 +83,113 @@ try {
|
||||
approved: true,
|
||||
target_id: target,
|
||||
observation_ms: 200,
|
||||
include_accessibility_text: true,
|
||||
include_script_sources: true,
|
||||
include_console_text: true,
|
||||
console_text_approved: true,
|
||||
include_json_body_shapes: true,
|
||||
json_body_schema_approved: true,
|
||||
include_websocket_shapes: true,
|
||||
websocket_shape_approved: true,
|
||||
}),
|
||||
);
|
||||
if (!withSource.ok) throw withSource.error;
|
||||
const source = withSource.value.scripts.items.find(
|
||||
(script) =>
|
||||
script.source.included &&
|
||||
script.source.content.includes("reaSourceMarker"),
|
||||
script.source.artifact.text.includes("reaSourceMarker"),
|
||||
);
|
||||
if (source === undefined)
|
||||
throw new Error(
|
||||
"Real Chrome did not return explicitly approved script source",
|
||||
);
|
||||
if (
|
||||
!withSource.value.accessibility.nodes.some((node) =>
|
||||
node.name?.includes("ax-private-label-value"),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome did not return independently approved accessibility text",
|
||||
);
|
||||
assertSensitiveShapes(withSource.value);
|
||||
|
||||
const bundle = await provider.analyzeBundle(
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [site.origin],
|
||||
approved: true,
|
||||
target_id: target,
|
||||
observation_ms: 200,
|
||||
source_capture_approved: true,
|
||||
fetch_source_maps: true,
|
||||
source_map_fetch_approved: true,
|
||||
}),
|
||||
);
|
||||
if (!bundle.ok) throw bundle.error;
|
||||
assertBundleAnalysis(bundle.value);
|
||||
|
||||
const captureDiff = await provider.compareCaptures(
|
||||
compareWebCapturesInputSchema.parse({
|
||||
before: { inspection: observed.value },
|
||||
after: { inspection: withSource.value },
|
||||
}),
|
||||
);
|
||||
if (!captureDiff.ok) throw captureDiff.error;
|
||||
if (
|
||||
captureDiff.value.dimensions.dom_structure.status !== "unchanged" ||
|
||||
captureDiff.value.dimensions.scripts.status !== "unchanged"
|
||||
)
|
||||
throw new Error("Real Chrome stable capture identities did not reconcile");
|
||||
|
||||
const screenshot = await provider.captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [site.origin],
|
||||
approved: true,
|
||||
screenshot_approved: true,
|
||||
target_id: target,
|
||||
}),
|
||||
);
|
||||
if (!screenshot.ok) throw screenshot.error;
|
||||
if (
|
||||
screenshot.value.viewport.width < 1 ||
|
||||
screenshot.value.viewport.height < 1 ||
|
||||
screenshot.value.artifact.bytes < 1
|
||||
)
|
||||
throw new Error("Real Chrome screenshot artifact was empty");
|
||||
const screenshotDiff = await provider.compareScreenshots(
|
||||
compareWebScreenshotsInputSchema.parse({
|
||||
before: screenshot.value.artifact,
|
||||
after: screenshot.value.artifact,
|
||||
}),
|
||||
);
|
||||
if (!screenshotDiff.ok) throw screenshotDiff.error;
|
||||
if (
|
||||
screenshotDiff.value.status !== "identical" ||
|
||||
screenshotDiff.value.changed_pixels !== 0
|
||||
)
|
||||
throw new Error("Real Chrome PNG artifact did not compare identically");
|
||||
|
||||
const sessionPromise = provider.observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [site.origin],
|
||||
approved: true,
|
||||
target_id: target,
|
||||
observation_ms: 1_500,
|
||||
}),
|
||||
);
|
||||
await delay(250);
|
||||
site.triggerSessionNavigation();
|
||||
const session = await sessionPromise;
|
||||
if (!session.ok) throw session.error;
|
||||
if (
|
||||
!session.value.timeline.some(
|
||||
({ type }) => type === "same_document_navigation",
|
||||
) ||
|
||||
!session.value.target.final_url?.includes("/app/session-1")
|
||||
)
|
||||
throw new Error("Real Chrome same-origin SPA timeline was missing");
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -102,13 +202,22 @@ try {
|
||||
networkRequests: observed.value.network.requests.length,
|
||||
consoleEvents: observed.value.console.events.length,
|
||||
websocketEvents: observed.value.network.websocket_events.length,
|
||||
bundleScripts: bundle.value.capture.scripts_analyzed,
|
||||
sourceMaps: bundle.value.observations.source_maps.processed,
|
||||
sessionEvents: session.value.timeline.length,
|
||||
screenshotBytes: screenshot.value.artifact.bytes,
|
||||
verified: true,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
if (browser !== undefined) await stopProcess(browser);
|
||||
await site.close();
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
await rm(profile, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async function browserExecutable() {
|
||||
@@ -178,6 +287,13 @@ function assertObservation(result, origin) {
|
||||
);
|
||||
if (result.accessibility.nodes.length < 1)
|
||||
throw new Error("Real Chrome accessibility observation was empty");
|
||||
if (
|
||||
result.accessibility.text_capture.status !== "not_approved" ||
|
||||
result.accessibility.nodes.some(
|
||||
(node) => node.name !== null || node.description !== null,
|
||||
)
|
||||
)
|
||||
throw new Error("Accessibility text was retained without approval");
|
||||
if (!result.scripts.items.some((script) => script.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome script metadata was missing");
|
||||
if (!result.resources.some((resource) => resource.url.includes("/app.js")))
|
||||
@@ -186,86 +302,143 @@ function assertObservation(result, origin) {
|
||||
throw new Error(
|
||||
"Real Chrome attach-window network observation was missing",
|
||||
);
|
||||
const initiatedRequest = result.network.requests.find((request) =>
|
||||
request.url.includes("/api"),
|
||||
);
|
||||
if (
|
||||
!initiatedRequest?.initiator.url?.includes("/app.js") ||
|
||||
initiatedRequest.initiator.line === null ||
|
||||
initiatedRequest.initiator.column === null
|
||||
)
|
||||
throw new Error("Real Chrome script initiator stack was not normalized");
|
||||
if (result.console.events.length < 1)
|
||||
throw new Error(
|
||||
"Real Chrome attach-window console observation was missing",
|
||||
);
|
||||
if (result.network.websocket_events.length < 1)
|
||||
throw new Error("Real Chrome WebSocket metadata was missing");
|
||||
if (
|
||||
result.console.events.some(
|
||||
(event) => event.text_capture.status !== "not_approved",
|
||||
) ||
|
||||
result.network.requests.some(
|
||||
(request) => request.body_shapes.status !== "not_approved",
|
||||
) ||
|
||||
result.network.websocket_events.some(
|
||||
(event) => event.payload_shape !== null,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Sensitive text or payload shapes were retained without approval",
|
||||
);
|
||||
if (!result.storage.local_storage_keys.includes("rea-storage-key"))
|
||||
throw new Error("Real Chrome local-storage key inventory was missing");
|
||||
if (!result.storage.indexed_db_names.includes("rea-browser-db"))
|
||||
throw new Error("Real Chrome IndexedDB name inventory was missing");
|
||||
if (!result.storage.cache_names.includes("rea-browser-cache"))
|
||||
throw new Error("Real Chrome cache name inventory was missing");
|
||||
if (
|
||||
!result.metadata.responses.some(({ csp }) =>
|
||||
csp.directives.some(({ name }) => name === "default-src"),
|
||||
) ||
|
||||
!result.metadata.agent_hints.some(
|
||||
({ declaration }) => declaration === "service-desc",
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome safe response metadata was missing");
|
||||
}
|
||||
|
||||
async function startSite() {
|
||||
let port = 0;
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url?.startsWith("/app.js") === true) {
|
||||
response.setHeader("content-type", "text/javascript");
|
||||
response.end(script(port));
|
||||
return;
|
||||
}
|
||||
if (request.url?.startsWith("/api") === true) {
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end('{"ok":true,"secret":"response-secret-value"}');
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(
|
||||
'<!doctype html><html><head><title>REA browser verifier</title></head><body><main><h1>Browser evidence</h1><button aria-label="Verify browser">Verify</button></main><script type="module" src="/app.js?token=script-query-secret"></script></body></html>',
|
||||
function assertBundleAnalysis(result) {
|
||||
if (
|
||||
result.capture.scripts_analyzed < 1 ||
|
||||
!result.observations.routes.some(({ value }) =>
|
||||
value.includes("/verified-route"),
|
||||
) ||
|
||||
!result.observations.endpoints.some(({ value }) => value.includes("/api"))
|
||||
)
|
||||
throw new Error("Real Chrome static bundle findings were missing");
|
||||
if (
|
||||
result.observations.source_maps.status !== "included" ||
|
||||
!result.observations.source_maps.items.some(
|
||||
({ status, original_sources, original_module_edges, mappings }) =>
|
||||
status === "included" &&
|
||||
original_sources.some(({ source }) =>
|
||||
source.includes("/src/main.ts"),
|
||||
) &&
|
||||
original_module_edges.some(
|
||||
({ specifier }) => specifier === "./dependency.ts",
|
||||
) &&
|
||||
mappings.length > 0,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Real Chrome approved source-map reconstruction was missing: ${JSON.stringify(sourceMapSummary(result.observations.source_maps))}`,
|
||||
);
|
||||
});
|
||||
const webSockets = new WebSocketServer({ noServer: true });
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
webSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
||||
webSocket.on("message", () =>
|
||||
webSocket.send("websocket-response-secret"),
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Local browser verifier did not bind a TCP port");
|
||||
port = address.port;
|
||||
}
|
||||
|
||||
function sourceMapSummary(sourceMaps) {
|
||||
return {
|
||||
origin: `http://127.0.0.1:${String(port)}`,
|
||||
async close() {
|
||||
for (const client of webSockets.clients) client.terminate();
|
||||
await new Promise((resolve) => webSockets.close(resolve));
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
);
|
||||
},
|
||||
status: sourceMaps.status,
|
||||
requested: sourceMaps.requested,
|
||||
processed: sourceMaps.processed,
|
||||
dropped: sourceMaps.dropped,
|
||||
items: sourceMaps.items.map((item) => ({
|
||||
status: item.status,
|
||||
declaredUrl: item.declared_url,
|
||||
sources: item.original_sources.map(({ source }) => source),
|
||||
edgeSpecifiers: item.original_module_edges.map(({ specifier }) =>
|
||||
specifier.slice(0, 256),
|
||||
),
|
||||
mappings: item.mappings.length,
|
||||
limitation: item.limitation,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function script(port) {
|
||||
return `
|
||||
export const reaSourceMarker = "approved-source-marker";
|
||||
localStorage.setItem("rea-storage-key", "storage-secret-value");
|
||||
indexedDB.open("rea-browser-db", 1);
|
||||
caches.open("rea-browser-cache");
|
||||
const observe = () => {
|
||||
console.log("rea-browser-observation", "console-secret-value");
|
||||
fetch("/api?token=network-secret-value", {
|
||||
headers: { Authorization: "Bearer request-secret-value" }
|
||||
});
|
||||
const socket = new WebSocket("ws://127.0.0.1:${String(port)}/live?token=websocket-url-secret");
|
||||
socket.addEventListener("open", () => socket.send("websocket-secret-value"));
|
||||
socket.addEventListener("message", () => socket.close());
|
||||
};
|
||||
observe();
|
||||
setInterval(observe, 150);
|
||||
`;
|
||||
function assertSensitiveShapes(result) {
|
||||
const request = result.network.requests.find((item) =>
|
||||
item.url.includes("/api"),
|
||||
);
|
||||
const requestPaths = request?.body_shapes.request?.properties.map(
|
||||
({ path }) => path,
|
||||
);
|
||||
const responsePaths = request?.body_shapes.response?.properties.map(
|
||||
({ path }) => path,
|
||||
);
|
||||
if (!requestPaths?.includes("/token") || !responsePaths?.includes("/secret"))
|
||||
throw new Error("Real Chrome JSON request/response shapes were missing");
|
||||
const consoleText = result.console.events.flatMap(
|
||||
(event) => event.text_capture.values,
|
||||
);
|
||||
if (
|
||||
!consoleText.some(({ text }) => text.includes("[REDACTED]")) ||
|
||||
consoleText.some(({ text }) => text.includes("console-secret-value"))
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome approved console text was not safely redacted",
|
||||
);
|
||||
if (
|
||||
!result.network.websocket_events.some(
|
||||
(event) =>
|
||||
event.payload_shape?.format === "json" &&
|
||||
event.payload_shape.json_shape?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome WebSocket JSON shape was missing");
|
||||
const serialized = JSON.stringify({
|
||||
console: result.console,
|
||||
network: result.network,
|
||||
});
|
||||
for (const secret of [
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"websocket-secret-value",
|
||||
"console-secret-value",
|
||||
])
|
||||
if (serialized.includes(secret))
|
||||
throw new Error(`Approved shape capture retained raw value: ${secret}`);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
@@ -273,14 +446,21 @@ function delay(milliseconds) {
|
||||
}
|
||||
|
||||
async function stopProcess(child) {
|
||||
if (child.exitCode !== null) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const gracefulExit = waitForExit(child);
|
||||
child.kill("SIGTERM");
|
||||
const exited = await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
||||
gracefulExit.then(() => true),
|
||||
delay(2_000).then(() => false),
|
||||
]);
|
||||
if (!exited) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const forcedExit = waitForExit(child);
|
||||
child.kill("SIGKILL");
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
await forcedExit;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForExit(child) {
|
||||
return new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ try {
|
||||
const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort();
|
||||
const actualNames = listed.tools.map(({ name }) => name).sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error("The real server did not expose the intended 70 tools");
|
||||
throw new Error("The real server did not expose the intended 78 tools");
|
||||
}
|
||||
|
||||
const options = { timeout };
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
name: rea-analysis
|
||||
description: Reverse engineer apps and websites with REA. Explore how features work, then build a version tailored to your project.
|
||||
metadata:
|
||||
version: "13"
|
||||
tool_count: 70
|
||||
version: "14"
|
||||
tool_count: 78
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
Use REA when the user wants to understand how an app or feature works, compare app versions, decompile code, or build a similar feature.
|
||||
|
||||
For a website already open in a user-owned Chrome-family browser, use
|
||||
`list_browser_targets` and `inspect_web_page` instead of opening a binary. Ask
|
||||
For a website already open in a user-owned Chrome-family browser, start with
|
||||
`list_browser_targets` and `inspect_web_page` instead of opening a binary. Use
|
||||
`analyze_web_bundle`, `observe_web_session`, `discover_webmcp_tools`, capture
|
||||
comparison, or screenshot tools only for the corresponding investigation. Ask
|
||||
the user to approve the exact page origins and loopback CDP endpoint before the
|
||||
first call. Browser observation is disabled unless the operator configured
|
||||
`REA_BROWSER_OBSERVE_ENABLED`, `REA_BROWSER_CDP_ENDPOINTS_JSON`, and
|
||||
@@ -19,11 +21,19 @@ first call. Browser observation is disabled unless the operator configured
|
||||
|
||||
Browser inspection is passive. Never claim that it clicked, navigated,
|
||||
evaluated page JavaScript, captured prior activity, or contained the page's
|
||||
network. Query values, headers, bodies, cookies, storage values, console values,
|
||||
and WebSocket payloads are deliberately absent. Request script sources or
|
||||
storage key names only when the user separately approves those more sensitive
|
||||
outputs. Treat truncation and attach-window coverage as explicit limitations,
|
||||
and cite the returned Evidence v2 IDs.
|
||||
network. Query values, credentials, cookies, authorization headers, storage
|
||||
values, and raw WebSocket or JSON values are deliberately absent. Accessibility
|
||||
text, bounded redacted console primitives, value-free JSON/WebSocket shapes,
|
||||
script sources, storage key names, source-map fetches, and screenshot pixels
|
||||
each require their documented independent opt-in or approval. Treat truncation,
|
||||
policy filtering, and attach-window coverage as explicit limitations, and cite
|
||||
the returned Evidence v2 IDs. WebMCP declarations are page-declared untrusted;
|
||||
REA inventories them but never invokes them.
|
||||
|
||||
Electron `file://` pages use `list_electron_targets` and
|
||||
`inspect_electron_page` with a separate loopback endpoint and canonical
|
||||
filesystem-root authority. Never claim that Electron inspection invoked IPC,
|
||||
evaluated renderer JavaScript, navigated, or escaped an approved root.
|
||||
|
||||
## Understand the request
|
||||
|
||||
|
||||
@@ -95,11 +95,11 @@ export class AnalysisSnapshotCache {
|
||||
this.#target.architecture !== snapshot.target.architecture)
|
||||
)
|
||||
this.#entries.clear();
|
||||
this.#target = snapshot.target;
|
||||
this.#target = structuredClone(snapshot.target);
|
||||
let imported = 0;
|
||||
for (const entry of snapshot.entries) {
|
||||
if (!this.#entries.has(entry.query_id)) imported += 1;
|
||||
this.#entries.set(entry.query_id, entry);
|
||||
this.#entries.set(entry.query_id, structuredClone(entry));
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export class AnalysisSnapshotCache {
|
||||
snapshot_version: 1,
|
||||
target: snapshotTarget(target),
|
||||
entries: this.entries(),
|
||||
evidence_bundle: evidenceBundle,
|
||||
evidence_bundle: structuredClone(evidenceBundle),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,9 +138,9 @@ export class AnalysisSnapshotCache {
|
||||
|
||||
/** Return canonical entries for persistence. */
|
||||
entries(): AnalysisSnapshotEntry[] {
|
||||
return [...this.#entries.values()].sort((left, right) =>
|
||||
left.query_id.localeCompare(right.query_id),
|
||||
);
|
||||
return [...this.#entries.values()]
|
||||
.sort((left, right) => left.query_id.localeCompare(right.query_id))
|
||||
.map((entry) => structuredClone(entry));
|
||||
}
|
||||
|
||||
/** Replay an exact provider-specific query, marking its cached provenance. */
|
||||
@@ -159,7 +159,7 @@ export class AnalysisSnapshotCache {
|
||||
const cached = this.#entries.get(queryId);
|
||||
if (cached === undefined) return undefined;
|
||||
const subject = cached.execution.subject;
|
||||
return {
|
||||
return structuredClone({
|
||||
result: cached.execution.result,
|
||||
rawResult: cached.execution.raw_result,
|
||||
provider: cached.execution.provider,
|
||||
@@ -183,7 +183,7 @@ export class AnalysisSnapshotCache {
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Record one successful immutable call unless the cache is full. */
|
||||
|
||||
@@ -30,6 +30,17 @@ export interface MutableOccurrence {
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
/** Index the first occurrence for each canonical logical path. */
|
||||
export const indexOccurrencesByPath = (
|
||||
occurrences: readonly MutableOccurrence[],
|
||||
): ReadonlyMap<string, MutableOccurrence> => {
|
||||
const byPath = new Map<string, MutableOccurrence>();
|
||||
for (const occurrence of occurrences)
|
||||
if (!byPath.has(occurrence.logical_path))
|
||||
byPath.set(occurrence.logical_path, occurrence);
|
||||
return byPath;
|
||||
};
|
||||
|
||||
export const createOccurrence = (
|
||||
entry: ArtifactEntry,
|
||||
path: string,
|
||||
@@ -196,6 +207,7 @@ export const createArtifactEdges = (
|
||||
producer?: ArtifactCommand,
|
||||
): ArtifactEdge[] => {
|
||||
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
|
||||
const byPath = indexOccurrencesByPath(occurrences);
|
||||
const edges: ArtifactEdge[] = [];
|
||||
for (const occurrence of occurrences) {
|
||||
if (
|
||||
@@ -203,11 +215,8 @@ export const createArtifactEdges = (
|
||||
occurrence.artifact_id === null
|
||||
)
|
||||
continue;
|
||||
const mappedSource = occurrence.logical_path.endsWith(".map")
|
||||
? occurrences.find(
|
||||
({ logical_path: path }) =>
|
||||
path === occurrence.logical_path.slice(0, -".map".length),
|
||||
)
|
||||
const mappedSource = occurrence.logical_path.toLowerCase().endsWith(".map")
|
||||
? byPath.get(occurrence.logical_path.slice(0, -".map".length))
|
||||
: undefined;
|
||||
const parentArtifactId =
|
||||
mappedSource?.artifact_id ??
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
createOccurrence,
|
||||
createRootNode,
|
||||
digestCanonical,
|
||||
indexOccurrencesByPath,
|
||||
materializeDirectoryNodes,
|
||||
nearestParent,
|
||||
pageOf,
|
||||
@@ -180,11 +181,10 @@ export const scanCanonicalArtifactInventory = async (
|
||||
const occurrenceById = new Map(
|
||||
occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]),
|
||||
);
|
||||
const occurrenceByPath = indexOccurrencesByPath(occurrences);
|
||||
const integrityContradictions = pendingContradictions.map(
|
||||
(contradiction): IntegrityContradiction => {
|
||||
const occurrence = occurrences.find(
|
||||
({ logical_path: path }) => path === contradiction.logicalPath,
|
||||
);
|
||||
const occurrence = occurrenceByPath.get(contradiction.logicalPath);
|
||||
if (occurrence === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ArtifactIntegrityPolicy,
|
||||
type ArtifactInventorySnapshot,
|
||||
} from "./ArtifactInventory.js";
|
||||
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
||||
|
||||
/** Resolve, authorize, and scan one artifact without a second path resolution. */
|
||||
export const scanAuthorizedArtifactInventory = async (
|
||||
@@ -24,7 +25,7 @@ export const scanAuthorizedArtifactInventory = async (
|
||||
);
|
||||
const [path, approved] = await Promise.all([
|
||||
realpath(inputPath),
|
||||
Promise.all(roots.map(async (root) => realpath(root))),
|
||||
canonicalizeConfiguredRoots(roots),
|
||||
]);
|
||||
if (!approved.some((root) => withinRoot(root, path)))
|
||||
throw new ArtifactReaderFailure(
|
||||
|
||||
@@ -113,7 +113,7 @@ export class BinarySession implements BinarySessionPort {
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
|
||||
if (operation !== undefined) {
|
||||
const exact = this.#capabilities?.get(operation)?.provider;
|
||||
if (exact !== undefined) return exact;
|
||||
if (exact !== undefined) return structuredClone(exact);
|
||||
if (enhancedToolNameSchema.safeParse(operation).success) {
|
||||
const providers = new Map<string, ProviderIdentity>();
|
||||
for (const descriptor of this.#capabilities?.values() ?? [])
|
||||
@@ -124,11 +124,11 @@ export class BinarySession implements BinarySessionPort {
|
||||
providers.set(descriptor.provider.id, descriptor.provider);
|
||||
if (providers.size === 1) {
|
||||
const provider = providers.values().next().value;
|
||||
if (provider !== undefined) return provider;
|
||||
if (provider !== undefined) return structuredClone(provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.#providerIdentity;
|
||||
return structuredClone(this.#providerIdentity);
|
||||
}
|
||||
|
||||
/** Observe runtime provider-health changes that affect discovery metadata. */
|
||||
@@ -223,18 +223,21 @@ export class BinarySession implements BinarySessionPort {
|
||||
workspaceId: string,
|
||||
revision: number,
|
||||
): InvestigationWorkspace | undefined {
|
||||
return this.#investigationWorkspaces.get(
|
||||
const workspace = this.#investigationWorkspaces.get(
|
||||
`${workspaceId}:${String(revision)}`,
|
||||
);
|
||||
return workspace === undefined ? undefined : structuredClone(workspace);
|
||||
}
|
||||
|
||||
/** List retained workspace revisions in canonical identity order. */
|
||||
investigationWorkspaces(): readonly InvestigationWorkspace[] {
|
||||
return [...this.#investigationWorkspaces.values()].sort(
|
||||
(left, right) =>
|
||||
left.workspace_id.localeCompare(right.workspace_id) ||
|
||||
left.revision - right.revision,
|
||||
);
|
||||
return [...this.#investigationWorkspaces.values()]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.workspace_id.localeCompare(right.workspace_id) ||
|
||||
left.revision - right.revision,
|
||||
)
|
||||
.map((workspace) => structuredClone(workspace));
|
||||
}
|
||||
|
||||
/** Create an approved residual unknown and immutable mutation evidence. */
|
||||
@@ -468,7 +471,9 @@ export class BinarySession implements BinarySessionPort {
|
||||
|
||||
/** Return the immutable artifact identity captured before Hopper launched. */
|
||||
activeTarget(): BinaryTarget | undefined {
|
||||
return this.#active?.target;
|
||||
return this.#active === undefined
|
||||
? undefined
|
||||
: structuredClone(this.#active.target);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,9 @@ import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { EvidenceFileError } from "../domain/errors.js";
|
||||
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
||||
|
||||
/** Read bounded JSON data from a regular file beneath an approved root. */
|
||||
export const readBoundedJson = async (
|
||||
@@ -31,8 +33,9 @@ export const readBoundedJson = async (
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "invalid-json", { cause }));
|
||||
}
|
||||
const limits = validateJsonLimits(decoded, policy);
|
||||
return limits.ok ? ok(decoded) : limits;
|
||||
return isJsonWithinLimits(decoded, policy)
|
||||
? ok(decoded)
|
||||
: err(new EvidenceFileError("read", "too-large"));
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "io", { cause }));
|
||||
}
|
||||
@@ -82,8 +85,9 @@ const isApproved = async (
|
||||
candidate: string,
|
||||
roots: readonly string[],
|
||||
): Promise<boolean> => {
|
||||
for (const configuredRoot of roots) {
|
||||
const root = await realpath(resolve(configuredRoot));
|
||||
for (const root of await canonicalizeConfiguredRoots(
|
||||
roots.map((configuredRoot) => resolve(configuredRoot)),
|
||||
)) {
|
||||
const relation = relative(root, candidate);
|
||||
if (
|
||||
relation === "" ||
|
||||
@@ -94,35 +98,6 @@ const isApproved = async (
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateJsonLimits = (
|
||||
root: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Result<null, EvidenceFileError> => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > policy.maxStringLength
|
||||
)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > policy.maxStringLength)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
|
||||
@@ -11,29 +11,73 @@ import type {
|
||||
ListBrowserTargetsInput,
|
||||
WebPageInspection,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type {
|
||||
AnalyzeWebBundleInput,
|
||||
WebBundleAnalysis,
|
||||
} from "../domain/webBundleAnalysis.js";
|
||||
import type {
|
||||
ObserveWebSessionInput,
|
||||
WebObservationSession,
|
||||
} from "../domain/browserSession.js";
|
||||
import type {
|
||||
DiscoverWebMcpToolsInput,
|
||||
WebMcpDiscovery,
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import type {
|
||||
CompareWebCapturesInput,
|
||||
WebCaptureDiff,
|
||||
} from "../domain/webCaptureDiff.js";
|
||||
import type {
|
||||
CaptureWebScreenshotInput,
|
||||
CompareWebScreenshotsInput,
|
||||
WebScreenshot,
|
||||
WebScreenshotDiff,
|
||||
} from "../domain/webScreenshot.js";
|
||||
|
||||
type BrowserEvidenceInput = ListBrowserTargetsInput | InspectWebPageInput;
|
||||
type BrowserEvidenceResult = BrowserTargetList | WebPageInspection;
|
||||
type BrowserEvidenceInput =
|
||||
| ListBrowserTargetsInput
|
||||
| InspectWebPageInput
|
||||
| AnalyzeWebBundleInput
|
||||
| ObserveWebSessionInput
|
||||
| DiscoverWebMcpToolsInput
|
||||
| CompareWebCapturesInput
|
||||
| CaptureWebScreenshotInput
|
||||
| CompareWebScreenshotsInput;
|
||||
type BrowserEvidenceResult =
|
||||
| BrowserTargetList
|
||||
| WebPageInspection
|
||||
| WebBundleAnalysis
|
||||
| WebObservationSession
|
||||
| WebMcpDiscovery
|
||||
| WebCaptureDiff
|
||||
| WebScreenshot
|
||||
| WebScreenshotDiff;
|
||||
type BrowserEvidenceOperation =
|
||||
| "list_browser_targets"
|
||||
| "inspect_web_page"
|
||||
| "analyze_web_bundle"
|
||||
| "observe_web_session"
|
||||
| "discover_webmcp_tools"
|
||||
| "compare_web_captures"
|
||||
| "capture_web_screenshot"
|
||||
| "compare_web_screenshots";
|
||||
|
||||
/** Create Evidence v2 for a policy-scoped external browser observation. */
|
||||
export const createBrowserEvidence = (
|
||||
operation: "list_browser_targets" | "inspect_web_page",
|
||||
operation: BrowserEvidenceOperation,
|
||||
input: BrowserEvidenceInput,
|
||||
result: BrowserEvidenceResult,
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType:
|
||||
operation === "list_browser_targets"
|
||||
? "rea.browser-target-list/v1"
|
||||
: "rea.web-page-inspection/v1",
|
||||
predicateType: browserPredicate(operation),
|
||||
operation,
|
||||
parameters: browserParameters(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
environment:
|
||||
"target" in result
|
||||
"browser" in result
|
||||
? {
|
||||
id: `${result.browser.product}@${result.browser.revision}`,
|
||||
platform: process.platform,
|
||||
@@ -46,16 +90,92 @@ export const createBrowserEvidence = (
|
||||
|
||||
const browserParameters = (
|
||||
input: BrowserEvidenceInput,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
cdp_endpoint: input.cdp_endpoint,
|
||||
allowed_origins: input.allowed_origins,
|
||||
...("target_id" in input
|
||||
? {
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
include_script_sources: input.include_script_sources,
|
||||
include_storage_keys: input.include_storage_keys,
|
||||
limits: input.limits,
|
||||
}
|
||||
: { offset: input.offset, limit: input.limit }),
|
||||
});
|
||||
): EvidenceObservation["parameters"] => {
|
||||
if (!("cdp_endpoint" in input)) {
|
||||
if ("max_changes" in input)
|
||||
return {
|
||||
before_target_id: input.before.inspection.target.target_id,
|
||||
before_capture_ended_at:
|
||||
input.before.inspection.capture_window.ended_at,
|
||||
after_target_id: input.after.inspection.target.target_id,
|
||||
after_capture_ended_at: input.after.inspection.capture_window.ended_at,
|
||||
max_changes: input.max_changes,
|
||||
};
|
||||
return {
|
||||
before_artifact_uri: input.before.uri,
|
||||
after_artifact_uri: input.after.uri,
|
||||
channel_threshold: input.channel_threshold,
|
||||
maximum_pixels: input.maximum_pixels,
|
||||
};
|
||||
}
|
||||
const scope = {
|
||||
cdp_endpoint: input.cdp_endpoint,
|
||||
allowed_origins: input.allowed_origins,
|
||||
};
|
||||
if (!("target_id" in input))
|
||||
return { ...scope, offset: input.offset, limit: input.limit };
|
||||
if ("screenshot_approved" in input)
|
||||
return {
|
||||
...scope,
|
||||
target_id: input.target_id,
|
||||
maximum_image_bytes: input.maximum_image_bytes,
|
||||
};
|
||||
if ("max_tools" in input)
|
||||
return {
|
||||
...scope,
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
max_tools: input.max_tools,
|
||||
max_schema_bytes: input.max_schema_bytes,
|
||||
max_schema_nodes: input.max_schema_nodes,
|
||||
max_schema_depth: input.max_schema_depth,
|
||||
};
|
||||
if (!("include_accessibility_text" in input))
|
||||
return {
|
||||
...scope,
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
max_timeline_events: input.max_timeline_events,
|
||||
};
|
||||
return {
|
||||
...scope,
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
include_accessibility_text: input.include_accessibility_text,
|
||||
include_console_text: input.include_console_text,
|
||||
include_json_body_shapes: input.include_json_body_shapes,
|
||||
include_websocket_shapes: input.include_websocket_shapes,
|
||||
include_script_sources: input.include_script_sources,
|
||||
include_storage_keys: input.include_storage_keys,
|
||||
limits: input.limits,
|
||||
...(input.include_script_sources && "source_capture_approved" in input
|
||||
? {
|
||||
source_capture_approved: input.source_capture_approved,
|
||||
fetch_source_maps: input.fetch_source_maps,
|
||||
source_map_fetch_approved: input.source_map_fetch_approved,
|
||||
analysis_limits: input.analysis_limits,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const browserPredicate = (operation: BrowserEvidenceOperation): string => {
|
||||
switch (operation) {
|
||||
case "list_browser_targets":
|
||||
return "rea.browser-target-list/v1";
|
||||
case "inspect_web_page":
|
||||
return "rea.web-page-inspection/v2";
|
||||
case "analyze_web_bundle":
|
||||
return "rea.web-bundle-analysis/v1";
|
||||
case "observe_web_session":
|
||||
return "rea.web-observation-session/v1";
|
||||
case "discover_webmcp_tools":
|
||||
return "rea.webmcp-discovery/v1";
|
||||
case "compare_web_captures":
|
||||
return "rea.web-capture-diff/v1";
|
||||
case "capture_web_screenshot":
|
||||
return "rea.web-screenshot/v1";
|
||||
case "compare_web_screenshots":
|
||||
return "rea.web-screenshot-diff/v1";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,28 @@ import type {
|
||||
WebPageInspection,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
AnalyzeWebBundleInput,
|
||||
WebBundleAnalysis,
|
||||
} from "../domain/webBundleAnalysis.js";
|
||||
import type {
|
||||
ObserveWebSessionInput,
|
||||
WebObservationSession,
|
||||
} from "../domain/browserSession.js";
|
||||
import type {
|
||||
DiscoverWebMcpToolsInput,
|
||||
WebMcpDiscovery,
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import type {
|
||||
CompareWebCapturesInput,
|
||||
WebCaptureDiff,
|
||||
} from "../domain/webCaptureDiff.js";
|
||||
import type {
|
||||
CaptureWebScreenshotInput,
|
||||
CompareWebScreenshotsInput,
|
||||
WebScreenshot,
|
||||
WebScreenshotDiff,
|
||||
} from "../domain/webScreenshot.js";
|
||||
import type { ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
|
||||
@@ -20,4 +42,26 @@ export interface BrowserObservationPort {
|
||||
input: InspectWebPageInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebPageInspection, AnalysisError>>;
|
||||
analyzeBundle(
|
||||
input: AnalyzeWebBundleInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebBundleAnalysis, AnalysisError>>;
|
||||
observeSession(
|
||||
input: ObserveWebSessionInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebObservationSession, AnalysisError>>;
|
||||
discoverWebMcpTools(
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebMcpDiscovery, AnalysisError>>;
|
||||
compareCaptures(
|
||||
input: CompareWebCapturesInput,
|
||||
): Promise<Result<WebCaptureDiff, AnalysisError>>;
|
||||
captureScreenshot(
|
||||
input: CaptureWebScreenshotInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebScreenshot, AnalysisError>>;
|
||||
compareScreenshots(
|
||||
input: CompareWebScreenshotsInput,
|
||||
): Promise<Result<WebScreenshotDiff, AnalysisError>>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@ import type {
|
||||
InspectWebPageInput,
|
||||
ListBrowserTargetsInput,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js";
|
||||
import type { ObserveWebSessionInput } from "../domain/browserSession.js";
|
||||
import type { DiscoverWebMcpToolsInput } from "../domain/webMcpDiscovery.js";
|
||||
import type { CompareWebCapturesInput } from "../domain/webCaptureDiff.js";
|
||||
import type {
|
||||
CaptureWebScreenshotInput,
|
||||
CompareWebScreenshotsInput,
|
||||
} from "../domain/webScreenshot.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisProtocolError,
|
||||
@@ -69,11 +77,154 @@ export const inspectWebPage = async (
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Capture approved sources and derive bounded static web-bundle evidence. */
|
||||
export const analyzeWebBundle = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: AnalyzeWebBundleInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(browser, authority, input, "analyze_web_bundle");
|
||||
if (!ready.ok) return ready;
|
||||
const analyzed = await ready.value.analyzeBundle(input, options);
|
||||
if (!analyzed.ok) return analyzed;
|
||||
return ok(
|
||||
createBrowserEvidence(
|
||||
"analyze_web_bundle",
|
||||
input,
|
||||
analyzed.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** Observe navigation caused by external user actions during one armed window. */
|
||||
export const observeWebSession = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ObserveWebSessionInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(browser, authority, input, "observe_web_session");
|
||||
if (!ready.ok) return ready;
|
||||
const observed = await ready.value.observeSession(input, options);
|
||||
return observed.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"observe_web_session",
|
||||
input,
|
||||
observed.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: observed;
|
||||
};
|
||||
|
||||
/** Discover WebMCP declarations without exposing an invocation surface. */
|
||||
export const discoverWebMcpTools = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
browser,
|
||||
authority,
|
||||
input,
|
||||
"discover_webmcp_tools",
|
||||
);
|
||||
if (!ready.ok) return ready;
|
||||
const discovered = await ready.value.discoverWebMcpTools(input, options);
|
||||
return discovered.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"discover_webmcp_tools",
|
||||
input,
|
||||
discovered.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: discovered;
|
||||
};
|
||||
|
||||
/** Compare two already-normalized web captures without external access. */
|
||||
export const compareWebCaptureEvidence = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
input: CompareWebCapturesInput,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "compare_web_captures");
|
||||
if (!ready.ok) return ready;
|
||||
const compared = await ready.value.compareCaptures(input);
|
||||
return compared.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"compare_web_captures",
|
||||
input,
|
||||
compared.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: compared;
|
||||
};
|
||||
|
||||
/** Capture one explicitly approved visible-viewport screenshot. */
|
||||
export const captureWebScreenshot = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: CaptureWebScreenshotInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
browser,
|
||||
authority,
|
||||
input,
|
||||
"capture_web_screenshot",
|
||||
);
|
||||
if (!ready.ok) return ready;
|
||||
const captured = await ready.value.captureScreenshot(input, options);
|
||||
return captured.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"capture_web_screenshot",
|
||||
input,
|
||||
captured.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: captured;
|
||||
};
|
||||
|
||||
/** Compare two self-verifying PNG artifacts without external access. */
|
||||
export const compareWebScreenshotEvidence = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
input: CompareWebScreenshotsInput,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "compare_web_screenshots");
|
||||
if (!ready.ok) return ready;
|
||||
const compared = await ready.value.compareScreenshots(input);
|
||||
return compared.ok
|
||||
? ok(
|
||||
createBrowserEvidence(
|
||||
"compare_web_screenshots",
|
||||
input,
|
||||
compared.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: compared;
|
||||
};
|
||||
|
||||
const prepare = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ScopedBrowserInput,
|
||||
operation: "list_browser_targets" | "inspect_web_page",
|
||||
operation:
|
||||
| "list_browser_targets"
|
||||
| "inspect_web_page"
|
||||
| "analyze_web_bundle"
|
||||
| "observe_web_session"
|
||||
| "discover_webmcp_tools"
|
||||
| "capture_web_screenshot",
|
||||
): Promise<Result<BrowserObservationPort, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
@@ -115,6 +266,20 @@ const prepare = async (
|
||||
: ok(browser);
|
||||
};
|
||||
|
||||
const requireBrowser = (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
operation: string,
|
||||
): Result<BrowserObservationPort, AnalysisError> =>
|
||||
browser === undefined
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-cdp-browser",
|
||||
operation,
|
||||
"browser comparison provider is not configured",
|
||||
),
|
||||
)
|
||||
: ok(browser);
|
||||
|
||||
const browserNetworkScope = (
|
||||
allowedOrigins: readonly string[],
|
||||
): "loopback" | "external" =>
|
||||
|
||||
@@ -55,6 +55,7 @@ export const buildCapabilityInventory = (
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly evidenceFileRoots: number;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
readonly electronObservationEnabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
const status = statusSchema.parse(sessionStatus);
|
||||
@@ -99,6 +100,7 @@ const availabilityFor = (
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly evidenceFileRoots: number;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
readonly electronObservationEnabled?: boolean;
|
||||
},
|
||||
): {
|
||||
readonly reason: ToolAvailabilityReason;
|
||||
@@ -123,6 +125,14 @@ const availabilityFor = (
|
||||
remediation:
|
||||
"Enable browser observation and configure exact CDP endpoint and page origins.",
|
||||
};
|
||||
if (kind === "electron-provider")
|
||||
return policy.electronObservationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
remediation:
|
||||
"Enable Electron observation and configure a loopback CDP endpoint and canonical file roots.",
|
||||
};
|
||||
if (kind === "session") return { reason: "available", remediation: null };
|
||||
if (!targetOpen)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
|
||||
/** Canonicalize configured policy roots while ignoring stale missing entries. */
|
||||
export const canonicalizeConfiguredRoots = async (
|
||||
roots: readonly string[],
|
||||
): Promise<string[]> => {
|
||||
const canonical: string[] = [];
|
||||
for (const root of roots) {
|
||||
try {
|
||||
canonical.push(await realpath(root));
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissingPath(cause)) throw cause;
|
||||
}
|
||||
}
|
||||
return canonical;
|
||||
};
|
||||
|
||||
const isMissingPath = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT";
|
||||
@@ -34,6 +34,43 @@ export interface InventoryEvidencePages {
|
||||
readonly right: readonly Evidence[];
|
||||
}
|
||||
|
||||
/** Injectable artifact scan boundary used by paired version inventory. */
|
||||
export type VersionInventoryScanner = typeof scanAuthorizedArtifactInventory;
|
||||
|
||||
/** Inputs shared by the two scans in one version comparison. */
|
||||
export interface VersionInventoryScanOptions {
|
||||
readonly leftPath: string;
|
||||
readonly rightPath: string;
|
||||
readonly inputRoots: readonly string[];
|
||||
readonly limits: ArtifactLimits;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly integrity: ArtifactIntegrityPolicy;
|
||||
}
|
||||
|
||||
/** Start both independent version scans while preserving left/right identity. */
|
||||
export const scanVersionInventories = async (
|
||||
options: VersionInventoryScanOptions,
|
||||
scanner: VersionInventoryScanner = scanAuthorizedArtifactInventory,
|
||||
): Promise<VersionSnapshots> => {
|
||||
const [left, right] = await Promise.all([
|
||||
scanner(
|
||||
options.leftPath,
|
||||
options.inputRoots,
|
||||
options.limits,
|
||||
options.signal,
|
||||
options.integrity,
|
||||
),
|
||||
scanner(
|
||||
options.rightPath,
|
||||
options.inputRoots,
|
||||
options.limits,
|
||||
options.signal,
|
||||
options.integrity,
|
||||
),
|
||||
]);
|
||||
return { left, right };
|
||||
};
|
||||
|
||||
/** Authorize and scan both inputs once under the same bounded traversal policy. */
|
||||
export const scanVersions = async (
|
||||
input: CrossVersionInvestigationInput,
|
||||
@@ -51,21 +88,16 @@ export const scanVersions = async (
|
||||
enabled: integrityContinueEnabled,
|
||||
maxMismatches: input.max_integrity_mismatches,
|
||||
};
|
||||
const left = await scanAuthorizedArtifactInventory(
|
||||
input.left_path,
|
||||
inputRoots,
|
||||
limits,
|
||||
signal,
|
||||
integrity,
|
||||
return ok(
|
||||
await scanVersionInventories({
|
||||
leftPath: input.left_path,
|
||||
rightPath: input.right_path,
|
||||
inputRoots,
|
||||
limits,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
integrity,
|
||||
}),
|
||||
);
|
||||
const right = await scanAuthorizedArtifactInventory(
|
||||
input.right_path,
|
||||
inputRoots,
|
||||
limits,
|
||||
signal,
|
||||
integrity,
|
||||
);
|
||||
return ok({ left, right });
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return err(
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface CrossVersionInvestigationExecution {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
readonly integrityContinueEnabled?: boolean;
|
||||
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** Run or resume a deterministic cross-version artifact investigation. */
|
||||
@@ -111,6 +112,7 @@ export const runCrossVersionInvestigation = async (
|
||||
session: execution.session,
|
||||
signal: execution.signal,
|
||||
progress: execution.progress,
|
||||
authorizeWorkspaceWrite: execution.authorizeWorkspaceWrite,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -127,6 +129,9 @@ const continueInvestigation = async (context: {
|
||||
readonly session: BinarySessionPort | undefined;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly progress: ProgressReporter | undefined;
|
||||
readonly authorizeWorkspaceWrite:
|
||||
| (() => Promise<Result<null, AnalysisError>>)
|
||||
| undefined;
|
||||
}): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
const left = targetFor(context.snapshots.left);
|
||||
const right = targetFor(context.snapshots.right);
|
||||
@@ -155,6 +160,10 @@ const continueInvestigation = async (context: {
|
||||
);
|
||||
if (context.initial !== null && existing?.status === "complete")
|
||||
return completeOutcome(context.initial, existing, true, context.session);
|
||||
if (context.authorizeWorkspaceWrite !== undefined) {
|
||||
const authorized = await context.authorizeWorkspaceWrite();
|
||||
if (!authorized.ok) return authorized;
|
||||
}
|
||||
const pages = createInventoryEvidencePages(context.input, context.snapshots);
|
||||
if (!pages.ok) return pages;
|
||||
if (isCancelled(context.signal))
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { PermissionCapability } from "../domain/permissionPolicy.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
PermissionPathError,
|
||||
type PermissionAuthority,
|
||||
} from "./PermissionAuthority.js";
|
||||
|
||||
interface RootPermissionRequest {
|
||||
readonly capability: PermissionCapability;
|
||||
readonly roots: readonly string[];
|
||||
readonly access: "read" | "write";
|
||||
readonly operation: string;
|
||||
}
|
||||
|
||||
/** Authorize one root-scoped operation and project policy path failures. */
|
||||
export const authorizeRootPermission = async (
|
||||
authority: PermissionAuthority,
|
||||
request: RootPermissionRequest,
|
||||
): Promise<Result<null, AnalysisError>> => {
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: request.capability,
|
||||
roots: request.roots,
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: `${request.operation}:${request.capability}:${request.roots.join(":")}`,
|
||||
},
|
||||
request.access,
|
||||
);
|
||||
return authorized.ok
|
||||
? ok(null)
|
||||
: err(
|
||||
authorized.error instanceof PermissionRequiredError
|
||||
? authorized.error
|
||||
: new AnalysisProtocolError(authorized.error.message, {
|
||||
cause: authorized.error,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export interface DeferredFileWriteAuthorization {
|
||||
readonly authorizeWrite: () => Promise<Result<null, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** Authorize an existing file read and defer write authority until requested. */
|
||||
export const authorizeFileReadWithDeferredWrite = async (
|
||||
authority: PermissionAuthority,
|
||||
input: {
|
||||
readonly path: string;
|
||||
readonly readCapability: PermissionCapability;
|
||||
readonly writeCapability: PermissionCapability;
|
||||
readonly operation: string;
|
||||
},
|
||||
): Promise<Result<DeferredFileWriteAuthorization, AnalysisError>> => {
|
||||
const read = await authority.authorize(
|
||||
{
|
||||
capability: input.readCapability,
|
||||
roots: [input.path],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: `${input.operation}:${input.readCapability}:${input.path}`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
let writeAuthorization: Promise<Result<null, AnalysisError>> | undefined;
|
||||
if (!read.ok) {
|
||||
if (
|
||||
!(read.error instanceof PermissionPathError) ||
|
||||
read.error.reason !== "not_found"
|
||||
)
|
||||
return err(
|
||||
read.error instanceof PermissionRequiredError
|
||||
? read.error
|
||||
: new AnalysisProtocolError(read.error.message, {
|
||||
cause: read.error,
|
||||
}),
|
||||
);
|
||||
writeAuthorization = authorizeRootPermission(authority, {
|
||||
capability: input.writeCapability,
|
||||
roots: [input.path],
|
||||
access: "write",
|
||||
operation: input.operation,
|
||||
});
|
||||
const authorized = await writeAuthorization;
|
||||
if (!authorized.ok) return authorized;
|
||||
}
|
||||
return ok({
|
||||
authorizeWrite: () =>
|
||||
(writeAuthorization ??= authorizeRootPermission(authority, {
|
||||
capability: input.writeCapability,
|
||||
roots: [input.path],
|
||||
access: "write",
|
||||
operation: input.operation,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseConfig } from "../config.js";
|
||||
import { parseConfig, type AppConfig } from "../config.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { createServerIdentity } from "../serverIdentity.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
@@ -30,6 +30,10 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import {
|
||||
authorizeFileReadWithDeferredWrite,
|
||||
type DeferredFileWriteAuthorization,
|
||||
} from "./DeferredFileAuthorization.js";
|
||||
|
||||
const WORKFLOW_PROVIDER = {
|
||||
id: "rea-workflow",
|
||||
@@ -59,6 +63,7 @@ export const runDirectAnalysis = async (
|
||||
readonly logger?: Logger;
|
||||
readonly snapshotPath?: string | undefined;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly permissionAuthority?: PermissionAuthority;
|
||||
} = {},
|
||||
): Promise<JsonValue> =>
|
||||
withProcessCancellation(options.signal, (signal) =>
|
||||
@@ -66,6 +71,9 @@ export const runDirectAnalysis = async (
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
signal,
|
||||
...(options.permissionAuthority === undefined
|
||||
? {}
|
||||
: { permissionAuthority: options.permissionAuthority }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -108,23 +116,8 @@ const authorizeAnalysis = async (
|
||||
authority: PermissionAuthority,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
snapshotPath: string | undefined,
|
||||
): Promise<Result<null, AnalysisError>> => {
|
||||
const requests = [];
|
||||
if (snapshotPath !== undefined) {
|
||||
requests.push(
|
||||
{
|
||||
capability: "snapshot_read" as const,
|
||||
path: snapshotPath,
|
||||
access: "write" as const,
|
||||
},
|
||||
{
|
||||
capability: "snapshot_write" as const,
|
||||
path: snapshotPath,
|
||||
access: "write" as const,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (tool === "extract_artifact" && typeof arguments_.output_root === "string")
|
||||
requests.push({
|
||||
capability: "artifact_extract" as const,
|
||||
@@ -181,6 +174,62 @@ const authorizeAnalysis = async (
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const permissionAuthorityFor = (
|
||||
config: AppConfig,
|
||||
supplied: PermissionAuthority | undefined,
|
||||
): ReturnType<typeof loadConfiguredPermissionAuthority> =>
|
||||
supplied === undefined
|
||||
? loadConfiguredPermissionAuthority(config)
|
||||
: Promise.resolve(ok(supplied));
|
||||
|
||||
const authorizeSnapshotAccess = async (
|
||||
authority: PermissionAuthority,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
snapshotPath: string | undefined,
|
||||
): Promise<
|
||||
Result<DeferredFileWriteAuthorization | undefined, AnalysisError>
|
||||
> =>
|
||||
snapshotPath === undefined
|
||||
? ok(undefined)
|
||||
: authorizeFileReadWithDeferredWrite(authority, {
|
||||
path: snapshotPath,
|
||||
readCapability: "snapshot_read",
|
||||
writeCapability: "snapshot_write",
|
||||
operation: tool,
|
||||
});
|
||||
|
||||
const authorizeDeferredWrite = (
|
||||
authorization: DeferredFileWriteAuthorization | undefined,
|
||||
): Promise<Result<null, AnalysisError>> =>
|
||||
authorization?.authorizeWrite() ?? Promise.resolve(ok(null));
|
||||
|
||||
const authorizeAnalysisRun = async (input: {
|
||||
readonly config: AppConfig;
|
||||
readonly suppliedAuthority: PermissionAuthority | undefined;
|
||||
readonly tool: NativeToolName | ArtifactToolName | DirectAnalysisTool;
|
||||
readonly arguments: Readonly<Record<string, JsonValue>>;
|
||||
readonly snapshotPath: string | undefined;
|
||||
}): Promise<
|
||||
Result<DeferredFileWriteAuthorization | undefined, AnalysisError>
|
||||
> => {
|
||||
const authority = await permissionAuthorityFor(
|
||||
input.config,
|
||||
input.suppliedAuthority,
|
||||
);
|
||||
if (!authority.ok) return authority;
|
||||
const operation = await authorizeAnalysis(
|
||||
authority.value,
|
||||
input.tool,
|
||||
input.arguments,
|
||||
);
|
||||
if (!operation.ok) return operation;
|
||||
return authorizeSnapshotAccess(
|
||||
authority.value,
|
||||
input.tool,
|
||||
input.snapshotPath,
|
||||
);
|
||||
};
|
||||
|
||||
const runAnalysis = async (
|
||||
path: string,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
@@ -189,22 +238,20 @@ const runAnalysis = async (
|
||||
readonly logger: Logger;
|
||||
readonly snapshotPath: string | undefined;
|
||||
readonly signal: AbortSignal;
|
||||
readonly permissionAuthority?: PermissionAuthority;
|
||||
},
|
||||
): Promise<JsonValue> => {
|
||||
const { logger, signal, snapshotPath } = options;
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const permissionAuthority = await loadConfiguredPermissionAuthority(
|
||||
config.value,
|
||||
);
|
||||
if (!permissionAuthority.ok) return cliError(permissionAuthority.error);
|
||||
const authorized = await authorizeAnalysis(
|
||||
permissionAuthority.value,
|
||||
const authorization = await authorizeAnalysisRun({
|
||||
config: config.value,
|
||||
suppliedAuthority: options.permissionAuthority,
|
||||
tool,
|
||||
arguments_,
|
||||
arguments: arguments_,
|
||||
snapshotPath,
|
||||
);
|
||||
if (!authorized.ok) return cliError(authorized.error);
|
||||
});
|
||||
if (!authorization.ok) return cliError(authorization.error);
|
||||
const unavailable = snapshotUnavailable(
|
||||
snapshotPath,
|
||||
config.value.hopperLoaderArgs,
|
||||
@@ -222,6 +269,8 @@ const runAnalysis = async (
|
||||
});
|
||||
if (!prepared.ok) return cliError(prepared.error);
|
||||
if (prepared.value.evidence !== undefined) return prepared.value.evidence;
|
||||
const writable = await authorizeDeferredWrite(authorization.value);
|
||||
if (!writable.ok) return cliError(writable.error);
|
||||
const { snapshot } = prepared.value;
|
||||
const opened = await session.open(
|
||||
path,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import {
|
||||
createEvidence,
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
} from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
ElectronPageInspection,
|
||||
ElectronTargetList,
|
||||
InspectElectronPageInput,
|
||||
ListElectronTargetsInput,
|
||||
} from "../domain/electronObservation.js";
|
||||
|
||||
type ElectronOperation = "list_electron_targets" | "inspect_electron_page";
|
||||
|
||||
/** Create Evidence v2 for one root-confined Electron observation. */
|
||||
export const createElectronEvidence = (
|
||||
operation: ElectronOperation,
|
||||
input: ListElectronTargetsInput | InspectElectronPageInput,
|
||||
result: ElectronTargetList | ElectronPageInspection,
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType:
|
||||
operation === "list_electron_targets"
|
||||
? "rea.electron-target-list/v1"
|
||||
: "rea.electron-page-inspection/v1",
|
||||
operation,
|
||||
parameters: parameters(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
environment:
|
||||
"target" in result
|
||||
? {
|
||||
id: `${result.browser.product}@${result.browser.revision}`,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
isolation: "none",
|
||||
}
|
||||
: null,
|
||||
limitations: result.limitations,
|
||||
});
|
||||
|
||||
const parameters = (
|
||||
input: ListElectronTargetsInput | InspectElectronPageInput,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
cdp_endpoint: input.cdp_endpoint,
|
||||
allowed_file_roots: input.allowed_file_roots,
|
||||
...("target_id" in input
|
||||
? {
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
include_script_sources: input.include_script_sources,
|
||||
source_capture_approved: input.source_capture_approved,
|
||||
limits: input.limits,
|
||||
}
|
||||
: { offset: input.offset, limit: input.limit }),
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/errors.js";
|
||||
import type {
|
||||
ElectronPageInspection,
|
||||
ElectronTargetList,
|
||||
InspectElectronPageInput,
|
||||
ListElectronTargetsInput,
|
||||
} from "../domain/electronObservation.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Provider-neutral boundary for root-confined Electron file-page observation. */
|
||||
export interface ElectronObservationPort {
|
||||
identity(): ProviderIdentity;
|
||||
listTargets(
|
||||
input: ListElectronTargetsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<ElectronTargetList, AnalysisError>>;
|
||||
inspectPage(
|
||||
input: InspectElectronPageInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<ElectronPageInspection, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type {
|
||||
InspectElectronPageInput,
|
||||
ListElectronTargetsInput,
|
||||
} from "../domain/electronObservation.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { createElectronEvidence } from "./ElectronEvidence.js";
|
||||
|
||||
/** Authorize and list root-confined Electron page targets. */
|
||||
export const listElectronTargets = async (
|
||||
provider: ElectronObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListElectronTargetsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
provider,
|
||||
authority,
|
||||
input,
|
||||
"list_electron_targets",
|
||||
);
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
return result.ok
|
||||
? ok(
|
||||
createElectronEvidence(
|
||||
"list_electron_targets",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Authorize and inspect one root-confined Electron page. */
|
||||
export const inspectElectronPage = async (
|
||||
provider: ElectronObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: InspectElectronPageInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
provider,
|
||||
authority,
|
||||
input,
|
||||
"inspect_electron_page",
|
||||
);
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.inspectPage(input, options);
|
||||
return result.ok
|
||||
? ok(
|
||||
createElectronEvidence(
|
||||
"inspect_electron_page",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
|
||||
const prepare = async (
|
||||
provider: ElectronObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListElectronTargetsInput | InspectElectronPageInput,
|
||||
operation: "list_electron_targets" | "inspect_electron_page",
|
||||
): Promise<Result<ElectronObservationPort, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-cdp-electron",
|
||||
operation,
|
||||
"Electron observation permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "electron_observe",
|
||||
roots: input.allowed_file_roots,
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
origins: [input.cdp_endpoint],
|
||||
network: "loopback",
|
||||
mount: false,
|
||||
operation_identity: `${operation}:${"target_id" in input ? input.target_id : input.cdp_endpoint}`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
if (!authorized.ok)
|
||||
return err(
|
||||
authorized.error instanceof PermissionRequiredError
|
||||
? authorized.error
|
||||
: new AnalysisProtocolError(authorized.error.message, {
|
||||
cause: authorized.error,
|
||||
}),
|
||||
);
|
||||
return provider === undefined
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-cdp-electron",
|
||||
operation,
|
||||
"Electron observation provider is not configured",
|
||||
),
|
||||
)
|
||||
: ok(provider);
|
||||
};
|
||||
@@ -122,9 +122,11 @@ export class EvidenceLedger {
|
||||
|
||||
/** Export records in deterministic, semantically irrelevant ID order. */
|
||||
export(): EvidenceBundle {
|
||||
return createEvidenceBundle(
|
||||
[...this.#records.values()],
|
||||
[...this.#unknownRevisions.values()],
|
||||
return structuredClone(
|
||||
createEvidenceBundle(
|
||||
[...this.#records.values()],
|
||||
[...this.#unknownRevisions.values()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,7 +234,7 @@ export class EvidenceLedger {
|
||||
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
|
||||
if (!checked.ok) return checked;
|
||||
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
||||
return ok(unknown);
|
||||
return ok(structuredClone(unknown));
|
||||
}
|
||||
|
||||
/** Apply one approved full-state update using optimistic revision matching. */
|
||||
@@ -277,7 +279,8 @@ export class EvidenceLedger {
|
||||
unknown.severity === filters.severity) &&
|
||||
(filters.domain === undefined || unknown.domain === filters.domain),
|
||||
)
|
||||
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id));
|
||||
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
|
||||
.map((unknown) => structuredClone(unknown));
|
||||
}
|
||||
|
||||
/** Return current resolution validity; imported invalid states are rejected. */
|
||||
@@ -295,7 +298,7 @@ export class EvidenceLedger {
|
||||
return ok({
|
||||
valid: unknown.status === "resolved",
|
||||
truthVerified: unknown.resolution?.disposition === "verified",
|
||||
unknown,
|
||||
unknown: structuredClone(unknown),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -331,7 +334,7 @@ export class EvidenceLedger {
|
||||
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
|
||||
if (!checked.ok) return checked;
|
||||
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
||||
return ok(unknown);
|
||||
return ok(structuredClone(unknown));
|
||||
}
|
||||
|
||||
#validateCandidate(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
|
||||
import { InvestigationWorkspaceError } from "../domain/errors.js";
|
||||
import {
|
||||
parseInvestigationWorkspace,
|
||||
@@ -132,7 +133,7 @@ const readWorkspaceFile = async (
|
||||
new InvestigationWorkspaceError("read", "invalid-json", { cause }),
|
||||
);
|
||||
}
|
||||
if (!withinJsonLimits(decoded, policy))
|
||||
if (!isJsonWithinLimits(decoded, policy))
|
||||
return err(new InvestigationWorkspaceError("read", "too-large"));
|
||||
try {
|
||||
return ok(parseInvestigationWorkspace(decoded));
|
||||
@@ -241,34 +242,6 @@ const validateNextRevision = (
|
||||
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
||||
};
|
||||
|
||||
const withinJsonLimits = (
|
||||
root: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
): boolean => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
||||
return false;
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > policy.maxStringLength
|
||||
)
|
||||
return false;
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > policy.maxStringLength) return false;
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
errorCode(cause) === "ENOENT";
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
||||
|
||||
const isWithin = (candidate: string, root: string): boolean =>
|
||||
candidate === root ||
|
||||
@@ -18,17 +19,15 @@ export const assertRealPathAuthority = async (
|
||||
policy: ProcessExecutionPolicy,
|
||||
): Promise<void> => {
|
||||
const executable = await realpath(scenario.executable);
|
||||
const executableRoots = await Promise.all(
|
||||
policy.executableRoots.map((root) => realpath(root)),
|
||||
const executableRoots = await canonicalizeConfiguredRoots(
|
||||
policy.executableRoots,
|
||||
);
|
||||
if (!executableRoots.some((root) => isWithin(executable, root)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved executable is outside approved roots",
|
||||
);
|
||||
const workingDirectory = await realpath(scenario.working_directory);
|
||||
const workingRoots = await Promise.all(
|
||||
policy.workingRoots.map((root) => realpath(root)),
|
||||
);
|
||||
const workingRoots = await canonicalizeConfiguredRoots(policy.workingRoots);
|
||||
if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved working directory is outside approved roots",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { IPty } from "node-pty";
|
||||
@@ -285,7 +285,7 @@ export const releaseProcessResources = async (options: {
|
||||
readonly checkpoints: ProcessCheckpoints | undefined;
|
||||
readonly runId: string;
|
||||
readonly temporaryRoot: string;
|
||||
readonly sampledProcessGroupIds: readonly number[];
|
||||
readonly capturedProcessGroupIds: readonly number[];
|
||||
}): Promise<string | undefined> => {
|
||||
for (const timer of options.timers) clearTimeout(timer);
|
||||
let failure: string | undefined;
|
||||
@@ -310,11 +310,7 @@ export const releaseProcessResources = async (options: {
|
||||
failure ??= "filesystem checkpoint cleanup failed";
|
||||
}
|
||||
if (options.terminal !== undefined && process.platform !== "win32") {
|
||||
const processGroupIds = new Set([
|
||||
options.terminal.pid,
|
||||
...options.sampledProcessGroupIds,
|
||||
]);
|
||||
for (const processGroupId of processGroupIds) {
|
||||
for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
|
||||
const cleaned = await cleanupOwnedProcessGroup({
|
||||
runId: options.runId,
|
||||
leaderPid: processGroupId,
|
||||
@@ -338,14 +334,12 @@ export const captureTerminalFrames = (options: {
|
||||
readonly started: number;
|
||||
readonly temporaryRoot: string;
|
||||
readonly onOutput: () => void;
|
||||
readonly onFirstOutput: () => void;
|
||||
readonly renderer: TerminalRenderer;
|
||||
readonly checkpoints: ProcessCheckpoints;
|
||||
}): (() => boolean) => {
|
||||
let outputBytes = 0;
|
||||
let truncated = false;
|
||||
options.terminal.onData((data) => {
|
||||
options.onFirstOutput();
|
||||
options.onOutput();
|
||||
const bytes = Buffer.byteLength(data);
|
||||
if (
|
||||
@@ -401,6 +395,7 @@ export const prepareProcessCapture = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
signal: AbortSignal | undefined,
|
||||
host: ProcessPreparationHost = systemProcessPreparationHost,
|
||||
): Promise<{
|
||||
readonly temporaryRoot: string;
|
||||
readonly runId: string;
|
||||
@@ -416,11 +411,29 @@ export const prepareProcessCapture = async (
|
||||
await assertRealPathAuthority(scenario, policy);
|
||||
assertNotCancelled(signal);
|
||||
const before = await snapshotRoots(scenario, signal);
|
||||
const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
|
||||
const runId = randomUUID();
|
||||
const home = join(temporaryRoot, "home");
|
||||
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
|
||||
return { temporaryRoot, runId, home, before };
|
||||
const temporaryRoot = await host.createTemporaryRoot();
|
||||
try {
|
||||
const runId = randomUUID();
|
||||
const home = join(temporaryRoot, "home");
|
||||
await host.createHome(home);
|
||||
return { temporaryRoot, runId, home, before };
|
||||
} catch (cause: unknown) {
|
||||
await host.cleanup(temporaryRoot);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** Filesystem seam for allocating and cleaning a capture's private home. */
|
||||
export interface ProcessPreparationHost {
|
||||
createTemporaryRoot(): Promise<string>;
|
||||
createHome(path: string): Promise<void>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
const systemProcessPreparationHost: ProcessPreparationHost = {
|
||||
createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
|
||||
createHome: (path) => mkdir(path),
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
};
|
||||
|
||||
const processPolicyMessage = (
|
||||
|
||||
@@ -126,7 +126,7 @@ export class ProcessCheckpoints {
|
||||
effects: classifyFilesystemEffects(previous, after.files),
|
||||
truncated: after.truncated,
|
||||
});
|
||||
return this.#captures;
|
||||
return structuredClone(this.#captures);
|
||||
}
|
||||
|
||||
/** Cancel scheduled captures and await any snapshot already in progress. */
|
||||
|
||||
@@ -112,7 +112,12 @@ export const compareProcessEvidenceFiles = async (
|
||||
};
|
||||
|
||||
const parseCaptureEvidence = (input: unknown) => {
|
||||
const evidence = parseEvidence(input);
|
||||
let evidence;
|
||||
try {
|
||||
evidence = parseEvidence(input);
|
||||
} catch {
|
||||
throw invalidCaptureEvidence();
|
||||
}
|
||||
if (
|
||||
evidence.operation !== "capture_process_scenario" ||
|
||||
evidence.predicate_type !== "rea.process-capture/v4" ||
|
||||
@@ -129,12 +134,22 @@ const parseCaptureEvidence = (input: unknown) => {
|
||||
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: evidence.evidence_id,
|
||||
capture: parseProcessCapture(evidence.normalized_result),
|
||||
};
|
||||
try {
|
||||
return {
|
||||
id: evidence.evidence_id,
|
||||
capture: parseProcessCapture(evidence.normalized_result),
|
||||
};
|
||||
} catch {
|
||||
throw invalidCaptureEvidence();
|
||||
}
|
||||
};
|
||||
|
||||
const invalidCaptureEvidence = (): ProcessCliFailure =>
|
||||
new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
"Capture evidence is malformed. Create new capture evidence, then try again.",
|
||||
);
|
||||
|
||||
const readJson = async (path: string): Promise<unknown> => {
|
||||
let bytes;
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type CommandShimReplay,
|
||||
} from "./CommandShimReplay.js";
|
||||
import { normalizeProcessText } from "./ProcessNormalization.js";
|
||||
import { selectCapturedProcessGroupIds } from "./ProcessOwnership.js";
|
||||
import {
|
||||
awaitTerminalExit,
|
||||
buildCaptureResult,
|
||||
@@ -130,20 +131,6 @@ const scheduleScenarioEvents = (options: ScenarioEventOptions): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const startScenarioEvents = (options: ScenarioEventOptions): (() => void) => {
|
||||
let eventsStarted = false;
|
||||
return () => {
|
||||
if (eventsStarted) return;
|
||||
eventsStarted = true;
|
||||
scheduleScenarioEvents(options);
|
||||
};
|
||||
};
|
||||
|
||||
const eventsRequireReadiness = (scenario: ProcessScenario): boolean =>
|
||||
scenario.events.some(
|
||||
(event) => event.type === "input" || event.type === "resize",
|
||||
);
|
||||
|
||||
interface StartedCaptureRuntime {
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly renderer: TerminalRenderer;
|
||||
@@ -171,7 +158,8 @@ const cleanupFailedStartup = async (options: {
|
||||
options.terminal?.kill("SIGKILL");
|
||||
const cleanupFailure = await releaseProcessResources({
|
||||
...options,
|
||||
sampledProcessGroupIds: [],
|
||||
capturedProcessGroupIds:
|
||||
options.terminal === undefined ? [] : [options.terminal.pid],
|
||||
});
|
||||
if (cleanupFailure !== undefined)
|
||||
throw new ProcessCaptureError(cleanupFailure, {
|
||||
@@ -243,22 +231,20 @@ const startCaptureRuntime = async (options: {
|
||||
rows: scenario.terminal.rows,
|
||||
name: "xterm-256color",
|
||||
});
|
||||
const startEvents = startScenarioEvents({
|
||||
...options,
|
||||
getTerminal: () => terminal,
|
||||
renderer,
|
||||
started,
|
||||
});
|
||||
if (!eventsRequireReadiness(scenario)) startEvents();
|
||||
const framesTruncated = captureTerminalFrames({
|
||||
...options,
|
||||
terminal,
|
||||
started,
|
||||
onOutput: () => (lastOutput = Date.now()),
|
||||
onFirstOutput: startEvents,
|
||||
renderer,
|
||||
checkpoints,
|
||||
});
|
||||
scheduleScenarioEvents({
|
||||
...options,
|
||||
getTerminal: () => terminal,
|
||||
renderer,
|
||||
started,
|
||||
});
|
||||
const stopSampler = startProcessSampler(
|
||||
terminal.pid,
|
||||
started,
|
||||
@@ -335,9 +321,13 @@ const finishProcessRun = async (options: {
|
||||
checkpoints: options.runtime?.checkpoints,
|
||||
runId: options.runId,
|
||||
temporaryRoot: options.temporaryRoot,
|
||||
sampledProcessGroupIds: options.samples.flatMap(({ process_group_id }) =>
|
||||
process_group_id === null ? [] : [process_group_id],
|
||||
),
|
||||
capturedProcessGroupIds:
|
||||
options.runtime === undefined
|
||||
? []
|
||||
: selectCapturedProcessGroupIds(
|
||||
options.runtime.terminal.pid,
|
||||
options.samples,
|
||||
),
|
||||
});
|
||||
let { capture, executionFailure } = options;
|
||||
if (capture !== undefined) {
|
||||
@@ -381,12 +371,7 @@ const completeCapture = async (options: {
|
||||
if (reason === "cancelled") throw processCaptureCancelled();
|
||||
const settlement = await observeSettlement(
|
||||
options.runId,
|
||||
[
|
||||
runtime.terminal.pid,
|
||||
...options.samples.flatMap(({ process_group_id }) =>
|
||||
process_group_id === null ? [] : [process_group_id],
|
||||
),
|
||||
],
|
||||
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
|
||||
scenario.settle_ms,
|
||||
);
|
||||
runtime.checkpoints.trigger("settled");
|
||||
|
||||
@@ -31,7 +31,7 @@ export const normalizeProcessText = (
|
||||
);
|
||||
}
|
||||
if (scenario.normalization.pids)
|
||||
normalized = normalized.replaceAll(String(pid), "<pid>");
|
||||
normalized = normalizePidTokens(normalized, [pid]);
|
||||
if (scenario.normalization.ports)
|
||||
normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
|
||||
for (const pattern of scenario.normalization.patterns)
|
||||
@@ -57,6 +57,17 @@ export const normalizeProcessSamples = (
|
||||
for (const identifier of identifiers)
|
||||
if (identifier > 0 && !mapping.has(identifier))
|
||||
mapping.set(identifier, mapping.size + 1);
|
||||
const normalizeCommand = (command: string): string => {
|
||||
const normalized = normalizeProcessText(
|
||||
command,
|
||||
scenario,
|
||||
"<no-temporary-root>",
|
||||
rootPid,
|
||||
);
|
||||
return scenario.normalization.pids
|
||||
? normalizePidTokens(normalized, identifiers)
|
||||
: normalized;
|
||||
};
|
||||
return samples.map((sample) => ({
|
||||
at_ms:
|
||||
Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
|
||||
@@ -69,15 +80,25 @@ export const normalizeProcessSamples = (
|
||||
: (mapping.get(sample.process_group_id) ?? 0),
|
||||
session_id:
|
||||
sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
|
||||
command: normalizeProcessText(
|
||||
sample.command,
|
||||
scenario,
|
||||
"<no-temporary-root>",
|
||||
rootPid,
|
||||
),
|
||||
command: normalizeCommand(sample.command),
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizePidTokens = (
|
||||
value: string,
|
||||
identifiers: readonly number[],
|
||||
): string => {
|
||||
const tokens = [...new Set(identifiers)]
|
||||
.filter((identifier) => Number.isSafeInteger(identifier) && identifier > 0)
|
||||
.sort((left, right) => right - left)
|
||||
.map(String);
|
||||
if (tokens.length === 0) return value;
|
||||
return value.replace(
|
||||
new RegExp(`(?<!\\d)(?:${tokens.join("|")})(?!\\d)`, "gu"),
|
||||
"<pid>",
|
||||
);
|
||||
};
|
||||
|
||||
export const redactProtocolEvents = (
|
||||
events: readonly ProcessCapture["protocol_events"][number][],
|
||||
scenario: ProcessScenario,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type { ProcessSample } from "../domain/processCapture.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -40,11 +41,30 @@ export type ProcessGroupObservation =
|
||||
| { readonly state: "alive" }
|
||||
| { readonly state: "unverifiable"; readonly reason: string };
|
||||
|
||||
const parseEnvironment = (value: string): Readonly<Record<string, string>> =>
|
||||
/** Select the PTY root group and groups led by an observed captured process. */
|
||||
export const selectCapturedProcessGroupIds = (
|
||||
rootPid: number,
|
||||
samples: readonly Pick<ProcessSample, "pid" | "process_group_id">[],
|
||||
): readonly number[] => {
|
||||
const processGroupIds = new Set<number>([rootPid]);
|
||||
for (const sample of samples) {
|
||||
// A sampled member may transiently inherit an unrelated group. POSIX group
|
||||
// leaders have pid === pgid, so only that observation establishes that the
|
||||
// group leader itself belonged to the captured tree. Token checks below
|
||||
// remain the final authority immediately before observation or signaling.
|
||||
if (sample.pid === sample.process_group_id) processGroupIds.add(sample.pid);
|
||||
}
|
||||
return [...processGroupIds];
|
||||
};
|
||||
|
||||
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
||||
export const parseProcessEnvironment = (
|
||||
value: string,
|
||||
): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries(
|
||||
value
|
||||
.split("\0")
|
||||
.filter((entry) => entry.includes("="))
|
||||
.filter((entry) => entry.indexOf("=") > 0)
|
||||
.map((entry) => {
|
||||
const separator = entry.indexOf("=");
|
||||
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
||||
@@ -72,7 +92,9 @@ const systemHost: ProcessOwnershipHost = {
|
||||
},
|
||||
async environment(pid) {
|
||||
if (process.platform === "linux") {
|
||||
return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
|
||||
return parseProcessEnvironment(
|
||||
await readFile(`/proc/${pid}/environ`, "utf8"),
|
||||
);
|
||||
}
|
||||
const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
|
||||
const environment: Record<string, string> = {};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type { ProcessSample } from "../domain/processCapture.js";
|
||||
|
||||
@@ -104,25 +104,79 @@ const readProcessCommand = async (
|
||||
}
|
||||
};
|
||||
|
||||
const readLinuxChildren = async (
|
||||
/** Procfs reads used to enumerate children created by every process thread. */
|
||||
export interface LinuxChildrenHost {
|
||||
taskIds(
|
||||
pid: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly number[] | undefined>;
|
||||
children(
|
||||
pid: number,
|
||||
taskId: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
const systemLinuxChildrenHost: LinuxChildrenHost = {
|
||||
async taskIds(pid, signal) {
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
const entries = await readdir(`/proc/${String(pid)}/task`);
|
||||
signal.throwIfAborted();
|
||||
return entries
|
||||
.filter((entry) => /^\d+$/.test(entry))
|
||||
.map(Number)
|
||||
.filter((taskId) => Number.isSafeInteger(taskId) && taskId > 0)
|
||||
.sort((left, right) => left - right);
|
||||
} catch (cause: unknown) {
|
||||
return handleProcReadFailure(cause);
|
||||
}
|
||||
},
|
||||
async children(pid, taskId, signal) {
|
||||
try {
|
||||
return await readFile(
|
||||
`/proc/${String(pid)}/task/${String(taskId)}/children`,
|
||||
{ encoding: "utf8", signal },
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return handleProcReadFailure(cause);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Read the deduplicated children created by every thread in a Linux process. */
|
||||
export const readLinuxChildren = async (
|
||||
pid: number,
|
||||
signal: AbortSignal,
|
||||
host: LinuxChildrenHost = systemLinuxChildrenHost,
|
||||
): Promise<readonly number[] | undefined> => {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(`/proc/${String(pid)}/task/${String(pid)}/children`, {
|
||||
encoding: "utf8",
|
||||
signal,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return handleProcReadFailure(cause);
|
||||
const taskIds = await host.taskIds(pid, signal);
|
||||
if (taskIds === undefined) return undefined;
|
||||
const children = new Set<number>();
|
||||
let successfulReads = 0;
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < taskIds.length;
|
||||
offset += PROC_READ_CONCURRENCY
|
||||
) {
|
||||
const texts = await Promise.all(
|
||||
taskIds
|
||||
.slice(offset, offset + PROC_READ_CONCURRENCY)
|
||||
.map((taskId) => host.children(pid, taskId, signal)),
|
||||
);
|
||||
for (const text of texts) {
|
||||
if (text === undefined) continue;
|
||||
successfulReads += 1;
|
||||
for (const value of text.trim().split(/\s+/)) {
|
||||
if (!/^\d+$/.test(value)) continue;
|
||||
const child = Number(value);
|
||||
if (Number.isSafeInteger(child) && child > 0) children.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((value) => /^\d+$/.test(value))
|
||||
.map(Number)
|
||||
.filter((child) => Number.isSafeInteger(child) && child > 0);
|
||||
return successfulReads === 0
|
||||
? undefined
|
||||
: [...children].sort((left, right) => left - right);
|
||||
};
|
||||
|
||||
const inspectProcess = async (
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
realpath,
|
||||
stat,
|
||||
unlink,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { z } from "zod";
|
||||
@@ -13,6 +23,7 @@ const grantSchema = z.object({
|
||||
capability: z.enum([
|
||||
"process_capture",
|
||||
"browser_observe",
|
||||
"electron_observe",
|
||||
"evidence_read",
|
||||
"evidence_write",
|
||||
"investigation_input",
|
||||
@@ -49,7 +60,12 @@ export class ProjectPermissionStoreError extends Error {
|
||||
readonly _tag = "ProjectPermissionStoreError" as const;
|
||||
|
||||
constructor(
|
||||
readonly reason: "project_not_found" | "not_owner_only" | "invalid" | "io",
|
||||
readonly reason:
|
||||
| "project_not_found"
|
||||
| "not_owner_only"
|
||||
| "invalid"
|
||||
| "locked"
|
||||
| "io",
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Project permission store failed: ${reason}`, options);
|
||||
@@ -89,9 +105,14 @@ export const readProjectPermissionStore = async (
|
||||
const metadata = await stat(path);
|
||||
if ((metadata.mode & 0o077) !== 0)
|
||||
return err(new ProjectPermissionStoreError("not_owner_only"));
|
||||
const parsed = storeSchema.safeParse(
|
||||
JSON.parse(await readFile(path, "utf8")),
|
||||
);
|
||||
const encoded = await readFile(path, "utf8");
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(encoded);
|
||||
} catch (cause: unknown) {
|
||||
return err(new ProjectPermissionStoreError("invalid", { cause }));
|
||||
}
|
||||
const parsed = storeSchema.safeParse(decoded);
|
||||
if (
|
||||
!parsed.success ||
|
||||
parsed.data.project_id !== project.value.id ||
|
||||
@@ -143,5 +164,114 @@ export const writeProjectPermissionStore = async (
|
||||
}
|
||||
};
|
||||
|
||||
const isNotFound = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
/** Revoke one grant while serializing the complete read-modify-write cycle. */
|
||||
export const revokeProjectPermissionGrant = async (
|
||||
path: string,
|
||||
projectRoot: string,
|
||||
grantId: string,
|
||||
): Promise<Result<boolean, ProjectPermissionStoreError>> => {
|
||||
let lock: PermissionStoreLock | undefined;
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const acquired = await acquirePermissionStoreLock(path);
|
||||
if (!acquired.ok) return acquired;
|
||||
lock = acquired.value;
|
||||
const current = await readProjectPermissionStore(path, projectRoot);
|
||||
if (!current.ok) return current;
|
||||
const grants = current.value?.grants ?? [];
|
||||
const retained = grants.filter(({ grant_id }) => grant_id !== grantId);
|
||||
if (retained.length === grants.length) return ok(false);
|
||||
const written = await writeProjectPermissionStore(
|
||||
path,
|
||||
projectRoot,
|
||||
retained,
|
||||
);
|
||||
return written.ok ? ok(true) : written;
|
||||
} catch (cause: unknown) {
|
||||
return err(new ProjectPermissionStoreError("io", { cause }));
|
||||
} finally {
|
||||
if (lock !== undefined) await releasePermissionStoreLock(lock);
|
||||
}
|
||||
};
|
||||
|
||||
interface PermissionStoreLock {
|
||||
readonly path: string;
|
||||
readonly handle: FileHandle;
|
||||
}
|
||||
|
||||
const acquirePermissionStoreLock = async (
|
||||
destination: string,
|
||||
): Promise<Result<PermissionStoreLock, ProjectPermissionStoreError>> => {
|
||||
const path = `${destination}.lock`;
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
return ok(await createPermissionStoreLock(path));
|
||||
} catch (cause: unknown) {
|
||||
if (!isAlreadyExists(cause))
|
||||
return err(new ProjectPermissionStoreError("io", { cause }));
|
||||
if (await removeStalePermissionStoreLock(path)) continue;
|
||||
if (attempt === 99)
|
||||
return err(new ProjectPermissionStoreError("locked", { cause }));
|
||||
await delay(10);
|
||||
}
|
||||
}
|
||||
return err(new ProjectPermissionStoreError("locked"));
|
||||
};
|
||||
|
||||
const createPermissionStoreLock = async (
|
||||
path: string,
|
||||
): Promise<PermissionStoreLock> => {
|
||||
const handle = await open(path, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${String(process.pid)}\n`, "utf8");
|
||||
await handle.sync();
|
||||
return { path, handle };
|
||||
} catch (cause: unknown) {
|
||||
await handle.close().catch(() => undefined);
|
||||
await unlink(path).catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const removeStalePermissionStoreLock = async (
|
||||
path: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 32)
|
||||
return false;
|
||||
const encoded = (await readFile(path, "utf8")).trim();
|
||||
if (!/^[1-9][0-9]{0,9}$/u.test(encoded)) return false;
|
||||
if (processIsAlive(Number(encoded))) return false;
|
||||
await unlink(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const processIsAlive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
return errorCode(cause) !== "ESRCH";
|
||||
}
|
||||
};
|
||||
|
||||
const releasePermissionStoreLock = async (
|
||||
lock: PermissionStoreLock,
|
||||
): Promise<void> => {
|
||||
await lock.handle.close().catch(() => undefined);
|
||||
await unlink(lock.path).catch(() => undefined);
|
||||
};
|
||||
|
||||
const isNotFound = (cause: unknown): boolean => errorCode(cause) === "ENOENT";
|
||||
|
||||
const isAlreadyExists = (cause: unknown): boolean =>
|
||||
errorCode(cause) === "EEXIST";
|
||||
|
||||
const errorCode = (cause: unknown): unknown =>
|
||||
typeof cause === "object" && cause !== null && "code" in cause
|
||||
? cause.code
|
||||
: undefined;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ReferenceSourceImportError,
|
||||
type ReferenceSourceImportOptions,
|
||||
} from "./ReferenceSourceImportTypes.js";
|
||||
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
||||
|
||||
/** Validated, authorized inputs ready for filesystem traversal. */
|
||||
export interface PreparedReferenceSourceImport {
|
||||
@@ -66,8 +67,10 @@ const authorizeRoot = async (
|
||||
failure("invalid-root", "Reference source root is not a directory"),
|
||||
);
|
||||
const canonicalRoot = await realpath(resolve(requestedRoot));
|
||||
for (const approvedRoot of approvedRoots) {
|
||||
const canonicalApproved = await realpath(resolve(approvedRoot));
|
||||
const canonicalApprovedRoots = await canonicalizeConfiguredRoots(
|
||||
approvedRoots.map((approvedRoot) => resolve(approvedRoot)),
|
||||
);
|
||||
for (const canonicalApproved of canonicalApprovedRoots) {
|
||||
if (withinRoot(canonicalApproved, canonicalRoot))
|
||||
return ok(canonicalRoot);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ export type SetupFailureCode =
|
||||
| "launcher_missing"
|
||||
| "runtime_dependency_unavailable"
|
||||
| "unsupported_hopper_build"
|
||||
| "setup_cancelled";
|
||||
| "setup_cancelled"
|
||||
| "destination_exists"
|
||||
| "mount_failed"
|
||||
| "bundle_invalid"
|
||||
| "copy_failed";
|
||||
|
||||
export type SetupHopperInstallResult =
|
||||
| { readonly status: "installed"; readonly launcherPath: string }
|
||||
@@ -84,6 +88,34 @@ export const setupInstallFailure = (
|
||||
remediation:
|
||||
"Setup was cancelled before Hopper was installed. Rerun rea setup when ready.",
|
||||
};
|
||||
case "destination_exists":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "destination_exists",
|
||||
remediation:
|
||||
"The Hopper installation destination already exists and was not replaced. Reuse the existing installation or move it aside, then rerun rea setup.",
|
||||
};
|
||||
case "mount":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "mount_failed",
|
||||
remediation:
|
||||
"The verified Hopper disk image could not be mounted. Eject any stale Hopper image, check local disk access, then rerun rea setup.",
|
||||
};
|
||||
case "bundle":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "bundle_invalid",
|
||||
remediation:
|
||||
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
|
||||
};
|
||||
case "copy":
|
||||
return {
|
||||
status: "failed",
|
||||
code: "copy_failed",
|
||||
remediation:
|
||||
"The verified Hopper application could not be copied to the installation destination. Check destination permissions and free space, then rerun rea setup.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
status: "failed",
|
||||
|
||||
@@ -69,7 +69,7 @@ export class TerminalRenderer {
|
||||
/** Await all queued parsing and return immutable rendered observations. */
|
||||
async frames(): Promise<readonly RenderedTerminalFrame[]> {
|
||||
await this.#pending;
|
||||
return this.#frames;
|
||||
return structuredClone(this.#frames);
|
||||
}
|
||||
|
||||
/** Whether a rendered observation exceeded its independent capture budget. */
|
||||
|
||||
@@ -94,7 +94,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
||||
}
|
||||
|
||||
provenance(): readonly ArtifactCommand[] {
|
||||
return this.#command === undefined ? [] : [this.#command];
|
||||
return this.#command === undefined ? [] : [structuredClone(this.#command)];
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
|
||||
@@ -103,7 +103,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
}
|
||||
|
||||
provenance(): readonly ArtifactCommand[] {
|
||||
return this.#provenance;
|
||||
return structuredClone(this.#provenance);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
||||
@@ -107,7 +107,7 @@ export class SafeOutputTree {
|
||||
}
|
||||
|
||||
get cleanup(): SafeOutputCleanup {
|
||||
return this.#cleanup;
|
||||
return structuredClone(this.#cleanup);
|
||||
}
|
||||
|
||||
/** Stream one selected regular file with exact byte and digest verification. */
|
||||
@@ -205,7 +205,7 @@ export class SafeOutputTree {
|
||||
|
||||
/** Remove only this operation's unsealed tree and verify absence. */
|
||||
async rollback(): Promise<SafeOutputCleanup> {
|
||||
if (this.#published) return this.#cleanup;
|
||||
if (this.#published) return structuredClone(this.#cleanup);
|
||||
await rm(this.#stagingRoot, { recursive: true, force: true });
|
||||
const absent = await isAbsent(this.#stagingRoot);
|
||||
this.#cleanup = {
|
||||
@@ -218,7 +218,7 @@ export class SafeOutputTree {
|
||||
"integrity",
|
||||
"Extraction output cleanup could not be verified",
|
||||
);
|
||||
return this.#cleanup;
|
||||
return structuredClone(this.#cleanup);
|
||||
}
|
||||
|
||||
async #prepareParent(relativePath: string): Promise<string> {
|
||||
|
||||
@@ -10,10 +10,41 @@ import {
|
||||
type ListBrowserTargetsInput,
|
||||
type WebPageInspection,
|
||||
} from "../domain/browserObservation.js";
|
||||
import { analyzeCapturedWebBundle } from "../domain/webBundleAnalyzer.js";
|
||||
import {
|
||||
webBundleAnalysisSchema,
|
||||
type AnalyzeWebBundleInput,
|
||||
type WebBundleAnalysis,
|
||||
} from "../domain/webBundleAnalysis.js";
|
||||
import {
|
||||
webObservationSessionSchema,
|
||||
type ObserveWebSessionInput,
|
||||
type WebObservationSession,
|
||||
} from "../domain/browserSession.js";
|
||||
import {
|
||||
webMcpDiscoverySchema,
|
||||
type DiscoverWebMcpToolsInput,
|
||||
type WebMcpDiscovery,
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import {
|
||||
compareWebCaptures,
|
||||
webCaptureDiffSchema,
|
||||
type CompareWebCapturesInput,
|
||||
type WebCaptureDiff,
|
||||
} from "../domain/webCaptureDiff.js";
|
||||
import {
|
||||
webScreenshotDiffSchema,
|
||||
webScreenshotSchema,
|
||||
type CaptureWebScreenshotInput,
|
||||
type CompareWebScreenshotsInput,
|
||||
type WebScreenshot,
|
||||
type WebScreenshotDiff,
|
||||
} from "../domain/webScreenshot.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
BrowserObservationError,
|
||||
ProviderAdapterError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
@@ -22,12 +53,17 @@ import {
|
||||
type CdpEndpointDiscovery,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
import { capturePage } from "./CdpPageCapture.js";
|
||||
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
|
||||
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
|
||||
import { observeCdpSession } from "./CdpObservationSession.js";
|
||||
import { discoverWebMcp } from "./CdpWebMcpDiscovery.js";
|
||||
import { captureCdpScreenshot } from "./CdpScreenshot.js";
|
||||
import { comparePngScreenshots } from "./PngVisualDiff.js";
|
||||
|
||||
const IDENTITY: ProviderIdentity = {
|
||||
id: "rea-cdp-browser",
|
||||
name: "REA Chrome DevTools Protocol observation provider",
|
||||
version: "1",
|
||||
version: "2",
|
||||
};
|
||||
|
||||
/** Passive, origin-scoped browser observation through a user-owned CDP endpoint. */
|
||||
@@ -81,6 +117,41 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
input: InspectWebPageInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebPageInspection, AnalysisError>> {
|
||||
try {
|
||||
const captured = await this.#capture(input, options);
|
||||
return ok(webPageInspectionSchema.parse(captured.inspection));
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "inspect_web_page"));
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeBundle(
|
||||
input: AnalyzeWebBundleInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
|
||||
try {
|
||||
const captured = await this.#capture(input, options);
|
||||
const sourceMaps = input.fetch_source_maps
|
||||
? await fetchWebSourceMaps(
|
||||
captured.sourceMapRequests,
|
||||
input,
|
||||
options.signal,
|
||||
)
|
||||
: undefined;
|
||||
return ok(
|
||||
webBundleAnalysisSchema.parse(
|
||||
analyzeCapturedWebBundle(captured.inspection, input, sourceMaps),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "analyze_web_bundle"));
|
||||
}
|
||||
}
|
||||
|
||||
async observeSession(
|
||||
input: ObserveWebSessionInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebObservationSession, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
@@ -101,7 +172,177 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
const result = await capturePage({
|
||||
return ok(
|
||||
webObservationSessionSchema.parse(
|
||||
await observeCdpSession({
|
||||
connection,
|
||||
sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "observe_web_session"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async discoverWebMcpTools(
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebMcpDiscovery, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
webMcpDiscoverySchema.parse(
|
||||
await discoverWebMcp({
|
||||
connection,
|
||||
sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "discover_webmcp_tools"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async compareCaptures(
|
||||
input: CompareWebCapturesInput,
|
||||
): Promise<Result<WebCaptureDiff, AnalysisError>> {
|
||||
try {
|
||||
return ok(webCaptureDiffSchema.parse(compareWebCaptures(input)));
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "compare_web_captures"));
|
||||
}
|
||||
}
|
||||
|
||||
async captureScreenshot(
|
||||
input: CaptureWebScreenshotInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebScreenshot, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
webScreenshotSchema.parse(
|
||||
await captureCdpScreenshot({
|
||||
connection,
|
||||
sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "capture_web_screenshot"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async compareScreenshots(
|
||||
input: CompareWebScreenshotsInput,
|
||||
): Promise<Result<WebScreenshotDiff, AnalysisError>> {
|
||||
try {
|
||||
return ok(webScreenshotDiffSchema.parse(comparePngScreenshots(input)));
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "compare_web_screenshots"));
|
||||
}
|
||||
}
|
||||
|
||||
async #capture(
|
||||
input: InspectWebPageInput,
|
||||
options: ExecutionOptions,
|
||||
): Promise<CapturedPage> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return await capturePage({
|
||||
connection,
|
||||
sessionId,
|
||||
discovery,
|
||||
@@ -112,9 +353,6 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
});
|
||||
return ok(webPageInspectionSchema.parse(result));
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "inspect_web_page"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
@@ -172,7 +410,7 @@ const selectTargets = (
|
||||
|
||||
const authorizeTarget = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
input: InspectWebPageInput,
|
||||
input: Pick<InspectWebPageInput, "target_id" | "allowed_origins">,
|
||||
): CdpEndpointTarget => {
|
||||
const target = discovery.targets.find(
|
||||
(candidate) => candidate.id === input.target_id,
|
||||
@@ -222,8 +460,10 @@ const bestEffortCleanup = async (
|
||||
|
||||
const providerError = (
|
||||
cause: unknown,
|
||||
operation: "list_browser_targets" | "inspect_web_page",
|
||||
operation: BrowserObservationOperation,
|
||||
): AnalysisError =>
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError(IDENTITY.id, operation, { cause });
|
||||
cause instanceof BrowserObservationError && cause.operation !== operation
|
||||
? new BrowserObservationError(operation, cause.reason, { cause })
|
||||
: cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError(IDENTITY.id, operation, { cause });
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
classifyBrowserCompleteness,
|
||||
type BrowserCompleteness,
|
||||
type BrowserDroppedEventKind,
|
||||
type BrowserDroppedEvents,
|
||||
type BrowserExclusion,
|
||||
type BrowserExclusionReason,
|
||||
type BrowserSection,
|
||||
} from "../domain/browserCompleteness.js";
|
||||
|
||||
const EVENT_SECTIONS: Readonly<
|
||||
Record<BrowserDroppedEventKind, BrowserSection>
|
||||
> = {
|
||||
scripts: "scripts",
|
||||
network_requests: "network_requests",
|
||||
console_events: "console_events",
|
||||
websocket_connections: "websocket_connections",
|
||||
websocket_frames: "websocket_frames",
|
||||
webmcp_tools: "webmcp_tools",
|
||||
timeline_events: "timeline",
|
||||
};
|
||||
|
||||
const POLICY_REASONS = new Set<BrowserExclusionReason>([
|
||||
"disallowed_origin",
|
||||
"unsupported_url",
|
||||
"unattributed_origin",
|
||||
"not_approved",
|
||||
"out_of_target_scope",
|
||||
]);
|
||||
|
||||
/** Mutable adapter ledger projected into immutable, canonical completeness v2. */
|
||||
export class CdpCaptureCompleteness {
|
||||
readonly #initialAttachLimited: readonly BrowserSection[];
|
||||
readonly #policyFiltered = new Set<BrowserSection>();
|
||||
readonly #attachLimited = new Set<BrowserSection>();
|
||||
readonly #truncated = new Set<BrowserSection>();
|
||||
readonly #unavailable = new Set<BrowserSection>();
|
||||
readonly #excluded = new Map<string, BrowserExclusion>();
|
||||
readonly #dropped: Omit<BrowserDroppedEvents, "total"> = emptyDropped();
|
||||
|
||||
constructor(initialAttachLimited: readonly BrowserSection[] = []) {
|
||||
this.#initialAttachLimited = [...initialAttachLimited];
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.#policyFiltered.clear();
|
||||
this.#attachLimited.clear();
|
||||
this.#truncated.clear();
|
||||
this.#unavailable.clear();
|
||||
this.#excluded.clear();
|
||||
Object.assign(this.#dropped, emptyDropped());
|
||||
for (const section of this.#initialAttachLimited)
|
||||
this.#attachLimited.add(section);
|
||||
}
|
||||
|
||||
exclude(
|
||||
section: BrowserSection,
|
||||
reason: BrowserExclusionReason,
|
||||
count: number | null = 1,
|
||||
): void {
|
||||
if (POLICY_REASONS.has(reason)) this.#policyFiltered.add(section);
|
||||
if (
|
||||
reason === "provider_unavailable" ||
|
||||
reason === "invalid_protocol_value"
|
||||
)
|
||||
this.#unavailable.add(section);
|
||||
const key = `${section}\0${reason}`;
|
||||
const existing = this.#excluded.get(key);
|
||||
const combined =
|
||||
count === null || existing?.count === null
|
||||
? null
|
||||
: (existing?.count ?? 0) + count;
|
||||
this.#excluded.set(key, { section, reason, count: combined });
|
||||
}
|
||||
|
||||
attachLimited(section: BrowserSection): void {
|
||||
this.#attachLimited.add(section);
|
||||
}
|
||||
|
||||
unavailable(section: BrowserSection): void {
|
||||
this.#unavailable.add(section);
|
||||
this.exclude(section, "provider_unavailable", null);
|
||||
}
|
||||
|
||||
truncate(section: BrowserSection): void {
|
||||
this.#truncated.add(section);
|
||||
}
|
||||
|
||||
drop(kind: BrowserDroppedEventKind, count = 1): void {
|
||||
this.#dropped[kind] += count;
|
||||
this.#truncated.add(EVENT_SECTIONS[kind]);
|
||||
}
|
||||
|
||||
get droppedTotal(): number {
|
||||
return Object.values(this.#dropped).reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
|
||||
snapshot(): BrowserCompleteness {
|
||||
return classifyBrowserCompleteness({
|
||||
policyFilteredSections: this.#policyFiltered,
|
||||
attachLimitedSections: this.#attachLimited,
|
||||
truncatedSections: this.#truncated,
|
||||
unavailableSections: this.#unavailable,
|
||||
excluded: [...this.#excluded.values()],
|
||||
droppedEvents: this.#dropped,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const emptyDropped = (): Omit<BrowserDroppedEvents, "total"> => ({
|
||||
scripts: 0,
|
||||
network_requests: 0,
|
||||
console_events: 0,
|
||||
websocket_connections: 0,
|
||||
websocket_frames: 0,
|
||||
webmcp_tools: 0,
|
||||
timeline_events: 0,
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
boundedText,
|
||||
isHttpUrl,
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
@@ -12,12 +13,19 @@ import {
|
||||
stringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
|
||||
export type CapturedResource = Omit<
|
||||
WebPageInspection["resources"][number],
|
||||
"resource_key"
|
||||
> & { readonly rawUrl: string };
|
||||
|
||||
/** Normalize allowed frames while retaining a bounded prefix and total count. */
|
||||
export const captureFrames = (
|
||||
result: unknown,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
maximum: number,
|
||||
completeness?: CdpCaptureCompleteness,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly items: WebPageInspection["frames"];
|
||||
@@ -30,14 +38,17 @@ export const captureFrames = (
|
||||
const frame = recordValue(tree.frame);
|
||||
const frameId = stringValue(frame?.id);
|
||||
const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
|
||||
if (
|
||||
frame === undefined ||
|
||||
frameId === undefined ||
|
||||
frameId.length > 256 ||
|
||||
sanitized === undefined ||
|
||||
sanitized.origin === null
|
||||
)
|
||||
if (frame === undefined || frameId === undefined || frameId.length > 256) {
|
||||
completeness?.exclude("frames", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
if (sanitized === undefined || sanitized.origin === null) {
|
||||
completeness?.exclude(
|
||||
"frames",
|
||||
exclusionReasonForUrl(stringValue(frame.url)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if (items.length >= maximum) continue;
|
||||
items.push({
|
||||
@@ -62,22 +73,30 @@ export const captureResources = (
|
||||
result: unknown,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
maximum: number,
|
||||
completeness?: CdpCaptureCompleteness,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly items: WebPageInspection["resources"];
|
||||
readonly items: readonly CapturedResource[];
|
||||
} => {
|
||||
const root = recordValue(requiredRecord(result).frameTree);
|
||||
if (root === undefined) return { total: 0, items: [] };
|
||||
const items: WebPageInspection["resources"] = [];
|
||||
const items: CapturedResource[] = [];
|
||||
let total = 0;
|
||||
for (const tree of walkFrameTrees(root)) {
|
||||
for (const resource of recordsValue(tree.resources)) {
|
||||
const url = allowedSanitizedUrl(resource.url, allowedOrigins);
|
||||
if (url === undefined || url.origin === null) continue;
|
||||
if (url === undefined || url.origin === null) {
|
||||
completeness?.exclude(
|
||||
"resources",
|
||||
exclusionReasonForUrl(stringValue(resource.url)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if (items.length >= maximum) continue;
|
||||
const contentSize = numberValue(resource.contentSize);
|
||||
items.push({
|
||||
rawUrl: stringValue(resource.url) ?? "",
|
||||
url: url.url,
|
||||
origin: url.origin,
|
||||
type: (stringValue(resource.type) ?? "Other").slice(0, 100),
|
||||
@@ -110,20 +129,29 @@ export const captureDom = (
|
||||
result: unknown,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
input: InspectWebPageInput,
|
||||
completeness?: CdpCaptureCompleteness,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["dom"]["nodes"];
|
||||
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
||||
readonly excludedUrls: number;
|
||||
} => {
|
||||
const root = requiredRecord(result);
|
||||
const strings = Array.isArray(root.strings)
|
||||
? root.strings.map((value) => stringValue(value) ?? "")
|
||||
: [];
|
||||
const nodes: WebPageInspection["dom"]["nodes"] = [];
|
||||
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
||||
const agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
|
||||
let excludedUrls = 0;
|
||||
let total = 0;
|
||||
for (const document of recordsValue(root.documents)) {
|
||||
const documentUrl = indexedString(strings, document.documentURL);
|
||||
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined)
|
||||
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
|
||||
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
|
||||
continue;
|
||||
}
|
||||
const documentNodes = recordValue(document.nodes);
|
||||
if (documentNodes === undefined) continue;
|
||||
const nodeTypes = numberArray(documentNodes.nodeType);
|
||||
@@ -142,42 +170,129 @@ export const captureDom = (
|
||||
) {
|
||||
const attributeIndexes = numberArray(attributes[index]);
|
||||
const parent = Math.trunc(parents[index] ?? -1);
|
||||
const nodeIndex = nodes.length;
|
||||
const nodeName = indexedString(strings, nodeNames[index]).slice(0, 256);
|
||||
nodes.push({
|
||||
index: nodes.length,
|
||||
index: nodeIndex,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
node_type: Math.max(0, Math.trunc(nodeTypes[index] ?? 0)),
|
||||
node_name: indexedString(strings, nodeNames[index]).slice(0, 256),
|
||||
node_name: nodeName,
|
||||
node_value_length: indexedString(strings, nodeValues[index]).length,
|
||||
attribute_names: attributeIndexes
|
||||
.filter((_value, attributeIndex) => attributeIndex % 2 === 0)
|
||||
.map((value) => indexedString(strings, value).slice(0, 256))
|
||||
.slice(0, 200),
|
||||
});
|
||||
const metadata = domMetadata(
|
||||
strings,
|
||||
attributeIndexes,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
allowedOrigins,
|
||||
);
|
||||
for (const url of metadata.urls) {
|
||||
if (urls.length >= input.limits.max_resources) {
|
||||
completeness?.truncate("metadata");
|
||||
continue;
|
||||
}
|
||||
urls.push(url);
|
||||
if (url.destination_scope !== "approved") {
|
||||
excludedUrls += 1;
|
||||
completeness?.exclude(
|
||||
"metadata",
|
||||
url.destination_scope === "outside_policy"
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url",
|
||||
);
|
||||
}
|
||||
}
|
||||
agentHints.push(...metadata.agentHints);
|
||||
}
|
||||
}
|
||||
return { total, nodes };
|
||||
return {
|
||||
total,
|
||||
nodes,
|
||||
urls,
|
||||
agentHints: agentHints.slice(0, input.limits.max_resources),
|
||||
excludedUrls,
|
||||
};
|
||||
};
|
||||
|
||||
export const captureAccessibility = (
|
||||
results: readonly unknown[],
|
||||
maximum: number,
|
||||
options: {
|
||||
readonly includeText: boolean;
|
||||
readonly maximumFieldBytes: number;
|
||||
readonly maximumTotalBytes: number;
|
||||
readonly unavailable?: boolean;
|
||||
},
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["accessibility"]["nodes"];
|
||||
readonly textCapture: WebPageInspection["accessibility"]["text_capture"];
|
||||
} => {
|
||||
const all = results.flatMap((result) =>
|
||||
recordsValue(requiredRecord(result).nodes),
|
||||
);
|
||||
return {
|
||||
total: all.length,
|
||||
nodes: all.slice(0, maximum).map((node) => ({
|
||||
let retainedBytes = 0;
|
||||
let excludedFields = 0;
|
||||
let truncatedFields = 0;
|
||||
const nodes = all.slice(0, maximum).map((node) => {
|
||||
const captureText = (value: unknown): string | null => {
|
||||
const raw = stringValue(recordValue(value)?.value);
|
||||
if (raw === undefined) return null;
|
||||
if (!options.includeText) {
|
||||
excludedFields += 1;
|
||||
return null;
|
||||
}
|
||||
const available = Math.max(0, options.maximumTotalBytes - retainedBytes);
|
||||
const captured = boundedUtf8(
|
||||
raw,
|
||||
Math.min(options.maximumFieldBytes, available),
|
||||
);
|
||||
retainedBytes += captured.bytes;
|
||||
if (captured.truncated) {
|
||||
truncatedFields += 1;
|
||||
if (captured.text === "" && raw !== "") {
|
||||
excludedFields += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return captured.text;
|
||||
};
|
||||
return {
|
||||
node_id: (stringValue(node.nodeId) ?? "").slice(0, 256),
|
||||
parent_id: (stringValue(node.parentId) ?? "").slice(0, 256) || null,
|
||||
role: axText(node.role),
|
||||
name: axText(node.name),
|
||||
description: axText(node.description),
|
||||
name: captureText(node.name),
|
||||
description: captureText(node.description),
|
||||
ignored: node.ignored === true,
|
||||
})),
|
||||
};
|
||||
});
|
||||
for (const node of all.slice(maximum)) {
|
||||
for (const value of [node.name, node.description]) {
|
||||
if (stringValue(recordValue(value)?.value) === undefined) continue;
|
||||
excludedFields += 1;
|
||||
if (options.includeText) truncatedFields += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: all.length,
|
||||
nodes,
|
||||
textCapture: {
|
||||
status: options.unavailable
|
||||
? "unavailable"
|
||||
: !options.includeText
|
||||
? "not_approved"
|
||||
: truncatedFields > 0
|
||||
? "truncated"
|
||||
: "included",
|
||||
retained_bytes: retainedBytes,
|
||||
excluded_fields: excludedFields,
|
||||
truncated_fields: truncatedFields,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -196,3 +311,112 @@ const numberArray = (value: unknown): readonly number[] =>
|
||||
return number === undefined ? [] : [number];
|
||||
})
|
||||
: [];
|
||||
|
||||
const domMetadata = (
|
||||
strings: readonly string[],
|
||||
attributes: readonly number[],
|
||||
documentUrl: string,
|
||||
nodeIndex: number,
|
||||
nodeName: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): {
|
||||
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
||||
} => {
|
||||
const pairs = new Map<string, string>();
|
||||
for (let index = 0; index + 1 < attributes.length; index += 2) {
|
||||
const name = indexedString(strings, attributes[index]).toLowerCase();
|
||||
const value = indexedString(strings, attributes[index + 1]);
|
||||
pairs.set(name, value);
|
||||
}
|
||||
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
||||
for (const attribute of domUrlAttributes) {
|
||||
const value = pairs.get(attribute);
|
||||
if (value === undefined) continue;
|
||||
const destination = domDestination(value, documentUrl, allowedOrigins);
|
||||
urls.push({
|
||||
node_index: nodeIndex,
|
||||
attribute,
|
||||
url: destination.url,
|
||||
destination_scope: destination.scope,
|
||||
});
|
||||
}
|
||||
const rel = (pairs.get("rel") ?? "")
|
||||
.toLowerCase()
|
||||
.split(/\s+/u)
|
||||
.filter((value) => agentRelValues.includes(value));
|
||||
const href = urls.find(({ attribute }) => attribute === "href")?.url ?? null;
|
||||
const agentHints =
|
||||
nodeName.toLowerCase() === "link" && rel.length > 0
|
||||
? rel.map((declaration) => ({
|
||||
mechanism: "dom_link_rel" as const,
|
||||
declaration,
|
||||
url: href,
|
||||
trust: "page-declared-untrusted" as const,
|
||||
}))
|
||||
: [];
|
||||
return { urls, agentHints };
|
||||
};
|
||||
|
||||
const domDestination = (
|
||||
value: string,
|
||||
documentUrl: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): {
|
||||
readonly url: string | null;
|
||||
readonly scope: "approved" | "outside_policy" | "unsupported";
|
||||
} => {
|
||||
try {
|
||||
const parsed = new URL(value, documentUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
||||
return { url: null, scope: "unsupported" };
|
||||
if (!allowedOrigins.has(parsed.origin))
|
||||
return { url: null, scope: "outside_policy" };
|
||||
return {
|
||||
url: allowedSanitizedUrl(parsed.href, allowedOrigins)?.url ?? null,
|
||||
scope: "approved",
|
||||
};
|
||||
} catch {
|
||||
return { url: null, scope: "unsupported" };
|
||||
}
|
||||
};
|
||||
|
||||
const boundedUtf8 = (
|
||||
value: string,
|
||||
maximumBytes: number,
|
||||
): {
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
readonly truncated: boolean;
|
||||
} => {
|
||||
const total = Buffer.byteLength(value);
|
||||
if (total <= maximumBytes)
|
||||
return { text: value, bytes: total, truncated: false };
|
||||
let text = "";
|
||||
let bytes = 0;
|
||||
for (const character of value) {
|
||||
const characterBytes = Buffer.byteLength(character);
|
||||
if (bytes + characterBytes > maximumBytes) break;
|
||||
text += character;
|
||||
bytes += characterBytes;
|
||||
}
|
||||
return { text, bytes, truncated: true };
|
||||
};
|
||||
|
||||
const exclusionReasonForUrl = (
|
||||
value: string | undefined,
|
||||
): "disallowed_origin" | "unsupported_url" | "unattributed_origin" =>
|
||||
value === undefined || value === ""
|
||||
? "unattributed_origin"
|
||||
: isHttpUrl(value)
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url";
|
||||
|
||||
const domUrlAttributes = [
|
||||
"href",
|
||||
"src",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
] as const;
|
||||
const agentRelValues = ["mcp", "model-context", "ai-plugin", "service-desc"];
|
||||
|
||||
+420
-47
@@ -2,7 +2,11 @@ import type {
|
||||
InspectWebPageInput,
|
||||
WebPageInspection,
|
||||
} from "../domain/browserObservation.js";
|
||||
import { inferJsonShape, type JsonShape } from "../domain/jsonShape.js";
|
||||
import type { CdpEvent } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { safeResponseMetadata } from "./CdpSafeMetadata.js";
|
||||
import { boundedSensitiveText } from "./SensitiveTextCapture.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
boundedText,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
|
||||
export interface CapturedScript {
|
||||
readonly scriptId: string;
|
||||
readonly rawUrl: string;
|
||||
readonly url: string;
|
||||
readonly origin: string | null;
|
||||
readonly hash: string;
|
||||
@@ -23,6 +28,7 @@ export interface CapturedScript {
|
||||
readonly isModule: boolean;
|
||||
readonly language: string | null;
|
||||
readonly sourceMapUrl: string | null;
|
||||
readonly sourceMapRawUrl: string | null;
|
||||
}
|
||||
|
||||
type NetworkState = WebPageInspection["network"]["requests"][number];
|
||||
@@ -34,10 +40,21 @@ export class CdpCaptureEvents {
|
||||
readonly allowedWebSockets = new Set<string>();
|
||||
readonly console: WebPageInspection["console"]["events"] = [];
|
||||
readonly websockets: WebPageInspection["network"]["websocket_events"] = [];
|
||||
dropped = 0;
|
||||
readonly responseMetadata: WebPageInspection["metadata"]["responses"] = [];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
|
||||
readonly completeness = new CdpCaptureCompleteness([
|
||||
"network_requests",
|
||||
"console_events",
|
||||
"websocket_connections",
|
||||
"websocket_frames",
|
||||
"metadata",
|
||||
]);
|
||||
originViolation = false;
|
||||
navigationDuringCapture = false;
|
||||
#mainFrameId: string | undefined;
|
||||
#consoleTextBytes = 0;
|
||||
#jsonBodyBytes = 0;
|
||||
#websocketShapeBytes = 0;
|
||||
|
||||
constructor(
|
||||
private readonly input: InspectWebPageInput,
|
||||
@@ -48,12 +65,23 @@ export class CdpCaptureEvents {
|
||||
this.#mainFrameId = frameId;
|
||||
this.originViolation = false;
|
||||
this.navigationDuringCapture = false;
|
||||
this.dropped = 0;
|
||||
this.scripts.clear();
|
||||
this.network.clear();
|
||||
this.allowedWebSockets.clear();
|
||||
this.console.length = 0;
|
||||
this.websockets.length = 0;
|
||||
this.responseMetadata.length = 0;
|
||||
this.agentHints.length = 0;
|
||||
this.#consoleTextBytes = 0;
|
||||
this.#jsonBodyBytes = 0;
|
||||
this.#websocketShapeBytes = 0;
|
||||
this.completeness.reset();
|
||||
if (!this.input.include_console_text)
|
||||
this.completeness.exclude("console_text", "not_approved", null);
|
||||
if (!this.input.include_json_body_shapes)
|
||||
this.completeness.exclude("json_body_shapes", "not_approved", null);
|
||||
if (!this.input.include_websocket_shapes)
|
||||
this.completeness.exclude("websocket_shapes", "not_approved", null);
|
||||
}
|
||||
|
||||
beginFinalCapture(frameId: string): void {
|
||||
@@ -99,21 +127,25 @@ export class CdpCaptureEvents {
|
||||
const scriptId = stringValue(params.scriptId);
|
||||
const rawUrl = stringValue(params.url) ?? "";
|
||||
const sanitized = allowedSanitizedUrl(rawUrl, this.allowedOrigins);
|
||||
if (
|
||||
scriptId === undefined ||
|
||||
scriptId.length > 256 ||
|
||||
sanitized === undefined
|
||||
)
|
||||
if (scriptId === undefined || scriptId.length > 256) {
|
||||
this.completeness.exclude("scripts", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
if (sanitized === undefined) {
|
||||
this.completeness.exclude("scripts", exclusionReasonForUrl(rawUrl));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.scripts.size >= this.input.limits.max_scripts &&
|
||||
!this.scripts.has(scriptId)
|
||||
) {
|
||||
this.dropped += 1;
|
||||
this.completeness.drop("scripts");
|
||||
return;
|
||||
}
|
||||
const sourceMap = this.#sourceMap(params.sourceMapURL, rawUrl);
|
||||
this.scripts.set(scriptId, {
|
||||
scriptId,
|
||||
rawUrl,
|
||||
url: sanitized.url,
|
||||
origin: sanitized.origin,
|
||||
hash: (stringValue(params.hash) ?? "").slice(0, 512),
|
||||
@@ -126,21 +158,53 @@ export class CdpCaptureEvents {
|
||||
),
|
||||
isModule: params.isModule === true,
|
||||
language: boundedText(params.scriptLanguage, 100),
|
||||
sourceMapUrl: this.#sourceMap(params.sourceMapURL),
|
||||
sourceMapUrl: sourceMap.sanitized,
|
||||
sourceMapRawUrl: sourceMap.raw,
|
||||
});
|
||||
}
|
||||
|
||||
#sourceMap(value: unknown): string | null {
|
||||
const sanitized = allowedSanitizedUrl(value, this.allowedOrigins);
|
||||
return sanitized?.url ?? null;
|
||||
#sourceMap(
|
||||
value: unknown,
|
||||
scriptUrl: string,
|
||||
): {
|
||||
readonly sanitized: string | null;
|
||||
readonly raw: string | null;
|
||||
} {
|
||||
const declaredUrl = stringValue(value);
|
||||
if (declaredUrl === undefined || declaredUrl === "")
|
||||
return { sanitized: null, raw: null };
|
||||
let rawUrl: string;
|
||||
try {
|
||||
rawUrl = new URL(declaredUrl, scriptUrl).href;
|
||||
} catch {
|
||||
this.completeness.exclude("source_maps", "unsupported_url");
|
||||
return { sanitized: null, raw: null };
|
||||
}
|
||||
const sanitized = allowedSanitizedUrl(rawUrl, this.allowedOrigins);
|
||||
if (sanitized !== undefined)
|
||||
return { sanitized: sanitized.url, raw: rawUrl };
|
||||
this.completeness.exclude("source_maps", exclusionReasonForUrl(rawUrl));
|
||||
return { sanitized: null, raw: null };
|
||||
}
|
||||
|
||||
#request(params: UnknownRecord): void {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const request = recordValue(params.request);
|
||||
const sanitized = allowedSanitizedUrl(request?.url, this.allowedOrigins);
|
||||
if (requestId === undefined || requestId.length > 256) return;
|
||||
if (request === undefined || sanitized === undefined) {
|
||||
if (requestId === undefined || requestId.length > 256) {
|
||||
this.completeness.exclude("network_requests", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
if (request === undefined) {
|
||||
this.completeness.exclude("network_requests", "invalid_protocol_value");
|
||||
this.network.delete(requestId);
|
||||
return;
|
||||
}
|
||||
const sanitized = allowedSanitizedUrl(request.url, this.allowedOrigins);
|
||||
if (sanitized === undefined) {
|
||||
this.completeness.exclude(
|
||||
"network_requests",
|
||||
exclusionReasonForUrl(stringValue(request.url)),
|
||||
);
|
||||
this.network.delete(requestId);
|
||||
return;
|
||||
}
|
||||
@@ -148,14 +212,21 @@ export class CdpCaptureEvents {
|
||||
this.network.size >= this.input.limits.max_network_events &&
|
||||
!this.network.has(requestId)
|
||||
) {
|
||||
this.dropped += 1;
|
||||
this.completeness.drop("network_requests");
|
||||
return;
|
||||
}
|
||||
const initiator = recordValue(params.initiator);
|
||||
const initiatorFrame = initiatorLocation(initiator);
|
||||
const rawInitiatorUrl = stringValue(initiatorFrame?.url);
|
||||
const initiatorUrl = allowedSanitizedUrl(
|
||||
firstCallFrame(initiator)?.url,
|
||||
rawInitiatorUrl,
|
||||
this.allowedOrigins,
|
||||
);
|
||||
if (rawInitiatorUrl !== undefined && initiatorUrl === undefined)
|
||||
this.completeness.exclude(
|
||||
"network_initiators",
|
||||
exclusionReasonForUrl(rawInitiatorUrl),
|
||||
);
|
||||
this.network.set(requestId, {
|
||||
request_id: requestId,
|
||||
url: sanitized.url,
|
||||
@@ -168,20 +239,30 @@ export class CdpCaptureEvents {
|
||||
initiator: {
|
||||
type: (stringValue(initiator?.type) ?? "other").slice(0, 100),
|
||||
url: initiatorUrl?.url ?? null,
|
||||
line: integerOrNull(firstCallFrame(initiator)?.lineNumber),
|
||||
column: integerOrNull(firstCallFrame(initiator)?.columnNumber),
|
||||
line: integerOrNull(initiatorFrame?.lineNumber),
|
||||
column: integerOrNull(initiatorFrame?.columnNumber),
|
||||
},
|
||||
body_shapes: this.#requestBodyShape(request),
|
||||
});
|
||||
}
|
||||
|
||||
#response(params: UnknownRecord): void {
|
||||
const requestId = stringValue(params.requestId);
|
||||
if (requestId === undefined || requestId.length > 256) return;
|
||||
if (requestId === undefined || requestId.length > 256) {
|
||||
this.completeness.exclude("network_requests", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
const current = this.network.get(requestId);
|
||||
const response = recordValue(params.response);
|
||||
const sanitized = allowedSanitizedUrl(response?.url, this.allowedOrigins);
|
||||
if (current === undefined) return;
|
||||
const sanitized = allowedSanitizedUrl(response?.url, this.allowedOrigins);
|
||||
if (response === undefined || sanitized === undefined) {
|
||||
this.completeness.exclude(
|
||||
"network_requests",
|
||||
response === undefined
|
||||
? "invalid_protocol_value"
|
||||
: exclusionReasonForUrl(stringValue(response.url)),
|
||||
);
|
||||
this.network.delete(requestId);
|
||||
return;
|
||||
}
|
||||
@@ -190,6 +271,18 @@ export class CdpCaptureEvents {
|
||||
status: numberValue(response.status) ?? null,
|
||||
mime_type: boundedText(response.mimeType, 256),
|
||||
});
|
||||
if (this.responseMetadata.length >= this.input.limits.max_network_events) {
|
||||
this.completeness.truncate("metadata");
|
||||
return;
|
||||
}
|
||||
const metadata = safeResponseMetadata(
|
||||
requestId,
|
||||
sanitized.url,
|
||||
response,
|
||||
this.allowedOrigins,
|
||||
);
|
||||
this.responseMetadata.push(metadata.response);
|
||||
this.agentHints.push(...metadata.agentHints);
|
||||
}
|
||||
|
||||
#finished(params: UnknownRecord): void {
|
||||
@@ -208,23 +301,35 @@ export class CdpCaptureEvents {
|
||||
|
||||
#console(params: UnknownRecord): void {
|
||||
const frame = firstCallFrame(recordValue(params.stackTrace));
|
||||
const source = allowedSanitizedUrl(frame?.url, this.allowedOrigins);
|
||||
if (frame === undefined || source === undefined) return;
|
||||
if (this.console.length >= this.input.limits.max_console_events) {
|
||||
this.dropped += 1;
|
||||
if (frame === undefined) {
|
||||
this.completeness.exclude("console_events", "unattributed_origin");
|
||||
return;
|
||||
}
|
||||
const source = allowedSanitizedUrl(frame.url, this.allowedOrigins);
|
||||
if (source === undefined) {
|
||||
this.completeness.exclude(
|
||||
"console_events",
|
||||
exclusionReasonForUrl(stringValue(frame.url)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.console.length >= this.input.limits.max_console_events) {
|
||||
this.completeness.drop("console_events");
|
||||
return;
|
||||
}
|
||||
const arguments_ = recordsValue(params.args);
|
||||
this.console.push({
|
||||
type: (stringValue(params.type) ?? "unknown").slice(0, 100),
|
||||
timestamp: numberValue(params.timestamp) ?? 0,
|
||||
argument_types: recordsValue(params.args)
|
||||
argument_types: arguments_
|
||||
.map((argument) =>
|
||||
(stringValue(argument.type) ?? "unknown").slice(0, 100),
|
||||
)
|
||||
.slice(0, 100),
|
||||
url: source?.url ?? null,
|
||||
line: integerOrNull(frame?.lineNumber),
|
||||
column: integerOrNull(frame?.columnNumber),
|
||||
url: source.url,
|
||||
line: integerOrNull(frame.lineNumber),
|
||||
column: integerOrNull(frame.columnNumber),
|
||||
text_capture: this.#consoleText(arguments_),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,48 +342,243 @@ export class CdpCaptureEvents {
|
||||
)
|
||||
return;
|
||||
if (this.websockets.length >= this.input.limits.max_websocket_events) {
|
||||
this.dropped += 1;
|
||||
this.completeness.drop("websocket_frames");
|
||||
return;
|
||||
}
|
||||
const response = recordValue(params.response);
|
||||
const payload = stringValue(response?.payloadData) ?? "";
|
||||
const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
|
||||
const decoded = opcode === 1 ? undefined : decodeBase64(payload);
|
||||
if (opcode !== 1 && decoded === undefined)
|
||||
this.completeness.exclude("websocket_frames", "invalid_protocol_value");
|
||||
this.websockets.push({
|
||||
request_id: requestId,
|
||||
direction,
|
||||
opcode,
|
||||
payload_bytes:
|
||||
opcode === 1
|
||||
? Buffer.byteLength(payload)
|
||||
: Buffer.from(payload, "base64").byteLength,
|
||||
opcode === 1 ? Buffer.byteLength(payload) : (decoded?.byteLength ?? 0),
|
||||
payload_shape: this.#websocketShape(payload, opcode),
|
||||
});
|
||||
}
|
||||
|
||||
responseBodyRequestIds(): readonly string[] {
|
||||
if (!this.input.include_json_body_shapes) return [];
|
||||
return [...this.network.values()]
|
||||
.filter((request) => isJsonMediaType(request.mime_type))
|
||||
.map((request) => request.request_id);
|
||||
}
|
||||
|
||||
responseBodyUnavailable(requestId: string): void {
|
||||
this.#updateResponseShape(requestId, null, false);
|
||||
this.completeness.unavailable("json_body_shapes");
|
||||
}
|
||||
|
||||
ingestResponseBody(requestId: string, value: unknown): void {
|
||||
const result = recordValue(value);
|
||||
const body = stringValue(result?.body);
|
||||
if (body === undefined) {
|
||||
this.#invalidResponseBody(requestId);
|
||||
return;
|
||||
}
|
||||
const decoded =
|
||||
result?.base64Encoded === true
|
||||
? decodeBase64(body)?.toString("utf8")
|
||||
: body;
|
||||
if (decoded === undefined) {
|
||||
this.#invalidResponseBody(requestId);
|
||||
return;
|
||||
}
|
||||
const inferred = this.#inferBodyShape(decoded);
|
||||
this.#updateResponseShape(requestId, inferred.shape, inferred.truncated);
|
||||
}
|
||||
|
||||
#invalidResponseBody(requestId: string): void {
|
||||
this.#updateResponseShape(requestId, null, false);
|
||||
this.completeness.exclude("json_body_shapes", "invalid_protocol_value");
|
||||
}
|
||||
|
||||
#requestBodyShape(request: UnknownRecord): NetworkState["body_shapes"] {
|
||||
if (!this.input.include_json_body_shapes)
|
||||
return { status: "not_approved", request: null, response: null };
|
||||
const body = stringValue(request.postData);
|
||||
if (!isJsonContentType(recordValue(request.headers)) || body === undefined)
|
||||
return { status: "unavailable", request: null, response: null };
|
||||
const inferred = this.#inferBodyShape(body);
|
||||
return {
|
||||
status: inferred.truncated
|
||||
? "truncated"
|
||||
: inferred.shape === null
|
||||
? "unavailable"
|
||||
: "included",
|
||||
request: inferred.shape,
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
|
||||
#inferBodyShape(text: string): {
|
||||
readonly shape: JsonShape | null;
|
||||
readonly truncated: boolean;
|
||||
} {
|
||||
const bytes = Buffer.byteLength(text);
|
||||
const remaining =
|
||||
this.input.limits.max_total_json_body_bytes - this.#jsonBodyBytes;
|
||||
if (bytes > this.input.limits.max_json_body_bytes || bytes > remaining) {
|
||||
this.completeness.truncate("json_body_shapes");
|
||||
return { shape: null, truncated: true };
|
||||
}
|
||||
this.#jsonBodyBytes += bytes;
|
||||
const shape = inferJsonShape(text, {
|
||||
maximumBytes: this.input.limits.max_json_body_bytes,
|
||||
maximumNodes: this.input.limits.max_json_shape_nodes,
|
||||
maximumDepth: this.input.limits.max_json_shape_depth,
|
||||
});
|
||||
if (shape?.truncated === true)
|
||||
this.completeness.truncate("json_body_shapes");
|
||||
return { shape, truncated: shape?.truncated === true };
|
||||
}
|
||||
|
||||
#updateResponseShape(
|
||||
requestId: string,
|
||||
response: JsonShape | null,
|
||||
truncated: boolean,
|
||||
): void {
|
||||
const current = this.network.get(requestId);
|
||||
if (current === undefined) return;
|
||||
const request = current.body_shapes.request;
|
||||
const status =
|
||||
truncated || current.body_shapes.status === "truncated"
|
||||
? "truncated"
|
||||
: response !== null
|
||||
? "included"
|
||||
: request !== null
|
||||
? "partial"
|
||||
: "unavailable";
|
||||
this.network.set(requestId, {
|
||||
...current,
|
||||
body_shapes: { status, request, response },
|
||||
});
|
||||
}
|
||||
|
||||
#consoleText(
|
||||
arguments_: readonly UnknownRecord[],
|
||||
): WebPageInspection["console"]["events"][number]["text_capture"] {
|
||||
if (!this.input.include_console_text)
|
||||
return {
|
||||
status: "not_approved",
|
||||
values: [],
|
||||
retained_bytes: 0,
|
||||
truncated_values: 0,
|
||||
};
|
||||
const values: WebPageInspection["console"]["events"][number]["text_capture"]["values"] =
|
||||
[];
|
||||
let retainedBytes = 0;
|
||||
let truncatedValues = 0;
|
||||
for (const [argumentIndex, argument] of arguments_.entries()) {
|
||||
const primitive = consolePrimitive(argument);
|
||||
if (primitive === undefined) continue;
|
||||
const remaining =
|
||||
this.input.limits.max_total_console_text_bytes - this.#consoleTextBytes;
|
||||
if (remaining <= 0) {
|
||||
truncatedValues += 1;
|
||||
continue;
|
||||
}
|
||||
const bounded = boundedSensitiveText(
|
||||
primitive.text,
|
||||
Math.min(this.input.limits.max_console_text_field_bytes, remaining),
|
||||
);
|
||||
values.push({
|
||||
argument_index: argumentIndex,
|
||||
type: primitive.type,
|
||||
text: bounded.text,
|
||||
});
|
||||
retainedBytes += bounded.bytes;
|
||||
this.#consoleTextBytes += bounded.bytes;
|
||||
if (bounded.truncated) truncatedValues += 1;
|
||||
}
|
||||
if (truncatedValues > 0) this.completeness.truncate("console_text");
|
||||
return {
|
||||
status: truncatedValues > 0 ? "truncated" : "included",
|
||||
values,
|
||||
retained_bytes: retainedBytes,
|
||||
truncated_values: truncatedValues,
|
||||
};
|
||||
}
|
||||
|
||||
#websocketShape(
|
||||
payload: string,
|
||||
opcode: number,
|
||||
): WebPageInspection["network"]["websocket_events"][number]["payload_shape"] {
|
||||
if (!this.input.include_websocket_shapes) return null;
|
||||
if (opcode !== 1)
|
||||
return { format: "binary", json_shape: null, truncated: false };
|
||||
const bytes = Buffer.byteLength(payload);
|
||||
const remaining =
|
||||
this.input.limits.max_total_websocket_shape_bytes -
|
||||
this.#websocketShapeBytes;
|
||||
if (
|
||||
bytes > this.input.limits.max_websocket_shape_bytes ||
|
||||
bytes > remaining
|
||||
) {
|
||||
this.completeness.truncate("websocket_shapes");
|
||||
return { format: "text", json_shape: null, truncated: true };
|
||||
}
|
||||
this.#websocketShapeBytes += bytes;
|
||||
const shape = inferJsonShape(payload, {
|
||||
maximumBytes: this.input.limits.max_websocket_shape_bytes,
|
||||
maximumNodes: this.input.limits.max_json_shape_nodes,
|
||||
maximumDepth: this.input.limits.max_json_shape_depth,
|
||||
});
|
||||
if (shape?.truncated === true)
|
||||
this.completeness.truncate("websocket_shapes");
|
||||
return {
|
||||
format: shape === null ? "text" : "json",
|
||||
json_shape: shape,
|
||||
truncated: shape?.truncated === true,
|
||||
};
|
||||
}
|
||||
|
||||
#websocketCreated(params: UnknownRecord): void {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const rawUrl = stringValue(params.url);
|
||||
if (
|
||||
requestId === undefined ||
|
||||
requestId.length > 256 ||
|
||||
rawUrl === undefined
|
||||
)
|
||||
if (requestId === undefined || requestId.length > 256) {
|
||||
this.completeness.exclude(
|
||||
"websocket_connections",
|
||||
"invalid_protocol_value",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (rawUrl === undefined) {
|
||||
this.completeness.exclude(
|
||||
"websocket_connections",
|
||||
"invalid_protocol_value",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
this.completeness.exclude("websocket_connections", "unsupported_url");
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol === "ws:") parsed.protocol = "http:";
|
||||
else if (parsed.protocol === "wss:") parsed.protocol = "https:";
|
||||
else return;
|
||||
else {
|
||||
this.completeness.exclude("websocket_connections", "unsupported_url");
|
||||
return;
|
||||
}
|
||||
if (!this.allowedOrigins.has(parsed.origin)) {
|
||||
this.completeness.exclude("websocket_connections", "disallowed_origin");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.allowedOrigins.has(parsed.origin) &&
|
||||
(this.allowedWebSockets.has(requestId) ||
|
||||
this.allowedWebSockets.size < this.input.limits.max_websocket_events)
|
||||
)
|
||||
this.allowedWebSockets.add(requestId);
|
||||
else if (this.allowedOrigins.has(parsed.origin)) this.dropped += 1;
|
||||
!this.allowedWebSockets.has(requestId) &&
|
||||
this.allowedWebSockets.size >= this.input.limits.max_websocket_events
|
||||
) {
|
||||
this.completeness.drop("websocket_connections");
|
||||
return;
|
||||
}
|
||||
this.allowedWebSockets.add(requestId);
|
||||
}
|
||||
|
||||
#frameNavigated(params: UnknownRecord): void {
|
||||
@@ -296,12 +596,85 @@ export class CdpCaptureEvents {
|
||||
|
||||
const firstCallFrame = (
|
||||
value: UnknownRecord | undefined,
|
||||
): UnknownRecord | undefined => recordsValue(value?.callFrames)[0];
|
||||
|
||||
const initiatorLocation = (
|
||||
initiator: UnknownRecord | undefined,
|
||||
): UnknownRecord | undefined => {
|
||||
const callFrames = recordsValue(value?.callFrames);
|
||||
return callFrames[0];
|
||||
let stack = recordValue(initiator?.stack);
|
||||
for (let depth = 0; depth < 32 && stack !== undefined; depth += 1) {
|
||||
const frame = firstCallFrame(stack);
|
||||
if (frame !== undefined) return frame;
|
||||
stack = recordValue(stack.parent);
|
||||
}
|
||||
return stringValue(initiator?.url) === undefined ? undefined : initiator;
|
||||
};
|
||||
|
||||
const exclusionReasonForUrl = (
|
||||
value: string | undefined,
|
||||
): "disallowed_origin" | "unsupported_url" | "unattributed_origin" =>
|
||||
value === undefined || value === ""
|
||||
? "unattributed_origin"
|
||||
: isHttpUrl(value)
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url";
|
||||
|
||||
const integerOrNull = (value: unknown): number | null => {
|
||||
const number = numberValue(value);
|
||||
return number === undefined ? null : Math.max(0, Math.trunc(number));
|
||||
};
|
||||
|
||||
const isJsonContentType = (headers: UnknownRecord | undefined): boolean => {
|
||||
if (headers === undefined) return false;
|
||||
for (const [name, value] of Object.entries(headers))
|
||||
if (
|
||||
name.toLowerCase() === "content-type" &&
|
||||
isJsonMediaType(stringValue(value))
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isJsonMediaType = (value: string | null | undefined): boolean => {
|
||||
const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
|
||||
return (
|
||||
mediaType === "application/json" || mediaType?.endsWith("+json") === true
|
||||
);
|
||||
};
|
||||
|
||||
const consolePrimitive = (
|
||||
value: UnknownRecord,
|
||||
): { readonly type: string; readonly text: string } | undefined => {
|
||||
const type = stringValue(value.type);
|
||||
switch (type) {
|
||||
case "string":
|
||||
return typeof value.value === "string"
|
||||
? { type, text: value.value }
|
||||
: undefined;
|
||||
case "boolean":
|
||||
return typeof value.value === "boolean"
|
||||
? { type, text: String(value.value) }
|
||||
: undefined;
|
||||
case "number":
|
||||
return typeof value.value === "number"
|
||||
? { type, text: String(value.value) }
|
||||
: typeof value.unserializableValue === "string"
|
||||
? { type, text: value.unserializableValue }
|
||||
: undefined;
|
||||
case "bigint":
|
||||
return typeof value.unserializableValue === "string"
|
||||
? { type, text: value.unserializableValue }
|
||||
: undefined;
|
||||
case "undefined":
|
||||
return { type, text: "undefined" };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const BASE64 = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u;
|
||||
|
||||
const decodeBase64 = (value: string): Buffer | undefined =>
|
||||
value.length % 4 === 0 && BASE64.test(value)
|
||||
? Buffer.from(value, "base64")
|
||||
: undefined;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
BrowserObservationError,
|
||||
} from "../domain/errors.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
|
||||
export type UnknownRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
@@ -64,11 +63,9 @@ export const allowedSanitizedUrl = (
|
||||
|
||||
export const sourceResult = (
|
||||
source: string,
|
||||
): { included: true; sha256: string; bytes: number; content: string } => ({
|
||||
): { included: true; artifact: ReturnType<typeof createWebTextArtifact> } => ({
|
||||
included: true,
|
||||
sha256: createHash("sha256").update(source).digest("hex"),
|
||||
bytes: Buffer.byteLength(source),
|
||||
content: source,
|
||||
artifact: createWebTextArtifact(source, "text/javascript"),
|
||||
});
|
||||
|
||||
export const sourceExcluded = (
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import type {
|
||||
ElectronPageInspection,
|
||||
InspectElectronPageInput,
|
||||
} from "../domain/electronObservation.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import {
|
||||
boundedText,
|
||||
delayWithCancellation,
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
requiredRecord,
|
||||
stringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import {
|
||||
authorizedElectronFile,
|
||||
canonicalElectronRoots,
|
||||
} from "./ElectronFileScope.js";
|
||||
|
||||
interface ElectronContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: InspectElectronPageInput;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
}
|
||||
|
||||
interface ScriptDraft {
|
||||
readonly scriptId: string;
|
||||
readonly rawUrl: string;
|
||||
readonly hash: string;
|
||||
readonly length: number;
|
||||
readonly isModule: boolean;
|
||||
readonly language: string | null;
|
||||
}
|
||||
|
||||
/** Passively inspect one root-confined Electron file page. */
|
||||
export const inspectCdpElectronPage = async (
|
||||
context: ElectronContext,
|
||||
): Promise<ElectronPageInspection> => {
|
||||
const roots = await canonicalElectronRoots(context.input.allowed_file_roots);
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const scripts: ScriptDraft[] = [];
|
||||
let mainFrameId: string | undefined;
|
||||
let navigationDuringCapture = false;
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
if (event.sessionId !== context.sessionId) return;
|
||||
ingestScript(event, scripts, completeness);
|
||||
if (mainFrameId !== undefined && navigatedFrameId(event) === mainFrameId)
|
||||
navigationDuringCapture = true;
|
||||
});
|
||||
const startedAt = new Date().toISOString();
|
||||
try {
|
||||
await report(context.progress, 1, "Authorizing Electron file frames");
|
||||
await context.connection.send(
|
||||
"Page.enable",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const before = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const frames = await captureFrames(
|
||||
before,
|
||||
roots,
|
||||
context.input.limits.max_frames,
|
||||
completeness,
|
||||
);
|
||||
const main = frames[0];
|
||||
if (main === undefined)
|
||||
throw new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"target_not_allowed",
|
||||
);
|
||||
mainFrameId = main.frame_id;
|
||||
await context.connection.send(
|
||||
"Debugger.enable",
|
||||
{ maxScriptsCacheSize: 4 * 1_024 * 1_024 },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
await delayWithCancellation(context.input.observation_ms, context.signal);
|
||||
await report(context.progress, 2, "Capturing Electron structure");
|
||||
const resources = await captureResources(context, roots, completeness);
|
||||
const dom = await captureDom(context, roots, completeness);
|
||||
const normalizedScripts = await captureScripts(
|
||||
context,
|
||||
roots,
|
||||
scripts,
|
||||
completeness,
|
||||
);
|
||||
const after = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const afterMain = await mainFilePath(after, roots);
|
||||
if (afterMain === undefined)
|
||||
throw new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"target_not_allowed",
|
||||
);
|
||||
if (navigationDuringCapture || afterMain !== main.file_path)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_changed");
|
||||
await report(context.progress, 3, "Electron inspection complete");
|
||||
return {
|
||||
schema_version: 1,
|
||||
browser: context.discovery.version,
|
||||
target: {
|
||||
target_id: context.target.id,
|
||||
type: context.target.type,
|
||||
title: context.target.title.slice(0, 16_384),
|
||||
file_path: main.file_path,
|
||||
attached: context.target.attached,
|
||||
},
|
||||
capture_window: {
|
||||
started_at: startedAt,
|
||||
ended_at: new Date().toISOString(),
|
||||
observation_ms: context.input.observation_ms,
|
||||
},
|
||||
completeness: completeness.snapshot(),
|
||||
frames,
|
||||
dom,
|
||||
scripts: normalizedScripts,
|
||||
resources,
|
||||
limitations: [
|
||||
"Only canonical file paths contained by an approved root are retained.",
|
||||
"REA does not evaluate renderer JavaScript, invoke Electron APIs, navigate, click, or close the page.",
|
||||
"Script contents require separate source-capture approval and remain byte bounded.",
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
const navigatedFrameId = (event: CdpEvent): string | undefined => {
|
||||
const params = recordValue(event.params);
|
||||
if (params === undefined) return undefined;
|
||||
if (event.method === "Page.frameNavigated")
|
||||
return stringValue(recordValue(params.frame)?.id);
|
||||
if (event.method === "Page.navigatedWithinDocument")
|
||||
return stringValue(params.frameId);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const ingestScript = (
|
||||
event: CdpEvent,
|
||||
scripts: ScriptDraft[],
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): void => {
|
||||
if (event.method !== "Debugger.scriptParsed") return;
|
||||
const value = recordValue(event.params);
|
||||
const scriptId = stringValue(value?.scriptId);
|
||||
const rawUrl = stringValue(value?.url);
|
||||
if (scriptId === undefined || scriptId.length > 256) {
|
||||
completeness.exclude("scripts", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
if (rawUrl === undefined || rawUrl === "") {
|
||||
completeness.exclude("scripts", "unattributed_origin");
|
||||
return;
|
||||
}
|
||||
if (scripts.length >= 2_000) {
|
||||
completeness.drop("scripts");
|
||||
return;
|
||||
}
|
||||
scripts.push({
|
||||
scriptId,
|
||||
rawUrl,
|
||||
hash: (stringValue(value?.hash) ?? "").slice(0, 512),
|
||||
length: nonnegativeInteger(value?.length),
|
||||
isModule: value?.isModule === true,
|
||||
language: boundedText(value?.scriptLanguage, 100),
|
||||
});
|
||||
};
|
||||
|
||||
const captureFrames = async (
|
||||
result: unknown,
|
||||
roots: readonly string[],
|
||||
maximum: number,
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): Promise<ElectronPageInspection["frames"]> => {
|
||||
const frameTree = recordValue(requiredRecord(result).frameTree);
|
||||
if (frameTree === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const pending: Array<{
|
||||
readonly tree: UnknownRecord;
|
||||
readonly parent: string | null;
|
||||
}> = [{ tree: frameTree, parent: null }];
|
||||
const frames: ElectronPageInspection["frames"] = [];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.shift();
|
||||
if (current === undefined) break;
|
||||
const frame = recordValue(current.tree.frame);
|
||||
const frameId = stringValue(frame?.id);
|
||||
const path = await authorizedElectronFile(
|
||||
stringValue(frame?.url) ?? "",
|
||||
roots,
|
||||
);
|
||||
if (frameId === undefined || path === undefined) {
|
||||
completeness.exclude("frames", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
if (frames.length >= maximum) {
|
||||
completeness.truncate("frames");
|
||||
continue;
|
||||
}
|
||||
frames.push({
|
||||
frame_id: frameId.slice(0, 256),
|
||||
parent_frame_id: current.parent,
|
||||
file_path: path,
|
||||
});
|
||||
for (const child of recordsValue(current.tree.childFrames))
|
||||
pending.push({ tree: child, parent: frameId.slice(0, 256) });
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
const captureResources = async (
|
||||
context: ElectronContext,
|
||||
roots: readonly string[],
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): Promise<ElectronPageInspection["resources"]> => {
|
||||
const result = await context.connection.send(
|
||||
"Page.getResourceTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const root = recordValue(requiredRecord(result).frameTree);
|
||||
if (root === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const pending = [root];
|
||||
const resources: ElectronPageInspection["resources"] = [];
|
||||
const seen = new Set<string>();
|
||||
while (pending.length > 0) {
|
||||
const tree = pending.shift();
|
||||
if (tree === undefined) break;
|
||||
for (const resource of recordsValue(tree.resources)) {
|
||||
const path = await authorizedElectronFile(
|
||||
stringValue(resource.url) ?? "",
|
||||
roots,
|
||||
);
|
||||
if (path === undefined) {
|
||||
completeness.exclude("resources", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
const item = {
|
||||
file_path: path,
|
||||
type: (stringValue(resource.type) ?? "Other").slice(0, 100),
|
||||
mime_type: (stringValue(resource.mimeType) ?? "").slice(0, 256),
|
||||
content_size:
|
||||
numberValue(resource.contentSize) === undefined
|
||||
? null
|
||||
: Math.max(0, numberValue(resource.contentSize) ?? 0),
|
||||
};
|
||||
const resourceKey = `electron_resource_${digest(item)}`;
|
||||
if (seen.has(resourceKey)) continue;
|
||||
seen.add(resourceKey);
|
||||
if (resources.length >= context.input.limits.max_resources) {
|
||||
completeness.truncate("resources");
|
||||
continue;
|
||||
}
|
||||
resources.push({
|
||||
resource_key: resourceKey,
|
||||
...item,
|
||||
});
|
||||
}
|
||||
pending.push(...recordsValue(tree.childFrames));
|
||||
}
|
||||
return resources.sort((left, right) =>
|
||||
left.resource_key.localeCompare(right.resource_key),
|
||||
);
|
||||
};
|
||||
|
||||
const captureDom = async (
|
||||
context: ElectronContext,
|
||||
roots: readonly string[],
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): Promise<ElectronPageInspection["dom"]> => {
|
||||
const result = requiredRecord(
|
||||
await context.connection.send(
|
||||
"DOMSnapshot.captureSnapshot",
|
||||
{ computedStyles: [], includePaintOrder: false, includeDOMRects: false },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
),
|
||||
);
|
||||
const strings = Array.isArray(result.strings)
|
||||
? result.strings.map((value) => (typeof value === "string" ? value : ""))
|
||||
: [];
|
||||
const nodes: ElectronPageInspection["dom"]["nodes"] = [];
|
||||
let total = 0;
|
||||
for (const document of recordsValue(result.documents)) {
|
||||
const documentUrl = stringAt(strings, document.documentURL);
|
||||
if ((await authorizedElectronFile(documentUrl, roots)) === undefined) {
|
||||
completeness.exclude("dom", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
const nodeData = recordValue(document.nodes);
|
||||
const nodeTypes = arrayValue(nodeData?.nodeType);
|
||||
const nodeNames = arrayValue(nodeData?.nodeName);
|
||||
const nodeValues = arrayValue(nodeData?.nodeValue);
|
||||
const parents = arrayValue(nodeData?.parentIndex);
|
||||
const attributes = arrayValue(nodeData?.attributes);
|
||||
const baseIndex = nodes.length;
|
||||
total += nodeTypes.length;
|
||||
for (let index = 0; index < nodeTypes.length; index += 1) {
|
||||
if (nodes.length >= context.input.limits.max_dom_nodes) continue;
|
||||
const attributeIndexes = arrayValue(attributes[index]);
|
||||
const parent = integer(parents[index], -1);
|
||||
nodes.push({
|
||||
index: nodes.length,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
node_type: integer(nodeTypes[index], 0),
|
||||
node_name: stringAt(strings, nodeNames[index]).slice(0, 1_024),
|
||||
node_value_length: Buffer.byteLength(
|
||||
stringAt(strings, nodeValues[index]),
|
||||
),
|
||||
attribute_names: attributeIndexes
|
||||
.filter((_value, attributeIndex) => attributeIndex % 2 === 0)
|
||||
.map((value) => stringAt(strings, value).slice(0, 1_024))
|
||||
.slice(0, 256),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (total > nodes.length) completeness.truncate("dom");
|
||||
return { total_nodes: total, nodes };
|
||||
};
|
||||
|
||||
const captureScripts = async (
|
||||
context: ElectronContext,
|
||||
roots: readonly string[],
|
||||
scripts: readonly ScriptDraft[],
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): Promise<ElectronPageInspection["scripts"]> => {
|
||||
let total = 0;
|
||||
let sourceBytes = 0;
|
||||
const items: ElectronPageInspection["scripts"]["items"] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const script of scripts) {
|
||||
const path = await authorizedElectronFile(script.rawUrl, roots);
|
||||
if (path === undefined) {
|
||||
completeness.exclude("scripts", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
const identity = {
|
||||
file_path: path,
|
||||
cdp_hash: script.hash,
|
||||
length: script.length,
|
||||
is_module: script.isModule,
|
||||
language: script.language,
|
||||
};
|
||||
const scriptKey = `electron_script_${digest(identity)}`;
|
||||
if (seen.has(scriptKey)) continue;
|
||||
seen.add(scriptKey);
|
||||
total += 1;
|
||||
if (items.length >= context.input.limits.max_scripts) {
|
||||
completeness.drop("scripts");
|
||||
continue;
|
||||
}
|
||||
let source: ElectronPageInspection["scripts"]["items"][number]["source"] = {
|
||||
included: false,
|
||||
reason: "source capture was not approved",
|
||||
};
|
||||
if (context.input.include_script_sources) {
|
||||
if (
|
||||
script.length > context.input.limits.max_script_source_bytes ||
|
||||
sourceBytes + script.length >
|
||||
context.input.limits.max_total_script_source_bytes
|
||||
) {
|
||||
source = {
|
||||
included: false,
|
||||
reason: "script source byte limit reached",
|
||||
};
|
||||
completeness.truncate("script_sources");
|
||||
} else {
|
||||
const result = requiredRecord(
|
||||
await context.connection.send(
|
||||
"Debugger.getScriptSource",
|
||||
{ scriptId: script.scriptId },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
),
|
||||
);
|
||||
const text = stringValue(result.scriptSource) ?? "";
|
||||
const bytes = Buffer.byteLength(text);
|
||||
if (
|
||||
bytes > context.input.limits.max_script_source_bytes ||
|
||||
sourceBytes + bytes >
|
||||
context.input.limits.max_total_script_source_bytes
|
||||
) {
|
||||
source = {
|
||||
included: false,
|
||||
reason: "script source byte limit reached",
|
||||
};
|
||||
completeness.truncate("script_sources");
|
||||
} else {
|
||||
source = {
|
||||
included: true,
|
||||
artifact: createWebTextArtifact(text, "text/javascript"),
|
||||
};
|
||||
sourceBytes += bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
script_key: scriptKey,
|
||||
...identity,
|
||||
source,
|
||||
});
|
||||
}
|
||||
if (!context.input.include_script_sources)
|
||||
completeness.exclude("script_sources", "not_approved", total);
|
||||
return {
|
||||
total,
|
||||
items: items.sort((left, right) =>
|
||||
left.script_key.localeCompare(right.script_key),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const mainFilePath = async (
|
||||
result: unknown,
|
||||
roots: readonly string[],
|
||||
): Promise<string | undefined> => {
|
||||
const frameTree = recordValue(requiredRecord(result).frameTree);
|
||||
const frame = recordValue(frameTree?.frame);
|
||||
return await authorizedElectronFile(stringValue(frame?.url) ?? "", roots);
|
||||
};
|
||||
|
||||
const arrayValue = (value: unknown): readonly unknown[] =>
|
||||
Array.isArray(value) ? value : [];
|
||||
|
||||
const stringAt = (strings: readonly string[], value: unknown): string =>
|
||||
strings[integer(value, -1)] ?? "";
|
||||
|
||||
const integer = (value: unknown, fallback: number): number => {
|
||||
const number = numberValue(value);
|
||||
return number === undefined ? fallback : Math.trunc(number);
|
||||
};
|
||||
|
||||
const nonnegativeInteger = (value: unknown): number =>
|
||||
Math.max(0, integer(value, 0));
|
||||
|
||||
const digest = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined) throw new TypeError("Expected canonical JSON");
|
||||
return createHash("sha256").update(encoded).digest("hex");
|
||||
};
|
||||
|
||||
const report = async (
|
||||
progress: ProgressReporter | undefined,
|
||||
completed: number,
|
||||
message: string,
|
||||
): Promise<void> =>
|
||||
await progress?.report({
|
||||
phase: "browser_observation",
|
||||
completed,
|
||||
total: 3,
|
||||
message,
|
||||
...(completed === 3 ? { terminal: true } : {}),
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { ElectronObservationPort } from "../application/ElectronObservationPort.js";
|
||||
import {
|
||||
electronPageInspectionSchema,
|
||||
electronTargetListSchema,
|
||||
type ElectronPageInspection,
|
||||
type ElectronTargetList,
|
||||
type InspectElectronPageInput,
|
||||
type ListElectronTargetsInput,
|
||||
} from "../domain/electronObservation.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
BrowserObservationError,
|
||||
ProviderAdapterError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import { discoverCdpEndpoint, type CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
|
||||
import {
|
||||
authorizedElectronFile,
|
||||
canonicalElectronRoots,
|
||||
} from "./ElectronFileScope.js";
|
||||
|
||||
const IDENTITY: ProviderIdentity = {
|
||||
id: "rea-cdp-electron",
|
||||
name: "REA Electron file-page CDP observation provider",
|
||||
version: "1",
|
||||
};
|
||||
|
||||
/** Passive Electron provider with canonical file-root confinement. */
|
||||
export class CdpElectronProvider implements ElectronObservationPort {
|
||||
identity(): ProviderIdentity {
|
||||
return IDENTITY;
|
||||
}
|
||||
|
||||
async listTargets(
|
||||
input: ListElectronTargetsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<ElectronTargetList, AnalysisError>> {
|
||||
try {
|
||||
const roots = await canonicalElectronRoots(input.allowed_file_roots);
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"list_browser_targets",
|
||||
options.signal,
|
||||
);
|
||||
const allowed = [];
|
||||
let outsideRoot = 0;
|
||||
let unsupportedUrl = 0;
|
||||
let nonPage = 0;
|
||||
for (const target of discovery.targets) {
|
||||
if (target.type !== "page") {
|
||||
nonPage += 1;
|
||||
continue;
|
||||
}
|
||||
const path = await authorizedElectronFile(target.url, roots);
|
||||
if (path === undefined) {
|
||||
if (isFileUrl(target.url)) outsideRoot += 1;
|
||||
else unsupportedUrl += 1;
|
||||
continue;
|
||||
}
|
||||
allowed.push({
|
||||
target_id: target.id,
|
||||
type: target.type,
|
||||
title: target.title.slice(0, 16_384),
|
||||
file_path: path,
|
||||
attached: target.attached,
|
||||
});
|
||||
}
|
||||
allowed.sort((left, right) =>
|
||||
left.target_id.localeCompare(right.target_id),
|
||||
);
|
||||
const items = allowed.slice(input.offset, input.offset + input.limit);
|
||||
const next = input.offset + items.length;
|
||||
return ok(
|
||||
electronTargetListSchema.parse({
|
||||
schema_version: 1,
|
||||
browser: discovery.version,
|
||||
targets: {
|
||||
items,
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
total: allowed.length,
|
||||
next_offset: next < allowed.length ? next : null,
|
||||
has_more: next < allowed.length,
|
||||
},
|
||||
excluded: {
|
||||
outside_root: outsideRoot,
|
||||
unsupported_url: unsupportedUrl,
|
||||
non_page: nonPage,
|
||||
},
|
||||
limitations: [
|
||||
"Only page targets whose canonical file path is contained by an approved root are listed.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "list_electron_targets"));
|
||||
}
|
||||
}
|
||||
|
||||
async inspectPage(
|
||||
input: InspectElectronPageInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<ElectronPageInspection, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const roots = await canonicalElectronRoots(input.allowed_file_roots);
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
options.signal,
|
||||
);
|
||||
const target = await authorizeTarget(
|
||||
discovery.targets,
|
||||
input.target_id,
|
||||
roots,
|
||||
);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
return ok(
|
||||
electronPageInspectionSchema.parse(
|
||||
await inspectCdpElectronPage({
|
||||
connection,
|
||||
sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "inspect_electron_page"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined) await cleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authorizeTarget = async (
|
||||
targets: readonly CdpEndpointTarget[],
|
||||
targetId: string,
|
||||
roots: readonly string[],
|
||||
): Promise<CdpEndpointTarget> => {
|
||||
const target = targets.find(({ id }) => id === targetId);
|
||||
if (target === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_found");
|
||||
if (
|
||||
target.type !== "page" ||
|
||||
(await authorizedElectronFile(target.url, roots)) === undefined
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
return target;
|
||||
};
|
||||
|
||||
const attachedSessionId = (value: unknown): string => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("sessionId" in value) ||
|
||||
typeof value.sessionId !== "string" ||
|
||||
value.sessionId.length > 256
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return value.sessionId;
|
||||
};
|
||||
|
||||
const cleanup = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
for (const method of ["Debugger.disable", "Page.disable"])
|
||||
try {
|
||||
await connection.send(method, {}, sessionId);
|
||||
} catch {
|
||||
// Detach remains the final non-destructive cleanup boundary.
|
||||
}
|
||||
try {
|
||||
await connection.send("Target.detachFromTarget", { sessionId });
|
||||
} catch {
|
||||
// Closing REA's socket never closes the Electron target.
|
||||
}
|
||||
};
|
||||
|
||||
const isFileUrl = (value: string): boolean => {
|
||||
try {
|
||||
return new URL(value).protocol === "file:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const providerError = (
|
||||
cause: unknown,
|
||||
operation: BrowserObservationOperation,
|
||||
): AnalysisError =>
|
||||
cause instanceof BrowserObservationError && cause.operation !== operation
|
||||
? new BrowserObservationError(operation, cause.reason, { cause })
|
||||
: cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError(IDENTITY.id, operation, { cause });
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AnalysisError,
|
||||
AnalysisTimeoutError,
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
|
||||
const HTTP_TIMEOUT_MS = 5_000;
|
||||
@@ -45,12 +46,10 @@ export interface CdpEndpointDiscovery {
|
||||
readonly targets: readonly CdpEndpointTarget[];
|
||||
}
|
||||
|
||||
type BrowserOperation = "list_browser_targets" | "inspect_web_page";
|
||||
|
||||
/** Read bounded CDP discovery endpoints without following redirects. */
|
||||
export const discoverCdpEndpoint = async (
|
||||
endpoint: string,
|
||||
operation: BrowserOperation,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CdpEndpointDiscovery> => {
|
||||
const versionInput = await readJson(
|
||||
@@ -95,7 +94,7 @@ export const discoverCdpEndpoint = async (
|
||||
const parseEndpointValue = <Output>(
|
||||
schema: z.ZodType<Output>,
|
||||
input: unknown,
|
||||
operation: BrowserOperation,
|
||||
operation: BrowserObservationOperation,
|
||||
): Output => {
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
@@ -108,7 +107,7 @@ const parseEndpointValue = <Output>(
|
||||
const safeBrowserWebSocketUrl = (
|
||||
endpoint: string,
|
||||
reported: string,
|
||||
operation: BrowserOperation,
|
||||
operation: BrowserObservationOperation,
|
||||
): string => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -136,7 +135,7 @@ const safeBrowserWebSocketUrl = (
|
||||
const readJson = async (
|
||||
url: URL,
|
||||
maximumBytes: number,
|
||||
operation: BrowserOperation,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> =>
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -196,7 +195,7 @@ const readJson = async (
|
||||
|
||||
const endpointFailure = (
|
||||
cause: unknown,
|
||||
operation: BrowserOperation,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import type {
|
||||
ObserveWebSessionInput,
|
||||
WebObservationSession,
|
||||
} from "../domain/browserSession.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { CdpConnection, type CdpEvent } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
delayWithCancellation,
|
||||
isHttpUrl,
|
||||
numberValue,
|
||||
recordValue,
|
||||
stringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
|
||||
|
||||
interface ObservationContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: ObserveWebSessionInput;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
}
|
||||
|
||||
type EndReason = WebObservationSession["window"]["end_reason"];
|
||||
type TimelineEvent = WebObservationSession["timeline"][number];
|
||||
|
||||
/** Observe external user actions without rejecting approved same-origin navigation. */
|
||||
export const observeCdpSession = async (
|
||||
context: ObservationContext,
|
||||
): Promise<WebObservationSession> => {
|
||||
const allowedOrigins = new Set(context.input.allowed_origins);
|
||||
const initial = await authorizedFrame(context, allowedOrigins);
|
||||
const initialUrl = mainFrameUrl(initial) ?? "";
|
||||
const mainFrameId = frameId(initial);
|
||||
if (mainFrameId === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const capture = new TimelineCapture(
|
||||
context.input.max_timeline_events,
|
||||
allowedOrigins,
|
||||
mainFrameId,
|
||||
initialUrl,
|
||||
);
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
if (event.sessionId === context.sessionId) capture.ingest(event);
|
||||
});
|
||||
try {
|
||||
await context.connection.send(
|
||||
"Page.enable",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
await context.connection.send(
|
||||
"Network.enable",
|
||||
{ maxTotalBufferSize: 1_024 * 1_024 },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
await context.connection.send(
|
||||
"Page.setLifecycleEventsEnabled",
|
||||
{ enabled: true },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const armedAt = new Date().toISOString();
|
||||
await context.progress?.report({
|
||||
phase: "browser_observation",
|
||||
completed: 1,
|
||||
total: 2,
|
||||
message:
|
||||
"Browser observation armed; perform the approved user action now",
|
||||
});
|
||||
let endReason = await waitForWindow(
|
||||
context.input.observation_ms,
|
||||
capture,
|
||||
context.signal,
|
||||
);
|
||||
if (endReason === "window_elapsed")
|
||||
endReason = await captureFinalUrl(context, capture, allowedOrigins);
|
||||
await context.progress?.report({
|
||||
phase: "browser_observation",
|
||||
completed: 2,
|
||||
total: 2,
|
||||
message: "Browser observation window ended",
|
||||
terminal: true,
|
||||
});
|
||||
return {
|
||||
schema_version: 1,
|
||||
browser: context.discovery.version,
|
||||
target: {
|
||||
target_id: context.target.id,
|
||||
initial_url: allowedSanitizedUrl(initialUrl, allowedOrigins)?.url ?? "",
|
||||
final_url: capture.finalUrl,
|
||||
},
|
||||
window: {
|
||||
armed_at: armedAt,
|
||||
ended_at: new Date().toISOString(),
|
||||
requested_ms: context.input.observation_ms,
|
||||
end_reason: endReason,
|
||||
},
|
||||
timeline: capture.timeline,
|
||||
completeness: capture.completeness.snapshot(),
|
||||
limitations: [
|
||||
"The timeline begins after REA attaches; earlier navigation and network activity is unavailable.",
|
||||
"Only navigation metadata is retained; headers, bodies, cookies, console values, and payloads are not captured by this tool.",
|
||||
"An out-of-policy destination ends collection immediately and is recorded without its URL.",
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
class TimelineCapture {
|
||||
readonly timeline: TimelineEvent[] = [];
|
||||
readonly completeness = new CdpCaptureCompleteness(["timeline"]);
|
||||
finalUrl: string | null;
|
||||
#sequence = 0;
|
||||
#pendingReload = false;
|
||||
#endReason: EndReason | undefined;
|
||||
readonly #listeners = new Set<(reason: EndReason) => void>();
|
||||
|
||||
constructor(
|
||||
private readonly maximum: number,
|
||||
private readonly allowedOrigins: ReadonlySet<string>,
|
||||
private readonly mainFrameId: string,
|
||||
initialUrl: string,
|
||||
) {
|
||||
this.finalUrl =
|
||||
allowedSanitizedUrl(initialUrl, allowedOrigins)?.url ?? null;
|
||||
}
|
||||
|
||||
ingest(event: CdpEvent): void {
|
||||
if (this.#endReason !== undefined) return;
|
||||
const params = recordValue(event.params);
|
||||
if (params === undefined) return;
|
||||
switch (event.method) {
|
||||
case "Page.frameRequestedNavigation":
|
||||
this.#navigationRequested(params);
|
||||
break;
|
||||
case "Page.frameNavigated":
|
||||
this.#navigationCommitted(params);
|
||||
break;
|
||||
case "Page.navigatedWithinDocument":
|
||||
this.#sameDocument(params);
|
||||
break;
|
||||
case "Network.requestWillBeSent":
|
||||
this.#redirect(params);
|
||||
break;
|
||||
case "Network.loadingFailed":
|
||||
this.#loadingFailed(params);
|
||||
break;
|
||||
case "Page.lifecycleEvent":
|
||||
this.#lifecycle(params);
|
||||
break;
|
||||
case "Inspector.targetCrashed":
|
||||
case "Target.detachedFromTarget":
|
||||
this.#add("target_terminated", params, null, null, "target_terminated");
|
||||
this.end("target_terminated");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onEnd(listener: (reason: EndReason) => void): () => void {
|
||||
if (this.#endReason !== undefined) listener(this.#endReason);
|
||||
else this.#listeners.add(listener);
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
end(reason: EndReason): void {
|
||||
if (this.#endReason !== undefined) return;
|
||||
this.#endReason = reason;
|
||||
for (const listener of this.#listeners) listener(reason);
|
||||
this.#listeners.clear();
|
||||
}
|
||||
|
||||
setFinalUrl(rawUrl: string): void {
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.finalUrl = destination.scope === "approved" ? destination.url : null;
|
||||
}
|
||||
|
||||
#navigationRequested(params: UnknownRecord): void {
|
||||
if (stringValue(params.frameId) !== this.mainFrameId) return;
|
||||
const reason = safeDetail(params.reason);
|
||||
this.#pendingReload = reason === "reload";
|
||||
this.#add(
|
||||
"navigation_requested",
|
||||
params,
|
||||
stringValue(params.url),
|
||||
null,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
#navigationCommitted(params: UnknownRecord): void {
|
||||
const frame = recordValue(params.frame);
|
||||
if (stringValue(frame?.id) !== this.mainFrameId) return;
|
||||
const rawUrl = stringValue(frame?.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add(
|
||||
this.#pendingReload ? "same_origin_reload" : "navigation_committed",
|
||||
{ ...params, frameId: frame?.id, loaderId: frame?.loaderId },
|
||||
rawUrl,
|
||||
null,
|
||||
safeDetail(frame?.transitionType),
|
||||
);
|
||||
this.#pendingReload = false;
|
||||
if (destination.scope === "approved") this.finalUrl = destination.url;
|
||||
else {
|
||||
this.finalUrl = null;
|
||||
this.completeness.exclude("timeline", "out_of_target_scope");
|
||||
this.end("target_left_scope");
|
||||
}
|
||||
}
|
||||
|
||||
#sameDocument(params: UnknownRecord): void {
|
||||
if (stringValue(params.frameId) !== this.mainFrameId) return;
|
||||
const rawUrl = stringValue(params.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add("same_document_navigation", params, rawUrl, null, null);
|
||||
if (destination.scope === "approved") this.finalUrl = destination.url;
|
||||
else {
|
||||
this.finalUrl = null;
|
||||
this.completeness.exclude("timeline", "out_of_target_scope");
|
||||
this.end("target_left_scope");
|
||||
}
|
||||
}
|
||||
|
||||
#redirect(params: UnknownRecord): void {
|
||||
if (
|
||||
stringValue(params.frameId) !== this.mainFrameId ||
|
||||
recordValue(params.redirectResponse) === undefined
|
||||
)
|
||||
return;
|
||||
const request = recordValue(params.request);
|
||||
const rawUrl = stringValue(request?.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add(
|
||||
"redirect",
|
||||
params,
|
||||
rawUrl,
|
||||
stringValue(params.requestId) ?? null,
|
||||
null,
|
||||
);
|
||||
if (destination.scope !== "approved") {
|
||||
this.completeness.exclude("timeline", "out_of_target_scope");
|
||||
this.end("target_left_scope");
|
||||
}
|
||||
}
|
||||
|
||||
#loadingFailed(params: UnknownRecord): void {
|
||||
const frame = stringValue(params.frameId);
|
||||
if (frame !== undefined && frame !== this.mainFrameId) return;
|
||||
this.#add(
|
||||
"load_failed",
|
||||
params,
|
||||
null,
|
||||
stringValue(params.requestId) ?? null,
|
||||
safeNetworkError(params.errorText),
|
||||
);
|
||||
}
|
||||
|
||||
#lifecycle(params: UnknownRecord): void {
|
||||
if (stringValue(params.frameId) !== this.mainFrameId) return;
|
||||
this.#add("lifecycle", params, null, null, safeDetail(params.name));
|
||||
}
|
||||
|
||||
#add(
|
||||
type: TimelineEvent["type"],
|
||||
params: UnknownRecord,
|
||||
rawUrl: string | null | undefined,
|
||||
requestId: string | null,
|
||||
detail: string | null,
|
||||
): void {
|
||||
const destination =
|
||||
rawUrl == null ? null : scopedUrl(rawUrl, this.allowedOrigins);
|
||||
if (this.timeline.length >= this.maximum) {
|
||||
this.completeness.drop("timeline_events");
|
||||
return;
|
||||
}
|
||||
this.#sequence += 1;
|
||||
this.timeline.push({
|
||||
sequence: this.#sequence,
|
||||
type,
|
||||
timestamp: Math.max(0, numberValue(params.timestamp) ?? 0),
|
||||
frame_id: boundedId(params.frameId),
|
||||
loader_id: boundedId(params.loaderId),
|
||||
request_id: requestId?.slice(0, 256) ?? null,
|
||||
url: destination?.scope === "approved" ? destination.url : null,
|
||||
destination_scope: destination?.scope ?? null,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const waitForWindow = async (
|
||||
durationMs: number,
|
||||
capture: TimelineCapture,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EndReason> =>
|
||||
await new Promise<EndReason>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let removeEnd = (): void => {};
|
||||
const finish = (reason: EndReason): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
removeEnd();
|
||||
resolve(reason);
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
removeEnd();
|
||||
reject(signal?.reason ?? new Error("browser observation cancelled"));
|
||||
};
|
||||
const timer = setTimeout(() => finish("window_elapsed"), durationMs);
|
||||
removeEnd = capture.onEnd(finish);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) onAbort();
|
||||
});
|
||||
|
||||
const authorizedFrame = async (
|
||||
context: ObservationContext,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): Promise<unknown> => {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const result = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const url = mainFrameUrl(result);
|
||||
if (allowedSanitizedUrl(url, allowedOrigins) !== undefined) return result;
|
||||
if (isHttpUrl(url))
|
||||
throw new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"target_not_allowed",
|
||||
);
|
||||
await delayWithCancellation(25, context.signal);
|
||||
}
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
};
|
||||
|
||||
const captureFinalUrl = async (
|
||||
context: ObservationContext,
|
||||
capture: TimelineCapture,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): Promise<EndReason> => {
|
||||
const result = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const url = mainFrameUrl(result);
|
||||
if (
|
||||
url !== undefined &&
|
||||
allowedSanitizedUrl(url, allowedOrigins) !== undefined
|
||||
) {
|
||||
capture.setFinalUrl(url);
|
||||
return "window_elapsed";
|
||||
}
|
||||
capture.finalUrl = null;
|
||||
capture.completeness.exclude(
|
||||
"timeline",
|
||||
url === undefined ? "invalid_protocol_value" : "out_of_target_scope",
|
||||
);
|
||||
return "target_left_scope";
|
||||
};
|
||||
|
||||
const frameId = (result: unknown): string | undefined =>
|
||||
stringValue(
|
||||
recordValue(recordValue(recordValue(result)?.frameTree)?.frame)?.id,
|
||||
);
|
||||
|
||||
const scopedUrl = (
|
||||
value: string | undefined,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): {
|
||||
readonly url: string | null;
|
||||
readonly scope: "approved" | "outside_policy" | "unsupported";
|
||||
} => {
|
||||
const allowed = allowedSanitizedUrl(value, allowedOrigins);
|
||||
if (allowed !== undefined) return { url: allowed.url, scope: "approved" };
|
||||
return isHttpUrl(value)
|
||||
? { url: null, scope: "outside_policy" }
|
||||
: { url: null, scope: "unsupported" };
|
||||
};
|
||||
|
||||
const boundedId = (value: unknown): string | null =>
|
||||
stringValue(value)?.slice(0, 256) ?? null;
|
||||
|
||||
const safeDetail = (value: unknown): string | null => {
|
||||
const detail = stringValue(value)?.toLowerCase();
|
||||
return detail !== undefined && /^[a-z0-9_.:-]{1,100}$/u.test(detail)
|
||||
? detail
|
||||
: null;
|
||||
};
|
||||
|
||||
const safeNetworkError = (value: unknown): string | null => {
|
||||
const error = stringValue(value);
|
||||
return error !== undefined && /^net::ERR_[A-Z0-9_]{1,100}$/u.test(error)
|
||||
? error
|
||||
: null;
|
||||
};
|
||||
+226
-66
@@ -4,6 +4,11 @@ import type {
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
import {
|
||||
reconcileCapturedWebScript,
|
||||
stableWebResources,
|
||||
stableWebScriptKey,
|
||||
} from "../domain/webInventory.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
captureDom,
|
||||
captureFrames,
|
||||
captureResources,
|
||||
type CapturedResource,
|
||||
mainFrameUrl,
|
||||
} from "./CdpCaptureDocuments.js";
|
||||
import {
|
||||
@@ -27,6 +33,7 @@ import {
|
||||
sourceResult,
|
||||
stringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
|
||||
|
||||
interface CaptureContext {
|
||||
readonly connection: CdpConnection;
|
||||
@@ -43,7 +50,6 @@ interface CaptureState {
|
||||
readonly events: CdpCaptureEvents;
|
||||
readonly allowedOrigins: ReadonlySet<string>;
|
||||
readonly limitations: string[];
|
||||
readonly truncatedSections: Set<string>;
|
||||
readonly startedAt: string;
|
||||
}
|
||||
|
||||
@@ -58,10 +64,20 @@ interface CapturedSections {
|
||||
readonly storage: WebPageInspection["storage"];
|
||||
}
|
||||
|
||||
export interface CapturedPage {
|
||||
readonly inspection: WebPageInspection;
|
||||
readonly sourceMapRequests: readonly WebSourceMapRequest[];
|
||||
}
|
||||
|
||||
interface CapturedScripts {
|
||||
readonly inventory: WebPageInspection["scripts"];
|
||||
readonly sourceMapRequests: readonly WebSourceMapRequest[];
|
||||
}
|
||||
|
||||
/** Capture and normalize one attached page without evaluating page JavaScript. */
|
||||
export const capturePage = async (
|
||||
context: CaptureContext,
|
||||
): Promise<WebPageInspection> => {
|
||||
): Promise<CapturedPage> => {
|
||||
const allowedOrigins = new Set(context.input.allowed_origins);
|
||||
const events = new CdpCaptureEvents(context.input, allowedOrigins);
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
@@ -74,11 +90,10 @@ export const capturePage = async (
|
||||
allowedOrigins,
|
||||
limitations: [
|
||||
"Observation starts when REA attaches; prior network and console activity is unavailable.",
|
||||
"Network headers, request bodies, response bodies, cookies, storage values, console values, and WebSocket payloads are not retained.",
|
||||
"Raw network headers, bodies, cookies, storage values, console objects, and WebSocket payloads are never retained; separately approved captures retain only bounded redacted text or value-free shapes.",
|
||||
"Source maps are reported only as declarative URLs and are not fetched.",
|
||||
"URL-less scripts and console events without an allowed source URL are excluded because their origin cannot be proven.",
|
||||
],
|
||||
truncatedSections: new Set<string>(),
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
@@ -90,19 +105,22 @@ export const capturePage = async (
|
||||
|
||||
const captureAuthorizedPage = async (
|
||||
state: CaptureState,
|
||||
): Promise<WebPageInspection> => {
|
||||
const { context, allowedOrigins, limitations, truncatedSections } = state;
|
||||
): Promise<CapturedPage> => {
|
||||
const { context, allowedOrigins, limitations } = state;
|
||||
const { connection, sessionId, input, signal } = context;
|
||||
await authorizeObservationWindow(state);
|
||||
await captureJsonResponseBodies(state);
|
||||
const frameResult = await authorizedFrameTree(context, allowedOrigins);
|
||||
const attachedUrl = mainFrameUrl(frameResult) ?? "";
|
||||
const frameCapture = captureFrames(
|
||||
frameResult,
|
||||
allowedOrigins,
|
||||
input.limits.max_frames,
|
||||
state.events.completeness,
|
||||
);
|
||||
const frames = frameCapture.items;
|
||||
if (frameCapture.total > frames.length) truncatedSections.add("frames");
|
||||
if (frameCapture.total > frames.length)
|
||||
state.events.completeness.truncate("frames");
|
||||
const captureFrame = frames[0];
|
||||
if (captureFrame === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
@@ -111,38 +129,39 @@ const captureAuthorizedPage = async (
|
||||
await connection.send("Page.getResourceTree", {}, sessionId, signal),
|
||||
allowedOrigins,
|
||||
input.limits.max_resources,
|
||||
state.events.completeness,
|
||||
);
|
||||
if (resourceCapture.total > resourceCapture.items.length)
|
||||
truncatedSections.add("resources");
|
||||
const dom = await capturePageDom(state);
|
||||
if (dom.total > dom.nodes.length) truncatedSections.add("dom");
|
||||
const accessibility = await accessibilityForFrames(
|
||||
context,
|
||||
frames,
|
||||
limitations,
|
||||
state.events.completeness.truncate("resources");
|
||||
const resources = stableWebResources(
|
||||
resourceCapture.items.map(publicResource),
|
||||
);
|
||||
const dom = await capturePageDom(state);
|
||||
if (dom.total > dom.nodes.length) state.events.completeness.truncate("dom");
|
||||
const accessibility = await accessibilityForFrames(state, frames);
|
||||
if (accessibility.total > accessibility.nodes.length)
|
||||
truncatedSections.add("accessibility");
|
||||
state.events.completeness.truncate("accessibility");
|
||||
const scripts = await captureScripts(
|
||||
context,
|
||||
state.events,
|
||||
truncatedSections,
|
||||
resourceCapture.items,
|
||||
resources,
|
||||
);
|
||||
const workerCapture = await captureWorkers(
|
||||
context,
|
||||
allowedOrigins,
|
||||
state,
|
||||
new Set(frames.map((frame) => frame.frame_id)),
|
||||
limitations,
|
||||
);
|
||||
if (workerCapture.total > workerCapture.items.length)
|
||||
truncatedSections.add("workers");
|
||||
state.events.completeness.truncate("workers");
|
||||
const storageCapture = await captureStorage(
|
||||
context,
|
||||
frames[0]?.origin ?? new URL(attachedUrl).origin,
|
||||
limitations,
|
||||
);
|
||||
if (storageCapture.truncated) truncatedSections.add("storage_keys");
|
||||
if (state.events.dropped > 0) truncatedSections.add("events");
|
||||
if (storageCapture.truncated)
|
||||
state.events.completeness.truncate("storage_keys");
|
||||
if (!input.include_storage_keys)
|
||||
state.events.completeness.exclude("storage_keys", "not_approved", null);
|
||||
const completedFrameResult = await authorizedFrameTree(
|
||||
context,
|
||||
allowedOrigins,
|
||||
@@ -153,16 +172,42 @@ const captureAuthorizedPage = async (
|
||||
if (state.events.navigationDuringCapture || completedUrl !== attachedUrl)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_changed");
|
||||
await report(context.progress, 3, "Normalizing browser evidence");
|
||||
return normalizedInspection(state, {
|
||||
attachedUrl,
|
||||
frames,
|
||||
dom,
|
||||
accessibility,
|
||||
scripts,
|
||||
resources: resourceCapture.items,
|
||||
workers: workerCapture.items,
|
||||
storage: storageCapture.value,
|
||||
});
|
||||
return {
|
||||
inspection: normalizedInspection(state, {
|
||||
attachedUrl,
|
||||
frames,
|
||||
dom,
|
||||
accessibility,
|
||||
scripts: scripts.inventory,
|
||||
resources,
|
||||
workers: workerCapture.items,
|
||||
storage: storageCapture.value,
|
||||
}),
|
||||
sourceMapRequests: scripts.sourceMapRequests,
|
||||
};
|
||||
};
|
||||
|
||||
const captureJsonResponseBodies = async (
|
||||
state: CaptureState,
|
||||
): Promise<void> => {
|
||||
const requestIds = state.events.responseBodyRequestIds();
|
||||
for (let index = 0; index < requestIds.length; index += 1) {
|
||||
const requestId = requestIds[index];
|
||||
if (requestId === undefined) continue;
|
||||
const result = await optionalCdpCommand(
|
||||
state.context,
|
||||
"Network.getResponseBody",
|
||||
{ requestId },
|
||||
state.limitations,
|
||||
);
|
||||
if (result !== undefined) {
|
||||
state.events.ingestResponseBody(requestId, result);
|
||||
continue;
|
||||
}
|
||||
for (const remaining of requestIds.slice(index))
|
||||
state.events.responseBodyUnavailable(remaining);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const authorizeObservationWindow = async (
|
||||
@@ -199,14 +244,19 @@ const capturePageDom = async (
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
return captureDom(result, allowedOrigins, context.input);
|
||||
return captureDom(
|
||||
result,
|
||||
allowedOrigins,
|
||||
context.input,
|
||||
state.events.completeness,
|
||||
);
|
||||
};
|
||||
|
||||
const normalizedInspection = (
|
||||
state: CaptureState,
|
||||
captured: CapturedSections,
|
||||
): WebPageInspection => ({
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
browser: state.context.discovery.version,
|
||||
target: normalizedTarget(
|
||||
state.context.target,
|
||||
@@ -218,15 +268,12 @@ const normalizedInspection = (
|
||||
ended_at: new Date().toISOString(),
|
||||
observation_ms: state.context.input.observation_ms,
|
||||
},
|
||||
completeness: {
|
||||
status: state.truncatedSections.size === 0 ? "complete" : "truncated",
|
||||
truncated_sections: [...state.truncatedSections].sort(),
|
||||
dropped_events: state.events.dropped,
|
||||
},
|
||||
completeness: state.events.completeness.snapshot(),
|
||||
frames: [...captured.frames],
|
||||
dom: { total_nodes: captured.dom.total, nodes: [...captured.dom.nodes] },
|
||||
accessibility: {
|
||||
total_nodes: captured.accessibility.total,
|
||||
text_capture: captured.accessibility.textCapture,
|
||||
nodes: [...captured.accessibility.nodes],
|
||||
},
|
||||
scripts: captured.scripts,
|
||||
@@ -243,6 +290,16 @@ const normalizedInspection = (
|
||||
prior_activity_available: false,
|
||||
},
|
||||
workers: [...captured.workers],
|
||||
metadata: {
|
||||
responses: [...state.events.responseMetadata],
|
||||
dom_urls: [...captured.dom.urls],
|
||||
agent_hints: deduplicatedAgentHints([
|
||||
...state.events.agentHints,
|
||||
...captured.dom.agentHints,
|
||||
]),
|
||||
excluded_dom_urls: captured.dom.excludedUrls,
|
||||
headers_allowlisted: true,
|
||||
},
|
||||
storage: captured.storage,
|
||||
limitations: state.limitations,
|
||||
});
|
||||
@@ -295,12 +352,12 @@ const enableObservationDomains = async (
|
||||
};
|
||||
|
||||
const accessibilityForFrames = async (
|
||||
context: CaptureContext,
|
||||
state: CaptureState,
|
||||
frames: readonly { readonly frame_id: string }[],
|
||||
limitations: string[],
|
||||
): Promise<ReturnType<typeof captureAccessibility>> => {
|
||||
const nodes: WebPageInspection["accessibility"]["nodes"] = [];
|
||||
let total = 0;
|
||||
const { context, limitations, events } = state;
|
||||
const results: unknown[] = [];
|
||||
let unavailable = false;
|
||||
for (const frame of frames) {
|
||||
const result = await optionalCdpCommand(
|
||||
context,
|
||||
@@ -308,24 +365,45 @@ const accessibilityForFrames = async (
|
||||
{ depth: 32, frameId: frame.frame_id },
|
||||
limitations,
|
||||
);
|
||||
if (result === undefined) continue;
|
||||
const capture = captureAccessibility(
|
||||
[result],
|
||||
Math.max(0, context.input.limits.max_ax_nodes - nodes.length),
|
||||
);
|
||||
total += capture.total;
|
||||
nodes.push(...capture.nodes);
|
||||
if (result === undefined) unavailable = true;
|
||||
else results.push(result);
|
||||
}
|
||||
return { total, nodes };
|
||||
if (unavailable) events.completeness.unavailable("accessibility");
|
||||
const capture = captureAccessibility(
|
||||
results,
|
||||
context.input.limits.max_ax_nodes,
|
||||
{
|
||||
includeText: context.input.include_accessibility_text,
|
||||
maximumFieldBytes: context.input.limits.max_ax_text_field_bytes,
|
||||
maximumTotalBytes: context.input.limits.max_total_ax_text_bytes,
|
||||
...(results.length === 0 && unavailable ? { unavailable: true } : {}),
|
||||
},
|
||||
);
|
||||
if (!context.input.include_accessibility_text)
|
||||
events.completeness.exclude(
|
||||
"accessibility",
|
||||
"not_approved",
|
||||
capture.textCapture.excluded_fields,
|
||||
);
|
||||
if (capture.textCapture.status === "truncated")
|
||||
events.completeness.truncate("accessibility");
|
||||
return capture;
|
||||
};
|
||||
|
||||
const captureScripts = async (
|
||||
context: CaptureContext,
|
||||
events: CdpCaptureEvents,
|
||||
truncatedSections: Set<string>,
|
||||
): Promise<WebPageInspection["scripts"]> => {
|
||||
rawResources: readonly CapturedResource[],
|
||||
resources: WebPageInspection["resources"],
|
||||
): Promise<CapturedScripts> => {
|
||||
let totalSourceBytes = 0;
|
||||
const items: WebPageInspection["scripts"]["items"] = [];
|
||||
const drafts: {
|
||||
readonly item: Omit<
|
||||
WebPageInspection["scripts"]["items"][number],
|
||||
"script_key"
|
||||
>;
|
||||
readonly sourceMapRawUrl: string | null;
|
||||
}[] = [];
|
||||
for (const script of events.scripts.values()) {
|
||||
let source: WebPageInspection["scripts"]["items"][number]["source"] =
|
||||
sourceExcluded("source capture was not approved");
|
||||
@@ -334,13 +412,13 @@ const captureScripts = async (
|
||||
source = sourceExcluded(
|
||||
"declared script length exceeds per-script limit",
|
||||
);
|
||||
truncatedSections.add("script_sources");
|
||||
events.completeness.truncate("script_sources");
|
||||
} else if (
|
||||
totalSourceBytes + script.length >
|
||||
context.input.limits.max_total_script_source_bytes
|
||||
) {
|
||||
source = sourceExcluded("total script source limit reached");
|
||||
truncatedSections.add("script_sources");
|
||||
events.completeness.truncate("script_sources");
|
||||
} else {
|
||||
const result = requiredRecord(
|
||||
await context.connection.send(
|
||||
@@ -360,37 +438,103 @@ const captureScripts = async (
|
||||
source = sourceExcluded(
|
||||
"actual source exceeds configured byte limits",
|
||||
);
|
||||
truncatedSections.add("script_sources");
|
||||
events.completeness.truncate("script_sources");
|
||||
} else {
|
||||
source = sourceResult(content);
|
||||
totalSourceBytes += bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
script_id: script.scriptId,
|
||||
const inventoryScript = {
|
||||
url: script.url,
|
||||
origin: script.origin,
|
||||
cdp_hash: script.hash,
|
||||
length: script.length,
|
||||
is_module: script.isModule,
|
||||
language: script.language,
|
||||
source_map_url: script.sourceMapUrl,
|
||||
source,
|
||||
};
|
||||
drafts.push({
|
||||
item: {
|
||||
...inventoryScript,
|
||||
origin: script.origin,
|
||||
resource_reconciliation: reconcileCapturedWebScript(
|
||||
{ ...inventoryScript, rawUrl: script.rawUrl },
|
||||
rawResources,
|
||||
resources,
|
||||
),
|
||||
source,
|
||||
},
|
||||
sourceMapRawUrl: script.sourceMapRawUrl,
|
||||
});
|
||||
}
|
||||
return { total: events.scripts.size, items };
|
||||
if (!context.input.include_script_sources)
|
||||
events.completeness.exclude(
|
||||
"script_sources",
|
||||
"not_approved",
|
||||
events.scripts.size,
|
||||
);
|
||||
const keyed = drafts.map((draft) => ({
|
||||
...draft,
|
||||
base: stableWebScriptKey(draft.item),
|
||||
}));
|
||||
keyed.sort(
|
||||
(left, right) =>
|
||||
left.base.localeCompare(right.base) ||
|
||||
sourceDigest(left.item.source).localeCompare(
|
||||
sourceDigest(right.item.source),
|
||||
),
|
||||
);
|
||||
const totals = new Map<string, number>();
|
||||
for (const { base } of keyed) totals.set(base, (totals.get(base) ?? 0) + 1);
|
||||
const seen = new Map<string, number>();
|
||||
const sourceMapRequests: WebSourceMapRequest[] = [];
|
||||
const items = keyed.map(({ base, item, sourceMapRawUrl }) => {
|
||||
const occurrence = (seen.get(base) ?? 0) + 1;
|
||||
seen.set(base, occurrence);
|
||||
const scriptKey =
|
||||
totals.get(base) === 1 ? base : `${base}_${String(occurrence)}`;
|
||||
if (item.source_map_url !== null && sourceMapRawUrl !== null)
|
||||
sourceMapRequests.push({
|
||||
scriptKey,
|
||||
declaredUrl: item.source_map_url,
|
||||
fetchUrl: sourceMapRawUrl,
|
||||
});
|
||||
return { script_key: scriptKey, ...item };
|
||||
});
|
||||
return {
|
||||
inventory: { total: events.scripts.size, items },
|
||||
sourceMapRequests,
|
||||
};
|
||||
};
|
||||
|
||||
const sourceDigest = (
|
||||
source: WebPageInspection["scripts"]["items"][number]["source"],
|
||||
): string => (source.included ? source.artifact.sha256 : source.reason);
|
||||
|
||||
const publicResource = ({ rawUrl: _rawUrl, ...resource }: CapturedResource) =>
|
||||
resource;
|
||||
|
||||
const deduplicatedAgentHints = (
|
||||
hints: WebPageInspection["metadata"]["agent_hints"],
|
||||
): WebPageInspection["metadata"]["agent_hints"] => {
|
||||
const seen = new Set<string>();
|
||||
return hints.filter((hint) => {
|
||||
const key = `${hint.mechanism}\0${hint.declaration}\0${hint.url ?? ""}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const captureWorkers = async (
|
||||
context: CaptureContext,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
state: CaptureState,
|
||||
frameIds: ReadonlySet<string>,
|
||||
limitations: string[],
|
||||
): Promise<{
|
||||
readonly total: number;
|
||||
readonly items: WebPageInspection["workers"];
|
||||
}> => {
|
||||
const { context, allowedOrigins, limitations, events } = state;
|
||||
const completeness = events.completeness;
|
||||
const result = await optionalCdpCommand(
|
||||
{ ...context, sessionId: undefined },
|
||||
"Target.getTargets",
|
||||
@@ -399,6 +543,7 @@ const captureWorkers = async (
|
||||
);
|
||||
const items: WebPageInspection["workers"] = [];
|
||||
let total = 0;
|
||||
if (result === undefined) completeness.unavailable("workers");
|
||||
for (const target of recordsValue(recordValue(result)?.targetInfos)) {
|
||||
const type = stringValue(target.type) ?? "";
|
||||
const url = allowedSanitizedUrl(target.url, allowedOrigins);
|
||||
@@ -406,8 +551,23 @@ const captureWorkers = async (
|
||||
stringValue(target.openerId) === context.target.id ||
|
||||
(stringValue(target.parentFrameId) !== undefined &&
|
||||
frameIds.has(stringValue(target.parentFrameId) ?? ""));
|
||||
if (!type.includes("worker") || url === undefined || !relatedToPage)
|
||||
if (!type.includes("worker")) continue;
|
||||
if (!relatedToPage) {
|
||||
completeness.exclude("workers", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
if (url === undefined) {
|
||||
const rawUrl = stringValue(target.url);
|
||||
completeness.exclude(
|
||||
"workers",
|
||||
rawUrl === undefined || rawUrl === ""
|
||||
? "unattributed_origin"
|
||||
: isHttpUrl(rawUrl)
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if (items.length >= context.input.limits.max_workers) continue;
|
||||
items.push({
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { WebPageInspection } from "../domain/browserObservation.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import {
|
||||
numberValue,
|
||||
stringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
|
||||
type ResponseMetadata = WebPageInspection["metadata"]["responses"][number];
|
||||
type LinkMetadata = ResponseMetadata["links"][number];
|
||||
type AgentHint = WebPageInspection["metadata"]["agent_hints"][number];
|
||||
|
||||
/** Normalize an allowlisted subset of response metadata and discard raw headers. */
|
||||
export const safeResponseMetadata = (
|
||||
requestId: string,
|
||||
url: string,
|
||||
response: UnknownRecord,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): {
|
||||
readonly response: ResponseMetadata;
|
||||
readonly agentHints: AgentHint[];
|
||||
} => {
|
||||
const headers = normalizedHeaders(response.headers);
|
||||
const links = parseLinks(headers.get("link"), url, allowedOrigins);
|
||||
const csp = parseCsp(
|
||||
headers.get("content-security-policy"),
|
||||
url,
|
||||
allowedOrigins,
|
||||
);
|
||||
const agentHints = [
|
||||
...links.flatMap((link) =>
|
||||
link.rel.some(isAgentRel)
|
||||
? [agentHint("link_rel", link.rel.join(" "), link.href)]
|
||||
: [],
|
||||
),
|
||||
...agentHeaderNames.flatMap((name) =>
|
||||
headers.has(name) ? [agentHint("response_header", name, null)] : [],
|
||||
),
|
||||
...wellKnownHint(url),
|
||||
];
|
||||
return {
|
||||
response: {
|
||||
request_id: requestId,
|
||||
url,
|
||||
mime_type: boundedHeader(stringValue(response.mimeType)),
|
||||
content_length: nonnegativeInteger(headers.get("content-length")),
|
||||
content_encoding: boundedHeader(headers.get("content-encoding")),
|
||||
csp,
|
||||
links,
|
||||
policies: {
|
||||
coop: policyToken(headers.get("cross-origin-opener-policy")),
|
||||
coep: policyToken(headers.get("cross-origin-embedder-policy")),
|
||||
corp: policyToken(headers.get("cross-origin-resource-policy")),
|
||||
referrer_policy: policyToken(headers.get("referrer-policy")),
|
||||
x_content_type_options: policyToken(
|
||||
headers.get("x-content-type-options"),
|
||||
),
|
||||
permissions_policy_features: permissionFeatures(
|
||||
headers.get("permissions-policy"),
|
||||
),
|
||||
},
|
||||
},
|
||||
agentHints,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizedHeaders = (value: unknown): ReadonlyMap<string, string> => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return new Map();
|
||||
const headers = new Map<string, string>();
|
||||
for (const [name, raw] of Object.entries(value).slice(0, 500)) {
|
||||
const normalized = name.toLowerCase().slice(0, 256);
|
||||
const header =
|
||||
typeof raw === "string"
|
||||
? raw
|
||||
: typeof raw === "number" && Number.isFinite(raw)
|
||||
? String(raw)
|
||||
: undefined;
|
||||
if (header !== undefined) headers.set(normalized, header.slice(0, 65_536));
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const parseCsp = (
|
||||
value: string | undefined,
|
||||
baseUrl: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): ResponseMetadata["csp"] => {
|
||||
const directives: ResponseMetadata["csp"]["directives"] = [];
|
||||
let nonceCount = 0;
|
||||
let hashCount = 0;
|
||||
for (const rawDirective of (value ?? "").split(";").slice(0, 100)) {
|
||||
const [rawName, ...tokens] = rawDirective.trim().split(/\s+/u);
|
||||
const name = (rawName ?? "").toLowerCase();
|
||||
if (!/^[a-z][a-z0-9-]{0,99}$/u.test(name)) continue;
|
||||
const sources: ResponseMetadata["csp"]["directives"][number]["sources"] =
|
||||
[];
|
||||
for (const token of tokens.slice(0, 100)) {
|
||||
const lower = token.toLowerCase();
|
||||
if (lower.startsWith("'nonce-")) {
|
||||
nonceCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower.startsWith("'sha256-") ||
|
||||
lower.startsWith("'sha384-") ||
|
||||
lower.startsWith("'sha512-")
|
||||
) {
|
||||
hashCount += 1;
|
||||
continue;
|
||||
}
|
||||
sources.push(cspSource(token, baseUrl, allowedOrigins));
|
||||
}
|
||||
directives.push({ name, sources });
|
||||
}
|
||||
return { directives, nonce_count: nonceCount, hash_count: hashCount };
|
||||
};
|
||||
|
||||
const cspSource = (
|
||||
token: string,
|
||||
baseUrl: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): ResponseMetadata["csp"]["directives"][number]["sources"][number] => {
|
||||
const lower = token.toLowerCase();
|
||||
if (/^'[a-z0-9-]+'$/u.test(lower))
|
||||
return { kind: "keyword", value: lower.slice(0, 100) };
|
||||
if (/^[a-z][a-z0-9+.-]*:$/u.test(lower))
|
||||
return { kind: "scheme", value: lower.slice(0, 100) };
|
||||
try {
|
||||
const parsed = new URL(token, baseUrl);
|
||||
return allowedOrigins.has(parsed.origin)
|
||||
? { kind: "approved_origin", value: parsed.origin }
|
||||
: { kind: "external_origin", value: null };
|
||||
} catch {
|
||||
return { kind: "other", value: null };
|
||||
}
|
||||
};
|
||||
|
||||
const parseLinks = (
|
||||
value: string | undefined,
|
||||
baseUrl: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): LinkMetadata[] => {
|
||||
const links: LinkMetadata[] = [];
|
||||
for (const entry of splitLinkHeader(value ?? "").slice(0, 100)) {
|
||||
const match = /^\s*<([^>]{1,4096})>(.*)$/u.exec(entry);
|
||||
if (match === null) continue;
|
||||
const parameters = linkParameters(match[2] ?? "");
|
||||
const destination = safeDestination(
|
||||
match[1] ?? "",
|
||||
baseUrl,
|
||||
allowedOrigins,
|
||||
);
|
||||
links.push({
|
||||
href: destination.url,
|
||||
destination_scope: destination.scope,
|
||||
rel: (parameters.get("rel") ?? "")
|
||||
.toLowerCase()
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean)
|
||||
.slice(0, 32),
|
||||
as: boundedHeader(parameters.get("as")),
|
||||
type: boundedHeader(parameters.get("type")),
|
||||
crossorigin: boundedHeader(parameters.get("crossorigin")),
|
||||
});
|
||||
}
|
||||
return links;
|
||||
};
|
||||
|
||||
const splitLinkHeader = (value: string): string[] => {
|
||||
const entries: string[] = [];
|
||||
let start = 0;
|
||||
let quoted = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (character === '"' && value[index - 1] !== "\\") quoted = !quoted;
|
||||
if (character !== "," || quoted) continue;
|
||||
entries.push(value.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
entries.push(value.slice(start));
|
||||
return entries;
|
||||
};
|
||||
|
||||
const linkParameters = (value: string): ReadonlyMap<string, string> => {
|
||||
const parameters = new Map<string, string>();
|
||||
for (const raw of value.split(";").slice(1, 50)) {
|
||||
const separator = raw.indexOf("=");
|
||||
const name = (separator < 0 ? raw : raw.slice(0, separator))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!/^[a-z][a-z0-9-]{0,99}$/u.test(name)) continue;
|
||||
const parameter = separator < 0 ? "" : raw.slice(separator + 1).trim();
|
||||
parameters.set(name, unquote(parameter).slice(0, 1_024));
|
||||
}
|
||||
return parameters;
|
||||
};
|
||||
|
||||
const safeDestination = (
|
||||
value: string,
|
||||
baseUrl: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): {
|
||||
readonly url: string | null;
|
||||
readonly scope: LinkMetadata["destination_scope"];
|
||||
} => {
|
||||
try {
|
||||
const parsed = new URL(value, baseUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
||||
return { url: null, scope: "unsupported" };
|
||||
if (!allowedOrigins.has(parsed.origin))
|
||||
return { url: null, scope: "outside_policy" };
|
||||
return { url: sanitizeBrowserUrl(parsed.href).url, scope: "approved" };
|
||||
} catch {
|
||||
return { url: null, scope: "unsupported" };
|
||||
}
|
||||
};
|
||||
|
||||
const permissionFeatures = (value: string | undefined): string[] =>
|
||||
[
|
||||
...new Set(
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.split("=", 1)[0]?.trim().toLowerCase() ?? "")
|
||||
.filter((feature) => /^[a-z][a-z0-9-]{0,99}$/u.test(feature)),
|
||||
),
|
||||
]
|
||||
.sort()
|
||||
.slice(0, 200);
|
||||
|
||||
const wellKnownHint = (url: string): AgentHint[] => {
|
||||
try {
|
||||
const path = new URL(url).pathname.toLowerCase();
|
||||
return wellKnownAgentPaths.has(path)
|
||||
? [agentHint("well_known_resource", path, sanitizeBrowserUrl(url).url)]
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const agentHint = (
|
||||
mechanism: AgentHint["mechanism"],
|
||||
declaration: string,
|
||||
url: string | null,
|
||||
): AgentHint => ({
|
||||
mechanism,
|
||||
declaration: declaration.slice(0, 256),
|
||||
url,
|
||||
trust: "page-declared-untrusted",
|
||||
});
|
||||
|
||||
const isAgentRel = (value: string): boolean =>
|
||||
["mcp", "model-context", "ai-plugin", "service-desc"].includes(value);
|
||||
|
||||
const policyToken = (value: string | undefined): string | null => {
|
||||
const token = (value ?? "").trim().toLowerCase();
|
||||
return /^[a-z][a-z0-9_.-]{0,99}$/u.test(token) ? token : null;
|
||||
};
|
||||
|
||||
const nonnegativeInteger = (value: string | undefined): number | null => {
|
||||
const number = numberValue(value === undefined ? undefined : Number(value));
|
||||
return number === undefined ? null : Math.max(0, Math.trunc(number));
|
||||
};
|
||||
|
||||
const boundedHeader = (value: string | undefined): string | null =>
|
||||
value === undefined ? null : value.trim().toLowerCase().slice(0, 256);
|
||||
|
||||
const unquote = (value: string): string =>
|
||||
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
||||
|
||||
const agentHeaderNames = ["x-model-context", "x-webmcp", "mcp-server"] as const;
|
||||
const wellKnownAgentPaths: ReadonlySet<string> = new Set([
|
||||
"/.well-known/ai-plugin.json",
|
||||
"/.well-known/mcp",
|
||||
"/.well-known/model-context",
|
||||
]);
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import type {
|
||||
CaptureWebScreenshotInput,
|
||||
WebScreenshot,
|
||||
} from "../domain/webScreenshot.js";
|
||||
import { createWebScreenshotArtifact } from "../domain/webScreenshot.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
recordValue,
|
||||
stringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
|
||||
import { captureFrames } from "./CdpCaptureDocuments.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { decodeCanonicalBase64 } from "../domain/webScreenshot.js";
|
||||
|
||||
interface ScreenshotContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: CaptureWebScreenshotInput;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
}
|
||||
|
||||
/** Capture the current viewport without navigation, evaluation, or page mutation. */
|
||||
export const captureCdpScreenshot = async (
|
||||
context: ScreenshotContext,
|
||||
): Promise<WebScreenshot> => {
|
||||
const origins = new Set(context.input.allowed_origins);
|
||||
await context.progress?.report({
|
||||
phase: "browser_observation",
|
||||
completed: 1,
|
||||
total: 2,
|
||||
message: "Authorizing current screenshot frame",
|
||||
});
|
||||
await context.connection.send(
|
||||
"Page.enable",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const before = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const beforeUrl = mainFrameUrl(before);
|
||||
const authorized = allowedSanitizedUrl(beforeUrl, origins);
|
||||
const mainFrame = captureFrames(before, origins, 1).items[0];
|
||||
if (authorized === undefined || mainFrame === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
const navigation = { changed: false, leftScope: false };
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
if (event.sessionId !== context.sessionId) return;
|
||||
observeScreenshotNavigation(event, mainFrame.frame_id, origins, navigation);
|
||||
});
|
||||
let result: ReturnType<typeof recordValue>;
|
||||
let after: unknown;
|
||||
try {
|
||||
result = recordValue(
|
||||
await context.connection.send(
|
||||
"Page.captureScreenshot",
|
||||
{ format: "png", fromSurface: true, captureBeyondViewport: false },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
),
|
||||
);
|
||||
after = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
} finally {
|
||||
removeListener();
|
||||
}
|
||||
const encoded = stringValue(result?.data);
|
||||
const bytes =
|
||||
encoded === undefined ? undefined : decodeCanonicalBase64(encoded);
|
||||
if (
|
||||
bytes === undefined ||
|
||||
bytes.byteLength > context.input.maximum_image_bytes
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "payload_limit");
|
||||
const dimensions = pngDimensions(bytes);
|
||||
if (navigation.leftScope)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
const afterUrl = mainFrameUrl(after);
|
||||
if (
|
||||
navigation.changed ||
|
||||
afterUrl !== beforeUrl ||
|
||||
allowedSanitizedUrl(afterUrl, origins) === undefined
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_changed");
|
||||
await context.progress?.report({
|
||||
phase: "browser_observation",
|
||||
completed: 2,
|
||||
total: 2,
|
||||
message: "Screenshot capture complete",
|
||||
terminal: true,
|
||||
});
|
||||
return {
|
||||
schema_version: 1,
|
||||
browser: context.discovery.version,
|
||||
target: {
|
||||
target_id: context.target.id,
|
||||
url: authorized.url,
|
||||
origin: authorized.origin ?? "",
|
||||
},
|
||||
captured_at: new Date().toISOString(),
|
||||
viewport: dimensions,
|
||||
artifact: createWebScreenshotArtifact(bytes),
|
||||
completeness: new CdpCaptureCompleteness().snapshot(),
|
||||
limitations: [
|
||||
"The screenshot contains the visible viewport and may include sensitive on-screen content; capture requires separate approval.",
|
||||
"REA does not scroll, evaluate JavaScript, or capture beyond the current viewport.",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const observeScreenshotNavigation = (
|
||||
event: CdpEvent,
|
||||
mainFrameId: string,
|
||||
origins: ReadonlySet<string>,
|
||||
state: { changed: boolean; leftScope: boolean },
|
||||
): void => {
|
||||
const params = recordValue(event.params);
|
||||
if (params === undefined) return;
|
||||
const frame =
|
||||
event.method === "Page.frameNavigated" ? recordValue(params.frame) : params;
|
||||
if (
|
||||
(event.method !== "Page.frameNavigated" &&
|
||||
event.method !== "Page.navigatedWithinDocument") ||
|
||||
(stringValue(frame?.id) ?? stringValue(frame?.frameId)) !== mainFrameId
|
||||
)
|
||||
return;
|
||||
state.changed = true;
|
||||
if (allowedSanitizedUrl(frame?.url, origins) === undefined)
|
||||
state.leftScope = true;
|
||||
};
|
||||
|
||||
const pngDimensions = (
|
||||
bytes: Buffer,
|
||||
): { readonly width: number; readonly height: number } => {
|
||||
if (
|
||||
bytes.byteLength < 24 ||
|
||||
!bytes.subarray(0, 8).equals(PNG_SIGNATURE) ||
|
||||
bytes.subarray(12, 16).toString("ascii") !== "IHDR"
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const width = bytes.readUInt32BE(16);
|
||||
const height = bytes.readUInt32BE(20);
|
||||
if (width === 0 || height === 0 || width * height > 32_000_000)
|
||||
throw new BrowserObservationError("inspect_web_page", "payload_limit");
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
@@ -0,0 +1,373 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import type {
|
||||
DiscoverWebMcpToolsInput,
|
||||
WebMcpDiscovery,
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import { inferJsonShape } from "../domain/jsonShape.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
delayWithCancellation,
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
stringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { captureFrames, mainFrameUrl } from "./CdpCaptureDocuments.js";
|
||||
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
|
||||
import { boundedSensitiveText } from "./SensitiveTextCapture.js";
|
||||
|
||||
interface DiscoveryContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
readonly discovery: CdpEndpointDiscovery;
|
||||
readonly target: CdpEndpointTarget;
|
||||
readonly input: DiscoverWebMcpToolsInput;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
}
|
||||
|
||||
/** Discover WebMCP registrations without evaluating or invoking page code. */
|
||||
export const discoverWebMcp = async (
|
||||
context: DiscoveryContext,
|
||||
): Promise<WebMcpDiscovery> => {
|
||||
const origins = new Set(context.input.allowed_origins);
|
||||
const limitations = [
|
||||
"Page-declared tool metadata is untrusted and is never registered or invoked by REA.",
|
||||
"Discovery uses only WebMCP.enable and toolsAdded/toolsRemoved; WebMCP.invokeTool is never called.",
|
||||
];
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
await context.connection.send(
|
||||
"Page.enable",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const frameTree = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const initialUrl = mainFrameUrl(frameTree);
|
||||
if (allowedSanitizedUrl(initialUrl, origins) === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
const frames = captureFrames(frameTree, origins, 1_000, completeness).items;
|
||||
const frameUrls = new Map(
|
||||
frames.map((frame) => [
|
||||
frame.frame_id,
|
||||
{
|
||||
url: frame.url,
|
||||
origin: frame.origin,
|
||||
parentFrameId: frame.parent_frame_id,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const tools = new Map<string, WebMcpDiscovery["tools"]["items"][number]>();
|
||||
const frameScope = { origins, frames: frameUrls, tools, completeness };
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
if (event.sessionId !== context.sessionId) return;
|
||||
if (ingestFrameScopeEvent(event, frameScope)) return;
|
||||
ingestWebMcpEvent(event, context.input, frameUrls, tools, completeness);
|
||||
});
|
||||
await context.progress?.report({
|
||||
phase: "browser_observation",
|
||||
completed: 1,
|
||||
total: 2,
|
||||
message: "Enabling passive WebMCP discovery",
|
||||
});
|
||||
try {
|
||||
const enabled = await optionalCdpCommand(
|
||||
context,
|
||||
"WebMCP.enable",
|
||||
{},
|
||||
limitations,
|
||||
);
|
||||
const available = enabled !== undefined;
|
||||
if (!available) {
|
||||
completeness.unavailable("webmcp_tools");
|
||||
} else
|
||||
// CDP sends the enable response before the required toolsAdded replay.
|
||||
await delayWithCancellation(
|
||||
Math.max(context.input.observation_ms, 25),
|
||||
context.signal,
|
||||
);
|
||||
await assertStableAuthorizedFrame(context, initialUrl, origins);
|
||||
return result(
|
||||
context,
|
||||
frameTree,
|
||||
tools,
|
||||
completeness,
|
||||
limitations,
|
||||
available,
|
||||
);
|
||||
} finally {
|
||||
removeListener();
|
||||
await context.progress?.report({
|
||||
phase: "browser_observation",
|
||||
completed: 2,
|
||||
total: 2,
|
||||
message: "WebMCP discovery complete",
|
||||
terminal: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface FrameScopeState {
|
||||
readonly origins: ReadonlySet<string>;
|
||||
readonly frames: Map<string, FrameScopeFrame>;
|
||||
readonly tools: Map<string, WebMcpDiscovery["tools"]["items"][number]>;
|
||||
readonly completeness: CdpCaptureCompleteness;
|
||||
}
|
||||
|
||||
interface FrameScopeFrame {
|
||||
readonly url: string;
|
||||
readonly origin: string | null;
|
||||
readonly parentFrameId: string | null;
|
||||
}
|
||||
|
||||
const ingestFrameScopeEvent = (
|
||||
event: CdpEvent,
|
||||
state: FrameScopeState,
|
||||
): boolean => {
|
||||
const params = recordValue(event.params);
|
||||
if (params === undefined) return false;
|
||||
if (event.method === "Page.frameDetached") {
|
||||
const frameId = stringValue(params.frameId);
|
||||
if (frameId === undefined || frameId.length > 256)
|
||||
state.completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
else removeFrame(frameId, state.frames, state.tools);
|
||||
return true;
|
||||
}
|
||||
const frame =
|
||||
event.method === "Page.frameNavigated" ? recordValue(params.frame) : params;
|
||||
if (
|
||||
event.method !== "Page.frameNavigated" &&
|
||||
event.method !== "Page.navigatedWithinDocument"
|
||||
)
|
||||
return false;
|
||||
const frameId = stringValue(frame?.id) ?? stringValue(frame?.frameId);
|
||||
if (frameId === undefined || frameId.length > 256) {
|
||||
state.completeness.exclude("webmcp_tools", "invalid_protocol_value");
|
||||
return true;
|
||||
}
|
||||
const url = allowedSanitizedUrl(frame?.url, state.origins);
|
||||
if (url === undefined || url.origin === null) {
|
||||
removeFrame(frameId, state.frames, state.tools);
|
||||
state.completeness.exclude("webmcp_tools", "out_of_target_scope");
|
||||
return true;
|
||||
}
|
||||
state.frames.set(frameId, {
|
||||
url: url.url,
|
||||
origin: url.origin,
|
||||
parentFrameId:
|
||||
(stringValue(frame?.parentId) ??
|
||||
state.frames.get(frameId)?.parentFrameId ??
|
||||
"") ||
|
||||
null,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeFrame = (
|
||||
frameId: string,
|
||||
frames: Map<string, FrameScopeFrame>,
|
||||
tools: Map<string, WebMcpDiscovery["tools"]["items"][number]>,
|
||||
): void => {
|
||||
const pending = [frameId];
|
||||
const removed = new Set<string>();
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined || removed.has(current)) continue;
|
||||
removed.add(current);
|
||||
for (const [candidate, frame] of frames)
|
||||
if (frame.parentFrameId === current) pending.push(candidate);
|
||||
frames.delete(current);
|
||||
}
|
||||
for (const [key, tool] of tools)
|
||||
if (removed.has(tool.frame_id)) tools.delete(key);
|
||||
};
|
||||
|
||||
const assertStableAuthorizedFrame = async (
|
||||
context: DiscoveryContext,
|
||||
initialUrl: string | undefined,
|
||||
origins: ReadonlySet<string>,
|
||||
): Promise<void> => {
|
||||
const finalTree = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const finalUrl = mainFrameUrl(finalTree);
|
||||
if (allowedSanitizedUrl(finalUrl, origins) === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
if (finalUrl !== initialUrl)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_changed");
|
||||
};
|
||||
|
||||
const ingestWebMcpEvent = (
|
||||
event: CdpEvent,
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
frames: ReadonlyMap<
|
||||
string,
|
||||
{ readonly url: string; readonly origin: string | null }
|
||||
>,
|
||||
tools: Map<string, WebMcpDiscovery["tools"]["items"][number]>,
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): void => {
|
||||
const params = recordValue(event.params);
|
||||
if (params === undefined) return;
|
||||
if (event.method === "WebMCP.toolsRemoved") {
|
||||
for (const removed of recordsValue(params.tools)) {
|
||||
const name = stringValue(removed.name);
|
||||
const frameId = stringValue(removed.frameId);
|
||||
if (name === undefined || frameId === undefined) continue;
|
||||
for (const [key, tool] of tools)
|
||||
if (tool.name === name && tool.frame_id === frameId) tools.delete(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.method !== "WebMCP.toolsAdded") return;
|
||||
for (const declared of recordsValue(params.tools)) {
|
||||
const normalized = normalizeTool(declared, input, frames, completeness);
|
||||
if (normalized === undefined) continue;
|
||||
if (!tools.has(normalized.tool_key) && tools.size >= input.max_tools) {
|
||||
completeness.drop("webmcp_tools");
|
||||
continue;
|
||||
}
|
||||
tools.set(normalized.tool_key, normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeTool = (
|
||||
value: UnknownRecord,
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
frames: ReadonlyMap<
|
||||
string,
|
||||
{ readonly url: string; readonly origin: string | null }
|
||||
>,
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): WebMcpDiscovery["tools"]["items"][number] | undefined => {
|
||||
const frameId = stringValue(value.frameId);
|
||||
const name = stringValue(value.name);
|
||||
const frame = frameId === undefined ? undefined : frames.get(frameId);
|
||||
if (
|
||||
frameId === undefined ||
|
||||
name === undefined ||
|
||||
frame?.origin === null ||
|
||||
frame === undefined
|
||||
) {
|
||||
completeness.exclude("webmcp_tools", "out_of_target_scope");
|
||||
return undefined;
|
||||
}
|
||||
const description = boundedSensitiveText(
|
||||
stringValue(value.description) ?? "",
|
||||
4_096,
|
||||
).text;
|
||||
const annotations = recordValue(value.annotations);
|
||||
return {
|
||||
tool_key: toolKey(frame.url, name),
|
||||
name: boundedSensitiveText(name, 512).text,
|
||||
description,
|
||||
frame_id: frameId.slice(0, 256),
|
||||
frame_url: frame.url,
|
||||
owner_origin: frame.origin,
|
||||
declaration_kind:
|
||||
numberValue(value.backendNodeId) === undefined
|
||||
? "imperative"
|
||||
: "declarative",
|
||||
input_schema_shape: schemaShape(value.inputSchema, input, completeness),
|
||||
annotations: {
|
||||
read_only: booleanOrNull(annotations?.readOnly),
|
||||
untrusted_content: booleanOrNull(annotations?.untrustedContent),
|
||||
autosubmit: booleanOrNull(annotations?.autosubmit),
|
||||
},
|
||||
registration_source: registrationSource(
|
||||
recordValue(value.stackTrace),
|
||||
new Set(input.allowed_origins),
|
||||
),
|
||||
trust: "page-declared-untrusted",
|
||||
};
|
||||
};
|
||||
|
||||
const schemaShape = (
|
||||
value: unknown,
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
completeness: CdpCaptureCompleteness,
|
||||
) => {
|
||||
if (recordValue(value) === undefined) return null;
|
||||
const encoded = JSON.stringify(value);
|
||||
const shape = inferJsonShape(encoded, {
|
||||
maximumBytes: input.max_schema_bytes,
|
||||
maximumNodes: input.max_schema_nodes,
|
||||
maximumDepth: input.max_schema_depth,
|
||||
});
|
||||
if (shape === null || shape.truncated) completeness.truncate("webmcp_tools");
|
||||
return shape;
|
||||
};
|
||||
|
||||
const registrationSource = (
|
||||
stack: UnknownRecord | undefined,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): WebMcpDiscovery["tools"]["items"][number]["registration_source"] => {
|
||||
const frame = recordsValue(stack?.callFrames)[0];
|
||||
const url = allowedSanitizedUrl(frame?.url, allowedOrigins);
|
||||
return frame === undefined || url === undefined
|
||||
? null
|
||||
: {
|
||||
url: url.url,
|
||||
line: integerOrNull(frame.lineNumber),
|
||||
column: integerOrNull(frame.columnNumber),
|
||||
};
|
||||
};
|
||||
|
||||
const result = (
|
||||
context: DiscoveryContext,
|
||||
frameTree: unknown,
|
||||
tools: ReadonlyMap<string, WebMcpDiscovery["tools"]["items"][number]>,
|
||||
completeness: CdpCaptureCompleteness,
|
||||
limitations: string[],
|
||||
available: boolean,
|
||||
): WebMcpDiscovery => {
|
||||
const targetUrl = mainFrameUrl(frameTree) ?? context.target.url;
|
||||
const sanitized = allowedSanitizedUrl(
|
||||
targetUrl,
|
||||
new Set(context.input.allowed_origins),
|
||||
);
|
||||
return {
|
||||
schema_version: 1,
|
||||
browser: context.discovery.version,
|
||||
target: {
|
||||
target_id: context.target.id,
|
||||
url: sanitized?.url ?? "[unsupported-url]",
|
||||
origin: sanitized?.origin ?? "",
|
||||
},
|
||||
status: available ? "available" : "unavailable",
|
||||
tools: {
|
||||
total: tools.size + completeness.droppedTotal,
|
||||
items: [...tools.values()].sort((left, right) =>
|
||||
left.tool_key.localeCompare(right.tool_key),
|
||||
),
|
||||
},
|
||||
completeness: completeness.snapshot(),
|
||||
limitations,
|
||||
};
|
||||
};
|
||||
|
||||
const toolKey = (frameUrl: string, name: string): string =>
|
||||
`webmcp_${createHash("sha256").update(`${frameUrl}\0${name}`).digest("hex")}`;
|
||||
|
||||
const booleanOrNull = (value: unknown): boolean | null =>
|
||||
typeof value === "boolean" ? value : null;
|
||||
|
||||
const integerOrNull = (value: unknown): number | null => {
|
||||
const number = numberValue(value);
|
||||
return number === undefined ? null : Math.max(0, Math.trunc(number));
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isAbsolute, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Canonicalize operator roots before evaluating any Electron file target. */
|
||||
export const canonicalElectronRoots = async (
|
||||
roots: readonly string[],
|
||||
): Promise<readonly string[]> => {
|
||||
const canonical: string[] = [];
|
||||
for (const root of roots) canonical.push(await realpath(root));
|
||||
return [...new Set(canonical)].sort();
|
||||
};
|
||||
|
||||
/** Resolve one file URL and reject host, encoding, and symlink root escapes. */
|
||||
export const authorizedElectronFile = async (
|
||||
value: string,
|
||||
roots: readonly string[],
|
||||
): Promise<string | undefined> => {
|
||||
if (value.length > 65_536 || /%(?:2f|5c)/iu.test(value)) return undefined;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
url.protocol !== "file:" ||
|
||||
url.hostname !== "" ||
|
||||
url.username !== "" ||
|
||||
url.password !== ""
|
||||
)
|
||||
return undefined;
|
||||
let path: string;
|
||||
try {
|
||||
path = fileURLToPath(url);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isAbsolute(path) || path.includes("\0")) return undefined;
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = await realpath(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return roots.some((root) => withinRoot(root, canonical))
|
||||
? canonical
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const withinRoot = (root: string, path: string): boolean => {
|
||||
const remainder = relative(root, path);
|
||||
return (
|
||||
remainder === "" || (!remainder.startsWith("..") && !isAbsolute(remainder))
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { inflateSync } from "node:zlib";
|
||||
|
||||
import type {
|
||||
CompareWebScreenshotsInput,
|
||||
WebScreenshotArtifact,
|
||||
WebScreenshotDiff,
|
||||
} from "../domain/webScreenshot.js";
|
||||
import { decodeCanonicalBase64 } from "../domain/webScreenshot.js";
|
||||
|
||||
interface DecodedPng {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly rgba: Buffer;
|
||||
}
|
||||
|
||||
/** Compare two validated screenshots using bounded, deterministic RGBA metrics. */
|
||||
export const comparePngScreenshots = (
|
||||
input: CompareWebScreenshotsInput,
|
||||
): WebScreenshotDiff => {
|
||||
const before = decodePng(input.before, input.maximum_pixels);
|
||||
const after = decodePng(input.after, input.maximum_pixels);
|
||||
if (before.width !== after.width || before.height !== after.height)
|
||||
return {
|
||||
schema_version: 1,
|
||||
status: "dimension_mismatch",
|
||||
before: dimensions(before),
|
||||
after: dimensions(after),
|
||||
channel_threshold: input.channel_threshold,
|
||||
compared_pixels: 0,
|
||||
changed_pixels: null,
|
||||
changed_ratio: null,
|
||||
maximum_channel_delta: null,
|
||||
mean_absolute_channel_delta: null,
|
||||
limitations: limitations(),
|
||||
};
|
||||
let changedPixels = 0;
|
||||
let maximumDelta = 0;
|
||||
let totalDelta = 0;
|
||||
for (let offset = 0; offset < before.rgba.length; offset += 4) {
|
||||
let changed = false;
|
||||
for (let channel = 0; channel < 4; channel += 1) {
|
||||
const delta = Math.abs(
|
||||
(before.rgba[offset + channel] ?? 0) -
|
||||
(after.rgba[offset + channel] ?? 0),
|
||||
);
|
||||
totalDelta += delta;
|
||||
maximumDelta = Math.max(maximumDelta, delta);
|
||||
if (delta > input.channel_threshold) changed = true;
|
||||
}
|
||||
if (changed) changedPixels += 1;
|
||||
}
|
||||
const pixels = before.width * before.height;
|
||||
return {
|
||||
schema_version: 1,
|
||||
status: changedPixels === 0 ? "identical" : "different",
|
||||
before: dimensions(before),
|
||||
after: dimensions(after),
|
||||
channel_threshold: input.channel_threshold,
|
||||
compared_pixels: pixels,
|
||||
changed_pixels: changedPixels,
|
||||
changed_ratio: changedPixels / pixels,
|
||||
maximum_channel_delta: maximumDelta,
|
||||
mean_absolute_channel_delta: totalDelta / (pixels * 4),
|
||||
limitations: limitations(),
|
||||
};
|
||||
};
|
||||
|
||||
const decodePng = (
|
||||
artifact: WebScreenshotArtifact,
|
||||
maximumPixels: number,
|
||||
): DecodedPng => {
|
||||
const bytes = decodeCanonicalBase64(artifact.data_base64);
|
||||
if (bytes === undefined || !bytes.subarray(0, 8).equals(PNG_SIGNATURE))
|
||||
throw new TypeError("Invalid PNG signature");
|
||||
let offset = 8;
|
||||
let header: ReturnType<typeof parseHeader> | undefined;
|
||||
const compressed: Buffer[] = [];
|
||||
let sawEnd = false;
|
||||
while (offset < bytes.length) {
|
||||
if (offset + 12 > bytes.length) throw new TypeError("Truncated PNG chunk");
|
||||
const length = bytes.readUInt32BE(offset);
|
||||
const type = bytes.subarray(offset + 4, offset + 8).toString("ascii");
|
||||
const dataStart = offset + 8;
|
||||
const dataEnd = dataStart + length;
|
||||
if (dataEnd + 4 > bytes.length) throw new TypeError("Truncated PNG data");
|
||||
const data = bytes.subarray(dataStart, dataEnd);
|
||||
if (type === "IHDR") header = parseHeader(data, maximumPixels);
|
||||
else if (type === "IDAT") compressed.push(data);
|
||||
else if (type === "IEND") {
|
||||
sawEnd = true;
|
||||
break;
|
||||
}
|
||||
offset = dataEnd + 4;
|
||||
}
|
||||
if (header === undefined || !sawEnd || compressed.length === 0)
|
||||
throw new TypeError("Incomplete PNG");
|
||||
const rowBytes = header.width * header.channels;
|
||||
const expected = (rowBytes + 1) * header.height;
|
||||
const raw = inflateSync(Buffer.concat(compressed), {
|
||||
maxOutputLength: expected + 1,
|
||||
});
|
||||
if (raw.byteLength !== expected) throw new TypeError("Unexpected PNG size");
|
||||
return {
|
||||
width: header.width,
|
||||
height: header.height,
|
||||
rgba: unfilter(raw, header.width, header.height, header.channels),
|
||||
};
|
||||
};
|
||||
|
||||
const parseHeader = (data: Buffer, maximumPixels: number) => {
|
||||
if (data.byteLength !== 13) throw new TypeError("Invalid PNG header");
|
||||
const width = data.readUInt32BE(0);
|
||||
const height = data.readUInt32BE(4);
|
||||
const bitDepth = data[8];
|
||||
const colorType = data[9];
|
||||
if (
|
||||
width === 0 ||
|
||||
height === 0 ||
|
||||
width * height > maximumPixels ||
|
||||
bitDepth !== 8 ||
|
||||
(colorType !== 2 && colorType !== 6) ||
|
||||
data[10] !== 0 ||
|
||||
data[11] !== 0 ||
|
||||
data[12] !== 0
|
||||
)
|
||||
throw new TypeError("Unsupported or oversized PNG");
|
||||
return { width, height, channels: colorType === 6 ? 4 : 3 };
|
||||
};
|
||||
|
||||
const unfilter = (
|
||||
raw: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
channels: number,
|
||||
): Buffer => {
|
||||
const rowBytes = width * channels;
|
||||
const decoded = Buffer.alloc(rowBytes * height);
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const rawOffset = row * (rowBytes + 1);
|
||||
const outputOffset = row * rowBytes;
|
||||
const filter = raw[rawOffset];
|
||||
if (filter === undefined || filter > 4)
|
||||
throw new TypeError("Unsupported PNG filter");
|
||||
for (let column = 0; column < rowBytes; column += 1) {
|
||||
const source = raw[rawOffset + 1 + column] ?? 0;
|
||||
const left =
|
||||
column >= channels
|
||||
? (decoded[outputOffset + column - channels] ?? 0)
|
||||
: 0;
|
||||
const above =
|
||||
row > 0 ? (decoded[outputOffset + column - rowBytes] ?? 0) : 0;
|
||||
const upperLeft =
|
||||
row > 0 && column >= channels
|
||||
? (decoded[outputOffset + column - rowBytes - channels] ?? 0)
|
||||
: 0;
|
||||
decoded[outputOffset + column] = applyFilter(
|
||||
filter,
|
||||
source,
|
||||
left,
|
||||
above,
|
||||
upperLeft,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (channels === 4) return decoded;
|
||||
const rgba = Buffer.alloc(width * height * 4);
|
||||
for (
|
||||
let source = 0, target = 0;
|
||||
source < decoded.length;
|
||||
source += 3, target += 4
|
||||
) {
|
||||
rgba[target] = decoded[source] ?? 0;
|
||||
rgba[target + 1] = decoded[source + 1] ?? 0;
|
||||
rgba[target + 2] = decoded[source + 2] ?? 0;
|
||||
rgba[target + 3] = 255;
|
||||
}
|
||||
return rgba;
|
||||
};
|
||||
|
||||
const applyFilter = (
|
||||
filter: number,
|
||||
source: number,
|
||||
left: number,
|
||||
above: number,
|
||||
upperLeft: number,
|
||||
): number => {
|
||||
switch (filter) {
|
||||
case 0:
|
||||
return source;
|
||||
case 1:
|
||||
return (source + left) & 0xff;
|
||||
case 2:
|
||||
return (source + above) & 0xff;
|
||||
case 3:
|
||||
return (source + Math.floor((left + above) / 2)) & 0xff;
|
||||
case 4:
|
||||
return (source + paeth(left, above, upperLeft)) & 0xff;
|
||||
default:
|
||||
throw new TypeError("Unsupported PNG filter");
|
||||
}
|
||||
};
|
||||
|
||||
const paeth = (left: number, above: number, upperLeft: number): number => {
|
||||
const prediction = left + above - upperLeft;
|
||||
const leftDistance = Math.abs(prediction - left);
|
||||
const aboveDistance = Math.abs(prediction - above);
|
||||
const upperLeftDistance = Math.abs(prediction - upperLeft);
|
||||
return leftDistance <= aboveDistance && leftDistance <= upperLeftDistance
|
||||
? left
|
||||
: aboveDistance <= upperLeftDistance
|
||||
? above
|
||||
: upperLeft;
|
||||
};
|
||||
|
||||
const dimensions = ({ width, height }: DecodedPng) => ({ width, height });
|
||||
|
||||
const limitations = (): string[] => [
|
||||
"Pixel comparison does not perform OCR, semantic layout analysis, or perceptual color correction.",
|
||||
"Only non-interlaced 8-bit RGB and RGBA PNG screenshots are accepted.",
|
||||
];
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
@@ -0,0 +1,35 @@
|
||||
const SECRET_ASSIGNMENT =
|
||||
/\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|secret|cookie|session)\b\s*[:=]\s*(?:Bearer\s+[A-Za-z0-9._~+/=-]+|"[^"]*"|'[^']*'|[^\s,;]+)/giu;
|
||||
const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/giu;
|
||||
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
|
||||
|
||||
/** Redact credential-shaped substrings before approved console text is retained. */
|
||||
export const redactSensitiveText = (value: string): string =>
|
||||
value
|
||||
.replace(SECRET_ASSIGNMENT, (_match, name: string) => `${name}=[REDACTED]`)
|
||||
.replace(BEARER, "Bearer [REDACTED]")
|
||||
.replace(JWT, "[REDACTED_JWT]");
|
||||
|
||||
/** Return a UTF-8-bounded prefix without splitting a code point. */
|
||||
export const boundedSensitiveText = (
|
||||
value: string,
|
||||
maximumBytes: number,
|
||||
): {
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
readonly truncated: boolean;
|
||||
} => {
|
||||
const redacted = redactSensitiveText(value);
|
||||
const total = Buffer.byteLength(redacted);
|
||||
if (total <= maximumBytes)
|
||||
return { text: redacted, bytes: total, truncated: false };
|
||||
let text = "";
|
||||
let bytes = 0;
|
||||
for (const character of redacted) {
|
||||
const size = Buffer.byteLength(character);
|
||||
if (bytes + size > maximumBytes) break;
|
||||
text += character;
|
||||
bytes += size;
|
||||
}
|
||||
return { text, bytes, truncated: true };
|
||||
};
|
||||
@@ -0,0 +1,445 @@
|
||||
import { AnyMap, eachMapping } from "@jridgewell/trace-mapping";
|
||||
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import type {
|
||||
AnalyzeWebBundleInput,
|
||||
WebBundleAnalysis,
|
||||
} from "../domain/webBundleAnalysis.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
|
||||
export interface WebSourceMapRequest {
|
||||
readonly scriptKey: string;
|
||||
readonly declaredUrl: string;
|
||||
readonly fetchUrl: string;
|
||||
}
|
||||
|
||||
interface SourceMapFetchHost {
|
||||
readonly fetch: typeof fetch;
|
||||
}
|
||||
|
||||
type SourceMaps = WebBundleAnalysis["observations"]["source_maps"];
|
||||
type SourceMapItem = SourceMaps["items"][number];
|
||||
|
||||
/** Fetch and validate approved source maps without browser credentials. */
|
||||
export const fetchWebSourceMaps = async (
|
||||
requests: readonly WebSourceMapRequest[],
|
||||
input: AnalyzeWebBundleInput,
|
||||
signal?: AbortSignal,
|
||||
host: SourceMapFetchHost = { fetch: globalThis.fetch },
|
||||
): Promise<SourceMaps> => {
|
||||
const items: SourceMapItem[] = [];
|
||||
let remainingBytes = input.analysis_limits.max_total_source_map_bytes;
|
||||
for (const request of requests.slice(
|
||||
0,
|
||||
input.analysis_limits.max_source_maps,
|
||||
)) {
|
||||
if (signal?.aborted === true) throw signal.reason;
|
||||
if (remainingBytes === 0) break;
|
||||
const fetched = await fetchOne(request, {
|
||||
input,
|
||||
maximumBytes: Math.min(
|
||||
input.analysis_limits.max_source_map_bytes,
|
||||
remainingBytes,
|
||||
),
|
||||
signal,
|
||||
host,
|
||||
});
|
||||
items.push(fetched.item);
|
||||
remainingBytes = Math.max(0, remainingBytes - fetched.bytesRead);
|
||||
}
|
||||
const included = items.filter(({ status }) => status === "included").length;
|
||||
const dropped = requests.length - items.length;
|
||||
const truncated =
|
||||
dropped > 0 || items.some(({ status }) => status === "truncated");
|
||||
return {
|
||||
status: truncated
|
||||
? "truncated"
|
||||
: items.length === 0
|
||||
? "unavailable"
|
||||
: included === items.length
|
||||
? "included"
|
||||
: included > 0
|
||||
? "partial"
|
||||
: "unavailable",
|
||||
requested: requests.length,
|
||||
processed: items.length,
|
||||
dropped,
|
||||
dropped_script_keys: requests
|
||||
.slice(items.length)
|
||||
.map(({ scriptKey }) => scriptKey),
|
||||
items,
|
||||
};
|
||||
};
|
||||
|
||||
interface SourceMapFetchResult {
|
||||
readonly item: SourceMapItem;
|
||||
readonly bytesRead: number;
|
||||
}
|
||||
|
||||
interface SourceMapFetchContext {
|
||||
readonly input: AnalyzeWebBundleInput;
|
||||
readonly maximumBytes: number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly host: SourceMapFetchHost;
|
||||
}
|
||||
|
||||
const fetchOne = async (
|
||||
request: WebSourceMapRequest,
|
||||
context: SourceMapFetchContext,
|
||||
): Promise<SourceMapFetchResult> => {
|
||||
const { input, maximumBytes, signal, host } = context;
|
||||
const base = emptyItem(request);
|
||||
if (!approvedUrl(request.fetchUrl, input.allowed_origins))
|
||||
return {
|
||||
item: {
|
||||
...base,
|
||||
status: "policy_filtered",
|
||||
limitation:
|
||||
"Declared source-map URL is outside the approved exact origins.",
|
||||
},
|
||||
bytesRead: 0,
|
||||
};
|
||||
try {
|
||||
const response = await fetchFollowingApprovedRedirects(
|
||||
request.fetchUrl,
|
||||
input.allowed_origins,
|
||||
signal,
|
||||
host,
|
||||
);
|
||||
if (response === undefined)
|
||||
return {
|
||||
item: {
|
||||
...base,
|
||||
status: "policy_filtered",
|
||||
limitation: "A source-map redirect left the approved exact origins.",
|
||||
},
|
||||
bytesRead: 0,
|
||||
};
|
||||
if (!response.ok)
|
||||
return {
|
||||
item: {
|
||||
...base,
|
||||
status: "fetch_failed",
|
||||
limitation: `Source-map server returned HTTP ${String(response.status)}.`,
|
||||
},
|
||||
bytesRead: 0,
|
||||
};
|
||||
const body = await boundedResponseText(response, maximumBytes);
|
||||
return {
|
||||
item: normalizeSourceMap(request, body.text, input),
|
||||
bytesRead: body.bytes,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
return {
|
||||
item: {
|
||||
...base,
|
||||
status: isLimitError(cause) ? "truncated" : "fetch_failed",
|
||||
limitation: isLimitError(cause)
|
||||
? "Source-map response exceeded the remaining approved byte budget."
|
||||
: "Source-map fetch or validation failed.",
|
||||
},
|
||||
bytesRead: cause instanceof SourceMapByteLimitError ? cause.bytesRead : 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFollowingApprovedRedirects = async (
|
||||
initialUrl: string,
|
||||
allowedOrigins: readonly string[],
|
||||
signal: AbortSignal | undefined,
|
||||
host: SourceMapFetchHost,
|
||||
): Promise<Response | undefined> => {
|
||||
let current = initialUrl;
|
||||
for (let redirect = 0; redirect <= 5; redirect += 1) {
|
||||
if (!approvedUrl(current, allowedOrigins)) return undefined;
|
||||
const response = await timedFetch(current, signal, host);
|
||||
if (response.status < 300 || response.status >= 400) return response;
|
||||
const location = response.headers.get("location");
|
||||
if (location === null) return response;
|
||||
current = new URL(location, current).href;
|
||||
}
|
||||
throw new Error("source_map_redirect_limit");
|
||||
};
|
||||
|
||||
const timedFetch = async (
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
host: SourceMapFetchHost,
|
||||
): Promise<Response> => {
|
||||
const timeout = AbortSignal.timeout(5_000);
|
||||
return await host.fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json, application/source-map+json;q=0.9" },
|
||||
redirect: "manual",
|
||||
credentials: "omit",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
||||
});
|
||||
};
|
||||
|
||||
const boundedResponseText = async (
|
||||
response: Response,
|
||||
maximumBytes: number,
|
||||
): Promise<{ readonly text: string; readonly bytes: number }> => {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > maximumBytes)
|
||||
throw new SourceMapByteLimitError(0);
|
||||
if (response.body === null) return { text: "", bytes: 0 };
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > maximumBytes) {
|
||||
await reader.cancel();
|
||||
throw new SourceMapByteLimitError(bytes);
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return { text, bytes };
|
||||
};
|
||||
|
||||
class SourceMapByteLimitError extends RangeError {
|
||||
constructor(readonly bytesRead: number) {
|
||||
super("source_map_bytes");
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSourceMap = (
|
||||
request: WebSourceMapRequest,
|
||||
text: string,
|
||||
input: AnalyzeWebBundleInput,
|
||||
): SourceMapItem => {
|
||||
const base = emptyItem(request);
|
||||
if (!validSourceMapEnvelope(text))
|
||||
return {
|
||||
...base,
|
||||
status: "invalid",
|
||||
limitation: "Source-map JSON is not a bounded version 3 map.",
|
||||
};
|
||||
try {
|
||||
const map = new AnyMap(text, request.fetchUrl);
|
||||
if (map.sources.length > input.analysis_limits.max_original_sources)
|
||||
return {
|
||||
...base,
|
||||
status: "truncated",
|
||||
limitation: "Source map exceeds the original-source inventory limit.",
|
||||
};
|
||||
const resolvedBySource = new Map<string, string>();
|
||||
const originalSources = map.sources.map((source, index) => {
|
||||
const content = map.sourcesContent?.[index];
|
||||
const resolved =
|
||||
map.resolvedSources[index] ?? source ?? "[unknown-source]";
|
||||
if (source !== null) resolvedBySource.set(source, resolved);
|
||||
return {
|
||||
source: sanitizeSource(resolved),
|
||||
artifact:
|
||||
typeof content === "string"
|
||||
? createWebTextArtifact(content, sourceMediaType(source))
|
||||
: null,
|
||||
};
|
||||
});
|
||||
const mappings: SourceMapItem["mappings"] = [];
|
||||
let totalMappings = 0;
|
||||
eachMapping(map, (mapping) => {
|
||||
if (
|
||||
mapping.source === null ||
|
||||
mapping.originalLine === null ||
|
||||
mapping.originalColumn === null
|
||||
)
|
||||
return;
|
||||
totalMappings += 1;
|
||||
if (mappings.length >= input.analysis_limits.max_source_map_mappings)
|
||||
return;
|
||||
mappings.push({
|
||||
generated_line: mapping.generatedLine,
|
||||
generated_column: mapping.generatedColumn,
|
||||
source: sanitizeSource(
|
||||
resolvedBySource.get(mapping.source) ?? mapping.source,
|
||||
),
|
||||
original_line: mapping.originalLine,
|
||||
original_column: mapping.originalColumn,
|
||||
name: mapping.name?.slice(0, 1_024) ?? null,
|
||||
});
|
||||
});
|
||||
const modules = originalModuleEdges(
|
||||
originalSources,
|
||||
input.analysis_limits.max_findings,
|
||||
);
|
||||
const mappingTruncated = totalMappings > mappings.length;
|
||||
const limitations = [
|
||||
...(mappingTruncated
|
||||
? [
|
||||
`Mappings were truncated from ${String(totalMappings)} to ${String(mappings.length)}.`,
|
||||
]
|
||||
: []),
|
||||
...(modules.truncated
|
||||
? [
|
||||
`Original module edges were truncated to ${String(modules.edges.length)}.`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return {
|
||||
...base,
|
||||
status: mappingTruncated || modules.truncated ? "truncated" : "included",
|
||||
artifact: createWebTextArtifact(text, "application/source-map+json"),
|
||||
original_sources: originalSources,
|
||||
original_module_edges: modules.edges,
|
||||
mappings,
|
||||
limitation: limitations.length === 0 ? null : limitations.join(" "),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...base,
|
||||
status: "invalid",
|
||||
limitation: "Source-map mappings could not be decoded safely.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const originalModuleEdges = (
|
||||
sources: SourceMapItem["original_sources"],
|
||||
maximum: number,
|
||||
): {
|
||||
readonly edges: SourceMapItem["original_module_edges"];
|
||||
readonly truncated: boolean;
|
||||
} => {
|
||||
const edges: SourceMapItem["original_module_edges"] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const source of sources) {
|
||||
if (source.artifact === null) continue;
|
||||
for (const detector of originalImportDetectors) {
|
||||
for (const match of source.artifact.text.matchAll(detector.pattern)) {
|
||||
const specifier = match[1]?.slice(0, 4_096);
|
||||
if (specifier === undefined) continue;
|
||||
const key = `${source.source}\0${detector.kind}\0${specifier}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (edges.length >= maximum) return { edges, truncated: true };
|
||||
edges.push({
|
||||
from_source: source.source,
|
||||
kind: detector.kind,
|
||||
specifier,
|
||||
resolved_source: resolveOriginalSource(specifier, source.source),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { edges, truncated: false };
|
||||
};
|
||||
|
||||
const validSourceMapEnvelope = (text: string): boolean => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!isRecord(parsed) || parsed.version !== 3) return false;
|
||||
if (typeof parsed.mappings === "string")
|
||||
return Array.isArray(parsed.sources) && Array.isArray(parsed.names);
|
||||
if (!Array.isArray(parsed.sections) || parsed.sections.length > 10_000)
|
||||
return false;
|
||||
const pending: unknown[] = [...parsed.sections];
|
||||
let visited = 0;
|
||||
while (pending.length > 0) {
|
||||
const section = pending.pop();
|
||||
if (!isRecord(section) || !isRecord(section.offset) || !("map" in section))
|
||||
return false;
|
||||
visited += 1;
|
||||
if (visited > 10_000) return false;
|
||||
const map = section.map;
|
||||
if (!isRecord(map) || map.version !== 3) return false;
|
||||
if (Array.isArray(map.sections)) pending.push(...map.sections);
|
||||
else if (
|
||||
typeof map.mappings !== "string" ||
|
||||
!Array.isArray(map.sources) ||
|
||||
!Array.isArray(map.names)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const approvedUrl = (
|
||||
value: string,
|
||||
allowedOrigins: readonly string[],
|
||||
): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (
|
||||
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
||||
parsed.username === "" &&
|
||||
parsed.password === "" &&
|
||||
allowedOrigins.includes(parsed.origin)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const emptyItem = (request: WebSourceMapRequest): SourceMapItem => ({
|
||||
script_key: request.scriptKey,
|
||||
declared_url: request.declaredUrl,
|
||||
status: "fetch_failed",
|
||||
artifact: null,
|
||||
original_sources: [],
|
||||
original_module_edges: [],
|
||||
mappings: [],
|
||||
limitation: null,
|
||||
});
|
||||
|
||||
const sanitizeSource = (value: string): string => {
|
||||
const bounded = value.slice(0, 4_096);
|
||||
try {
|
||||
const parsed = new URL(bounded);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||
? sanitizeBrowserUrl(parsed.href).url
|
||||
: `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
||||
} catch {
|
||||
return bounded.split("#", 1)[0]?.split("?", 1)[0] ?? "";
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOriginalSource = (
|
||||
specifier: string,
|
||||
base: string,
|
||||
): string | null => {
|
||||
try {
|
||||
return sanitizeSource(new URL(specifier, base).href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sourceMediaType = (source: string | null): string =>
|
||||
source?.endsWith(".ts") || source?.endsWith(".tsx")
|
||||
? "text/typescript"
|
||||
: "text/javascript";
|
||||
|
||||
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isLimitError = (value: unknown): value is RangeError =>
|
||||
value instanceof RangeError;
|
||||
|
||||
const originalImportDetectors = [
|
||||
{
|
||||
kind: "static_import" as const,
|
||||
pattern:
|
||||
/\b(?:import|export)\s+(?:[^'"\n]*?\s+from\s+)?["']([^"'\n]+)["']/gu,
|
||||
},
|
||||
{
|
||||
kind: "dynamic_import" as const,
|
||||
pattern: /\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/gu,
|
||||
},
|
||||
{
|
||||
kind: "require" as const,
|
||||
pattern: /\brequire\s*\(\s*["']([^"'\n]+)["']\s*\)/gu,
|
||||
},
|
||||
] as const;
|
||||
@@ -38,6 +38,14 @@ export const CLI_COMMAND_NAMES = [
|
||||
"policy",
|
||||
"list-browser-targets",
|
||||
"inspect-web-page",
|
||||
"analyze-web-bundle",
|
||||
"observe-web-session",
|
||||
"discover-webmcp-tools",
|
||||
"compare-web-captures",
|
||||
"capture-web-screenshot",
|
||||
"compare-web-screenshots",
|
||||
"list-electron-targets",
|
||||
"inspect-electron-page",
|
||||
] as const;
|
||||
|
||||
const toolCatalog = TOOL_CONTRACTS.map((contract) => ({
|
||||
|
||||
@@ -28,6 +28,8 @@ import { projectReferenceSourceImportError } from "./application/ReferenceSource
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { registerPolicyCommands } from "./cliPolicyCommands.js";
|
||||
import { registerBrowserCommands } from "./cliBrowserCommands.js";
|
||||
import { registerAdvancedBrowserCommands } from "./cliBrowserAdvancedCommands.js";
|
||||
import { registerElectronCommands } from "./cliElectronCommands.js";
|
||||
|
||||
/**
|
||||
* Build the one-shot Incur CLI without starting Hopper at import time.
|
||||
@@ -75,6 +77,8 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
|
||||
registerProcessCommands(cli, logger);
|
||||
registerPolicyCommands(cli, logger);
|
||||
registerBrowserCommands(cli, logger);
|
||||
registerAdvancedBrowserCommands(cli, logger);
|
||||
registerElectronCommands(cli, logger);
|
||||
return cli;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
import {
|
||||
captureWebScreenshot,
|
||||
compareWebCaptureEvidence,
|
||||
compareWebScreenshotEvidence,
|
||||
discoverWebMcpTools,
|
||||
} from "./application/BrowserObservationService.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
|
||||
import { compareWebCapturesInputSchema } from "./domain/webCaptureDiff.js";
|
||||
import { discoverWebMcpToolsInputSchema } from "./domain/webMcpDiscovery.js";
|
||||
import {
|
||||
captureWebScreenshotInputSchema,
|
||||
compareWebScreenshotsInputSchema,
|
||||
} from "./domain/webScreenshot.js";
|
||||
import type { JsonValue } from "./domain/jsonValue.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const scopeOptions = {
|
||||
allowedOrigins: z.array(z.string().min(1)).optional(),
|
||||
approved: z.boolean().default(false),
|
||||
};
|
||||
|
||||
/** Register WebMCP, capture-diff, and screenshot CLI equivalents. */
|
||||
export const registerAdvancedBrowserCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
registerWebMcp(cli, logger);
|
||||
registerCaptureDiff(cli, logger);
|
||||
registerScreenshot(cli, logger);
|
||||
registerScreenshotDiff(cli, logger);
|
||||
};
|
||||
|
||||
const registerWebMcp = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("discover-webmcp-tools", {
|
||||
description: "Passively discover page-declared WebMCP tools",
|
||||
args: z.object({ endpoint: z.string(), targetId: z.string() }),
|
||||
options: z.object({
|
||||
...scopeOptions,
|
||||
observationMs: z.number().int().min(0).max(10_000).default(100),
|
||||
maxTools: z.number().int().min(1).max(5_000).default(500),
|
||||
maxSchemaBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_024 * 1_024)
|
||||
.default(256 * 1_024),
|
||||
maxSchemaNodes: z.number().int().min(1).max(100_000).default(5_000),
|
||||
maxSchemaDepth: z.number().int().min(1).max(100).default(20),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "discover-webmcp-tools", async () => {
|
||||
const context = await browserContext();
|
||||
if (!context.ok) return context.error;
|
||||
const parsed = discoverWebMcpToolsInputSchema.safeParse({
|
||||
cdp_endpoint: args.endpoint,
|
||||
allowed_origins:
|
||||
options.allowedOrigins ?? context.allowedBrowserOrigins,
|
||||
target_id: args.targetId,
|
||||
approved: options.approved,
|
||||
observation_ms: options.observationMs,
|
||||
max_tools: options.maxTools,
|
||||
max_schema_bytes: options.maxSchemaBytes,
|
||||
max_schema_nodes: options.maxSchemaNodes,
|
||||
max_schema_depth: options.maxSchemaDepth,
|
||||
});
|
||||
if (!parsed.success) return inputError("discover_webmcp_tools");
|
||||
const result = await discoverWebMcpTools(
|
||||
context.provider,
|
||||
context.authority,
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const registerCaptureDiff = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("compare-web-captures", {
|
||||
description: "Compare two normalized web capture JSON values",
|
||||
args: z.object({ beforeJson: z.string(), afterJson: z.string() }),
|
||||
options: z.object({
|
||||
maxChanges: z.number().int().min(1).max(20_000).default(2_000),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "compare-web-captures", async () => {
|
||||
const before = parseJson(args.beforeJson);
|
||||
const after = parseJson(args.afterJson);
|
||||
const parsed = compareWebCapturesInputSchema.safeParse({
|
||||
before,
|
||||
after,
|
||||
max_changes: options.maxChanges,
|
||||
});
|
||||
if (!parsed.success) return inputError("compare_web_captures");
|
||||
const result = await compareWebCaptureEvidence(
|
||||
new CdpBrowserProvider(),
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const registerScreenshot = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("capture-web-screenshot", {
|
||||
description: "Capture an explicitly approved visible page viewport",
|
||||
args: z.object({ endpoint: z.string(), targetId: z.string() }),
|
||||
options: z.object({
|
||||
...scopeOptions,
|
||||
screenshotApproved: z.boolean().default(false),
|
||||
maximumImageBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(8 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "capture-web-screenshot", async () => {
|
||||
const context = await browserContext();
|
||||
if (!context.ok) return context.error;
|
||||
const parsed = captureWebScreenshotInputSchema.safeParse({
|
||||
cdp_endpoint: args.endpoint,
|
||||
allowed_origins:
|
||||
options.allowedOrigins ?? context.allowedBrowserOrigins,
|
||||
target_id: args.targetId,
|
||||
approved: options.approved,
|
||||
screenshot_approved: options.screenshotApproved,
|
||||
maximum_image_bytes: options.maximumImageBytes,
|
||||
});
|
||||
if (!parsed.success) return inputError("capture_web_screenshot");
|
||||
const result = await captureWebScreenshot(
|
||||
context.provider,
|
||||
context.authority,
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const registerScreenshotDiff = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("compare-web-screenshots", {
|
||||
description: "Compare two self-verifying PNG artifact JSON values",
|
||||
args: z.object({ beforeJson: z.string(), afterJson: z.string() }),
|
||||
options: z.object({
|
||||
channelThreshold: z.number().int().min(0).max(255).default(0),
|
||||
maximumPixels: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(32_000_000)
|
||||
.default(16_000_000),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "compare-web-screenshots", async () => {
|
||||
const parsed = compareWebScreenshotsInputSchema.safeParse({
|
||||
before: parseJson(args.beforeJson),
|
||||
after: parseJson(args.afterJson),
|
||||
channel_threshold: options.channelThreshold,
|
||||
maximum_pixels: options.maximumPixels,
|
||||
});
|
||||
if (!parsed.success) return inputError("compare_web_screenshots");
|
||||
const result = await compareWebScreenshotEvidence(
|
||||
new CdpBrowserProvider(),
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const browserContext = async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return { ok: false as const, error: cliError(config.error) };
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok)
|
||||
return { ok: false as const, error: cliError(authority.error) };
|
||||
return {
|
||||
ok: true as const,
|
||||
authority: authority.value,
|
||||
provider: new CdpBrowserProvider(),
|
||||
allowedBrowserOrigins: config.value.browserAllowedOrigins,
|
||||
};
|
||||
};
|
||||
|
||||
const parseJson = (value: string): unknown => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const inputError = (operation: string): JsonValue =>
|
||||
cliError(new AnalysisInputError(operation));
|
||||
|
||||
const cliError = (
|
||||
error: Parameters<typeof projectAnalysisError>[0],
|
||||
): JsonValue => ({
|
||||
error: "Browser observation failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
import {
|
||||
analyzeWebBundle,
|
||||
inspectWebPage,
|
||||
listBrowserTargets,
|
||||
observeWebSession,
|
||||
} from "./application/BrowserObservationService.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
} from "./domain/browserObservation.js";
|
||||
import { analyzeWebBundleInputSchema } from "./domain/webBundleAnalysis.js";
|
||||
import { observeWebSessionInputSchema } from "./domain/browserSession.js";
|
||||
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
|
||||
import type { JsonValue } from "./domain/jsonValue.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
@@ -33,6 +37,8 @@ export const registerBrowserCommands = (
|
||||
): void => {
|
||||
registerTargetList(cli, logger);
|
||||
registerPageInspection(cli, logger);
|
||||
registerBundleAnalysis(cli, logger);
|
||||
registerObservationSession(cli, logger);
|
||||
};
|
||||
|
||||
const registerTargetList = (
|
||||
@@ -86,11 +92,30 @@ const registerPageInspection = (
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
observationMs: z.number().int().min(0).max(10_000).default(500),
|
||||
includeAccessibilityText: z.boolean().default(false),
|
||||
includeConsoleText: z.boolean().default(false),
|
||||
consoleTextApproved: z.boolean().default(false),
|
||||
includeJsonBodyShapes: z.boolean().default(false),
|
||||
jsonBodySchemaApproved: z.boolean().default(false),
|
||||
includeWebsocketShapes: z.boolean().default(false),
|
||||
websocketShapeApproved: z.boolean().default(false),
|
||||
includeScriptSources: z.boolean().default(false),
|
||||
includeStorageKeys: z.boolean().default(false),
|
||||
maxFrames: z.number().int().min(1).max(1_000).default(200),
|
||||
maxDomNodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
maxAxNodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
maxAxTextFieldBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024)
|
||||
.default(1_024),
|
||||
maxTotalAxTextBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_024 * 1_024)
|
||||
.default(64 * 1_024),
|
||||
maxScripts: z.number().int().min(1).max(1_000).default(200),
|
||||
maxResources: z.number().int().min(1).max(10_000).default(2_000),
|
||||
maxWorkers: z.number().int().min(1).max(5_000).default(500),
|
||||
@@ -109,7 +134,45 @@ const registerPageInspection = (
|
||||
.default(4 * 1_024 * 1_024),
|
||||
maxNetworkEvents: z.number().int().min(1).max(10_000).default(1_000),
|
||||
maxConsoleEvents: z.number().int().min(1).max(2_000).default(200),
|
||||
maxConsoleTextFieldBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024)
|
||||
.default(1_024),
|
||||
maxTotalConsoleTextBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_024 * 1_024)
|
||||
.default(64 * 1_024),
|
||||
maxJsonBodyBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(4 * 1_024 * 1_024)
|
||||
.default(1_024 * 1_024),
|
||||
maxTotalJsonBodyBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
maxJsonShapeNodes: z.number().int().min(1).max(100_000).default(5_000),
|
||||
maxJsonShapeDepth: z.number().int().min(1).max(100).default(20),
|
||||
maxWebsocketEvents: z.number().int().min(1).max(5_000).default(500),
|
||||
maxWebsocketShapeBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_024 * 1_024)
|
||||
.default(64 * 1_024),
|
||||
maxTotalWebsocketShapeBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(1_024 * 1_024),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "inspect-web-page", async () => {
|
||||
@@ -122,12 +185,21 @@ const registerPageInspection = (
|
||||
approved: options.approved,
|
||||
target_id: args.targetId,
|
||||
observation_ms: options.observationMs,
|
||||
include_accessibility_text: options.includeAccessibilityText,
|
||||
include_console_text: options.includeConsoleText,
|
||||
console_text_approved: options.consoleTextApproved,
|
||||
include_json_body_shapes: options.includeJsonBodyShapes,
|
||||
json_body_schema_approved: options.jsonBodySchemaApproved,
|
||||
include_websocket_shapes: options.includeWebsocketShapes,
|
||||
websocket_shape_approved: options.websocketShapeApproved,
|
||||
include_script_sources: options.includeScriptSources,
|
||||
include_storage_keys: options.includeStorageKeys,
|
||||
limits: {
|
||||
max_frames: options.maxFrames,
|
||||
max_dom_nodes: options.maxDomNodes,
|
||||
max_ax_nodes: options.maxAxNodes,
|
||||
max_ax_text_field_bytes: options.maxAxTextFieldBytes,
|
||||
max_total_ax_text_bytes: options.maxTotalAxTextBytes,
|
||||
max_scripts: options.maxScripts,
|
||||
max_resources: options.maxResources,
|
||||
max_workers: options.maxWorkers,
|
||||
@@ -136,7 +208,16 @@ const registerPageInspection = (
|
||||
max_total_script_source_bytes: options.maxTotalScriptSourceBytes,
|
||||
max_network_events: options.maxNetworkEvents,
|
||||
max_console_events: options.maxConsoleEvents,
|
||||
max_console_text_field_bytes: options.maxConsoleTextFieldBytes,
|
||||
max_total_console_text_bytes: options.maxTotalConsoleTextBytes,
|
||||
max_json_body_bytes: options.maxJsonBodyBytes,
|
||||
max_total_json_body_bytes: options.maxTotalJsonBodyBytes,
|
||||
max_json_shape_nodes: options.maxJsonShapeNodes,
|
||||
max_json_shape_depth: options.maxJsonShapeDepth,
|
||||
max_websocket_events: options.maxWebsocketEvents,
|
||||
max_websocket_shape_bytes: options.maxWebsocketShapeBytes,
|
||||
max_total_websocket_shape_bytes:
|
||||
options.maxTotalWebsocketShapeBytes,
|
||||
},
|
||||
});
|
||||
if (!parsed.success)
|
||||
@@ -151,6 +232,122 @@ const registerPageInspection = (
|
||||
});
|
||||
};
|
||||
|
||||
const registerBundleAnalysis = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("analyze-web-bundle", {
|
||||
description: "Capture and statically analyze an approved page bundle",
|
||||
args: z.object({
|
||||
endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"),
|
||||
targetId: z.string().describe("Target ID from list-browser-targets"),
|
||||
}),
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
sourceCaptureApproved: z.boolean().default(false),
|
||||
observationMs: z.number().int().min(0).max(10_000).default(500),
|
||||
fetchSourceMaps: z.boolean().default(false),
|
||||
sourceMapFetchApproved: z.boolean().default(false),
|
||||
maxFindings: z.number().int().min(1).max(10_000).default(1_000),
|
||||
maxAstNodes: z.number().int().min(1).max(2_000_000).default(250_000),
|
||||
maxSourceMaps: z.number().int().min(1).max(1_000).default(100),
|
||||
maxSourceMapBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
maxTotalSourceMapBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(64 * 1_024 * 1_024)
|
||||
.default(16 * 1_024 * 1_024),
|
||||
maxSourceMapMappings: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100_000)
|
||||
.default(10_000),
|
||||
maxOriginalSources: z.number().int().min(1).max(20_000).default(2_000),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "analyze-web-bundle", async () => {
|
||||
const context = await browserContext();
|
||||
if (!context.ok) return context.error;
|
||||
const parsed = analyzeWebBundleInputSchema.safeParse({
|
||||
cdp_endpoint: args.endpoint,
|
||||
allowed_origins:
|
||||
options.allowedOrigins ?? context.allowedBrowserOrigins,
|
||||
approved: options.approved,
|
||||
target_id: args.targetId,
|
||||
observation_ms: options.observationMs,
|
||||
include_script_sources: true,
|
||||
source_capture_approved: options.sourceCaptureApproved,
|
||||
fetch_source_maps: options.fetchSourceMaps,
|
||||
source_map_fetch_approved: options.sourceMapFetchApproved,
|
||||
analysis_limits: {
|
||||
max_findings: options.maxFindings,
|
||||
max_ast_nodes: options.maxAstNodes,
|
||||
max_source_maps: options.maxSourceMaps,
|
||||
max_source_map_bytes: options.maxSourceMapBytes,
|
||||
max_total_source_map_bytes: options.maxTotalSourceMapBytes,
|
||||
max_source_map_mappings: options.maxSourceMapMappings,
|
||||
max_original_sources: options.maxOriginalSources,
|
||||
},
|
||||
});
|
||||
if (!parsed.success)
|
||||
return cliError(new AnalysisInputError("analyze_web_bundle"));
|
||||
const result = await analyzeWebBundle(
|
||||
context.provider,
|
||||
context.authority,
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const registerObservationSession = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("observe-web-session", {
|
||||
description: "Observe user-driven page navigation during a bounded window",
|
||||
args: z.object({
|
||||
endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"),
|
||||
targetId: z.string().describe("Target ID from list-browser-targets"),
|
||||
}),
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
observationMs: z.number().int().min(1).max(60_000).default(10_000),
|
||||
maxTimelineEvents: z.number().int().min(1).max(20_000).default(2_000),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "observe-web-session", async () => {
|
||||
const context = await browserContext();
|
||||
if (!context.ok) return context.error;
|
||||
const parsed = observeWebSessionInputSchema.safeParse({
|
||||
cdp_endpoint: args.endpoint,
|
||||
allowed_origins:
|
||||
options.allowedOrigins ?? context.allowedBrowserOrigins,
|
||||
target_id: args.targetId,
|
||||
approved: options.approved,
|
||||
observation_ms: options.observationMs,
|
||||
max_timeline_events: options.maxTimelineEvents,
|
||||
});
|
||||
if (!parsed.success)
|
||||
return cliError(new AnalysisInputError("observe_web_session"));
|
||||
const result = await observeWebSession(
|
||||
context.provider,
|
||||
context.authority,
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const browserContext = async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return { ok: false as const, error: cliError(config.error) };
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
import {
|
||||
inspectElectronPage,
|
||||
listElectronTargets,
|
||||
} from "./application/ElectronObservationService.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { CdpElectronProvider } from "./browser/CdpElectronProvider.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import {
|
||||
inspectElectronPageInputSchema,
|
||||
listElectronTargetsInputSchema,
|
||||
} from "./domain/electronObservation.js";
|
||||
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
|
||||
import type { JsonValue } from "./domain/jsonValue.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const scopeOptions = {
|
||||
allowedFileRoots: z
|
||||
.array(z.string().min(1))
|
||||
.optional()
|
||||
.describe("Filesystem roots; defaults to REA_ELECTRON_FILE_ROOTS_JSON"),
|
||||
approved: z.boolean().default(false),
|
||||
};
|
||||
|
||||
/** Register CLI equivalents of the Electron MCP tools. */
|
||||
export const registerElectronCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("list-electron-targets", {
|
||||
description: "List root-confined file pages from Electron CDP",
|
||||
args: z.object({ endpoint: z.string() }),
|
||||
options: z.object({
|
||||
...scopeOptions,
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(200).default(100),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "list-electron-targets", async () => {
|
||||
const context = await electronContext();
|
||||
if (!context.ok) return context.error;
|
||||
const parsed = listElectronTargetsInputSchema.safeParse({
|
||||
cdp_endpoint: args.endpoint,
|
||||
allowed_file_roots:
|
||||
options.allowedFileRoots ?? context.allowedFileRoots,
|
||||
approved: options.approved,
|
||||
offset: options.offset,
|
||||
limit: options.limit,
|
||||
});
|
||||
if (!parsed.success) return inputError("list_electron_targets");
|
||||
const result = await listElectronTargets(
|
||||
context.provider,
|
||||
context.authority,
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
cli.command("inspect-electron-page", {
|
||||
description: "Passively inspect one root-confined Electron file page",
|
||||
args: z.object({ endpoint: z.string(), targetId: z.string() }),
|
||||
options: z.object({
|
||||
...scopeOptions,
|
||||
observationMs: z.number().int().min(0).max(10_000).default(100),
|
||||
includeScriptSources: z.boolean().default(false),
|
||||
sourceCaptureApproved: z.boolean().default(false),
|
||||
maxFrames: z.number().int().min(1).max(1_000).default(200),
|
||||
maxDomNodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
maxScripts: z.number().int().min(1).max(2_000).default(500),
|
||||
maxResources: z.number().int().min(1).max(10_000).default(2_000),
|
||||
maxScriptSourceBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(4 * 1_024 * 1_024)
|
||||
.default(1_024 * 1_024),
|
||||
maxTotalScriptSourceBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "inspect-electron-page", async () => {
|
||||
const context = await electronContext();
|
||||
if (!context.ok) return context.error;
|
||||
const parsed = inspectElectronPageInputSchema.safeParse({
|
||||
cdp_endpoint: args.endpoint,
|
||||
allowed_file_roots:
|
||||
options.allowedFileRoots ?? context.allowedFileRoots,
|
||||
target_id: args.targetId,
|
||||
approved: options.approved,
|
||||
observation_ms: options.observationMs,
|
||||
include_script_sources: options.includeScriptSources,
|
||||
source_capture_approved: options.sourceCaptureApproved,
|
||||
limits: {
|
||||
max_frames: options.maxFrames,
|
||||
max_dom_nodes: options.maxDomNodes,
|
||||
max_scripts: options.maxScripts,
|
||||
max_resources: options.maxResources,
|
||||
max_script_source_bytes: options.maxScriptSourceBytes,
|
||||
max_total_script_source_bytes: options.maxTotalScriptSourceBytes,
|
||||
},
|
||||
});
|
||||
if (!parsed.success) return inputError("inspect_electron_page");
|
||||
const result = await inspectElectronPage(
|
||||
context.provider,
|
||||
context.authority,
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const electronContext = async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return { ok: false as const, error: cliError(config.error) };
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok)
|
||||
return { ok: false as const, error: cliError(authority.error) };
|
||||
return {
|
||||
ok: true as const,
|
||||
authority: authority.value,
|
||||
provider: new CdpElectronProvider(),
|
||||
allowedFileRoots: config.value.electronFileRoots,
|
||||
};
|
||||
};
|
||||
|
||||
const inputError = (operation: string): JsonValue =>
|
||||
cliError(new AnalysisInputError(operation));
|
||||
|
||||
const cliError = (
|
||||
error: Parameters<typeof projectAnalysisError>[0],
|
||||
): JsonValue => ({
|
||||
error: "Electron observation failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
@@ -3,14 +3,13 @@ import { Cli, z } from "incur";
|
||||
import { runCrossVersionInvestigation } from "./application/CrossVersionInvestigation.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import {
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
projectAnalysisError,
|
||||
type AnalysisError,
|
||||
} from "./domain/errors.js";
|
||||
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import {
|
||||
authorizeFileReadWithDeferredWrite,
|
||||
authorizeRootPermission,
|
||||
} from "./application/DeferredFileAuthorization.js";
|
||||
|
||||
const investigationOptionsSchema = z.object({
|
||||
yes: z
|
||||
@@ -90,44 +89,23 @@ const runInvestigation = async (
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) return cliError(authority.error);
|
||||
for (const scope of [
|
||||
const workspaceAuthorization = await authorizeFileReadWithDeferredWrite(
|
||||
authority.value,
|
||||
{
|
||||
capability: "investigation_workspace_read" as const,
|
||||
roots: [args.workspacePath],
|
||||
access: "write" as const,
|
||||
path: args.workspacePath,
|
||||
readCapability: "investigation_workspace_read",
|
||||
writeCapability: "investigation_workspace_write",
|
||||
operation: "investigate_versions",
|
||||
},
|
||||
{
|
||||
capability: "investigation_workspace_write" as const,
|
||||
roots: [args.workspacePath],
|
||||
access: "write" as const,
|
||||
},
|
||||
{
|
||||
capability: "investigation_input" as const,
|
||||
roots: [args.leftPath, args.rightPath],
|
||||
access: "read" as const,
|
||||
},
|
||||
]) {
|
||||
const authorized = await authority.value.authorize(
|
||||
{
|
||||
capability: scope.capability,
|
||||
roots: scope.roots,
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: `investigate_versions:${scope.capability}:${scope.roots.join(":")}`,
|
||||
},
|
||||
scope.access,
|
||||
);
|
||||
if (!authorized.ok)
|
||||
return cliError(
|
||||
authorized.error instanceof PermissionRequiredError
|
||||
? authorized.error
|
||||
: new AnalysisProtocolError(authorized.error.message, {
|
||||
cause: authorized.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
);
|
||||
if (!workspaceAuthorization.ok) return cliError(workspaceAuthorization.error);
|
||||
const inputAuthorization = await authorizeRootPermission(authority.value, {
|
||||
capability: "investigation_input",
|
||||
roots: [args.leftPath, args.rightPath],
|
||||
access: "read",
|
||||
operation: "investigate_versions",
|
||||
});
|
||||
if (!inputAuthorization.ok) return cliError(inputAuthorization.error);
|
||||
const investigated = await runCrossVersionInvestigation(
|
||||
{
|
||||
approved: true,
|
||||
@@ -156,6 +134,7 @@ const runInvestigation = async (
|
||||
{
|
||||
inputRoots: config.value.investigationInputRoots,
|
||||
integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled,
|
||||
authorizeWorkspaceWrite: workspaceAuthorization.value.authorizeWrite,
|
||||
},
|
||||
);
|
||||
return investigated.ok
|
||||
|
||||
+17
-14
@@ -3,7 +3,7 @@ import { Cli, z } from "incur";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import {
|
||||
readProjectPermissionStore,
|
||||
writeProjectPermissionStore,
|
||||
revokeProjectPermissionGrant,
|
||||
} from "./application/ProjectPermissionStore.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig, type AppConfig } from "./config.js";
|
||||
@@ -17,6 +17,7 @@ import type { Logger } from "./logger.js";
|
||||
const capabilitySchema = z.enum([
|
||||
"process_capture",
|
||||
"browser_observe",
|
||||
"electron_observe",
|
||||
"evidence_read",
|
||||
"evidence_write",
|
||||
"investigation_input",
|
||||
@@ -68,24 +69,26 @@ export const registerPolicyCommands = (
|
||||
if (args.action === "revoke") {
|
||||
if (args.value === undefined)
|
||||
return { error: "Grant ID is required for policy revoke" };
|
||||
const result = await readProjectGrants(config.value);
|
||||
if (!result.ok) return result.error;
|
||||
const retained = result.grants.filter(
|
||||
({ grant_id }) => grant_id !== args.value,
|
||||
const { permissionProjectRoot, permissionProjectStore } =
|
||||
config.value;
|
||||
if (
|
||||
permissionProjectRoot === undefined ||
|
||||
permissionProjectStore === undefined
|
||||
)
|
||||
return { error: "Project policy is not configured" };
|
||||
const revoked = await revokeProjectPermissionGrant(
|
||||
permissionProjectStore,
|
||||
permissionProjectRoot,
|
||||
args.value,
|
||||
);
|
||||
if (retained.length === result.grants.length)
|
||||
return { error: "Grant was not found", grant_id: args.value };
|
||||
const written = await writeProjectPermissionStore(
|
||||
result.path,
|
||||
result.root,
|
||||
retained,
|
||||
);
|
||||
return written.ok
|
||||
return revoked.ok && revoked.value
|
||||
? {
|
||||
revoked: args.value,
|
||||
reload: "Send SIGHUP to the REA MCP process",
|
||||
}
|
||||
: { error: written.error.message };
|
||||
: revoked.ok
|
||||
? { error: "Grant was not found", grant_id: args.value }
|
||||
: { error: revoked.error.message };
|
||||
}
|
||||
const capability = capabilitySchema.safeParse(args.value);
|
||||
if (!capability.success)
|
||||
|
||||
+61
-3
@@ -14,6 +14,7 @@ import {
|
||||
browserEndpointSchema,
|
||||
browserOriginSchema,
|
||||
} from "./domain/browserObservation.js";
|
||||
import { electronFileRootsSchema } from "./domain/electronObservation.js";
|
||||
|
||||
const DEFAULT_HOPPER_LAUNCHER_PATH =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
@@ -38,6 +39,9 @@ export interface AppConfig {
|
||||
readonly browserObservationEnabled: boolean;
|
||||
readonly browserCdpEndpoints: readonly string[];
|
||||
readonly browserAllowedOrigins: readonly string[];
|
||||
readonly electronObservationEnabled: boolean;
|
||||
readonly electronCdpEndpoints: readonly string[];
|
||||
readonly electronFileRoots: readonly string[];
|
||||
readonly permissionCeilings: readonly PermissionCeiling[];
|
||||
readonly administratorPermissionGrants: readonly PermissionGrant[];
|
||||
readonly permissionProjectRoot: string | undefined;
|
||||
@@ -76,6 +80,9 @@ const environmentSchema = z
|
||||
REA_BROWSER_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
REA_BROWSER_CDP_ENDPOINTS_JSON: z.string().default("[]"),
|
||||
REA_BROWSER_ALLOWED_ORIGINS_JSON: z.string().default("[]"),
|
||||
REA_ELECTRON_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
REA_ELECTRON_CDP_ENDPOINTS_JSON: z.string().default("[]"),
|
||||
REA_ELECTRON_FILE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_PERMISSION_PROJECT_ROOT: z.string().min(1).optional(),
|
||||
REA_PERMISSION_PROJECT_STORE: z.string().min(1).optional(),
|
||||
})
|
||||
@@ -101,9 +108,12 @@ const parseStringArray = (
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new ConfigurationError(`${name} must encode an array of strings`, {
|
||||
cause: parsed.error,
|
||||
}),
|
||||
new ConfigurationError(
|
||||
parsed.error.issues.some(({ code }) => code === "too_big")
|
||||
? `${name} must encode at most 128 strings`
|
||||
: `${name} must encode an array of strings`,
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(new ConfigurationError(`${name} must be valid JSON`, { cause }));
|
||||
@@ -133,6 +143,31 @@ const parseBrowserArray = (
|
||||
}
|
||||
};
|
||||
|
||||
const parseElectronFileRoots = (
|
||||
encoded: string,
|
||||
): Result<readonly string[], ConfigurationError> => {
|
||||
try {
|
||||
const decoded: unknown = JSON.parse(encoded);
|
||||
if (Array.isArray(decoded) && decoded.length === 0) return ok([]);
|
||||
const parsed = electronFileRootsSchema.safeParse(decoded);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new ConfigurationError(
|
||||
"REA_ELECTRON_FILE_ROOTS_JSON must encode absolute roots",
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ConfigurationError(
|
||||
"REA_ELECTRON_FILE_ROOTS_JSON must be valid JSON",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({
|
||||
roots,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
@@ -266,6 +301,17 @@ export const parseConfig = (
|
||||
32,
|
||||
);
|
||||
if (!browserOrigins.ok) return browserOrigins;
|
||||
const electronEndpoints = parseBrowserArray(
|
||||
parsedEnvironment.data.REA_ELECTRON_CDP_ENDPOINTS_JSON,
|
||||
"REA_ELECTRON_CDP_ENDPOINTS_JSON",
|
||||
browserEndpointSchema,
|
||||
16,
|
||||
);
|
||||
if (!electronEndpoints.ok) return electronEndpoints;
|
||||
const electronFileRoots = parseElectronFileRoots(
|
||||
parsedEnvironment.data.REA_ELECTRON_FILE_ROOTS_JSON,
|
||||
);
|
||||
if (!electronFileRoots.ok) return electronFileRoots;
|
||||
const permissionCeilings = [
|
||||
...(parsedEnvironment.data.REA_PROCESS_CAPTURE_ENABLED === "true"
|
||||
? [
|
||||
@@ -297,6 +343,14 @@ export const parseConfig = (
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(parsedEnvironment.data.REA_ELECTRON_OBSERVE_ENABLED === "true"
|
||||
? [
|
||||
permissionScope("electron_observe", electronFileRoots.value, {
|
||||
origins: electronEndpoints.value,
|
||||
network: "loopback",
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true"
|
||||
? [permissionScope("native_mount", [], { mount: true })]
|
||||
: []),
|
||||
@@ -336,6 +390,10 @@ export const parseConfig = (
|
||||
parsedEnvironment.data.REA_BROWSER_OBSERVE_ENABLED === "true",
|
||||
browserCdpEndpoints: browserEndpoints.value,
|
||||
browserAllowedOrigins: browserOrigins.value,
|
||||
electronObservationEnabled:
|
||||
parsedEnvironment.data.REA_ELECTRON_OBSERVE_ENABLED === "true",
|
||||
electronCdpEndpoints: electronEndpoints.value,
|
||||
electronFileRoots: electronFileRoots.value,
|
||||
permissionCeilings,
|
||||
administratorPermissionGrants: administratorGrants(permissionCeilings),
|
||||
permissionProjectRoot: parsedEnvironment.data.REA_PERMISSION_PROJECT_ROOT,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolContract } from "./toolContracts.js";
|
||||
import { z } from "zod";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import {
|
||||
browserTargetListSchema,
|
||||
@@ -6,8 +7,30 @@ import {
|
||||
listBrowserTargetsInputSchema,
|
||||
webPageInspectionSchema,
|
||||
} from "../domain/browserObservation.js";
|
||||
import {
|
||||
analyzeWebBundleInputSchema,
|
||||
webBundleAnalysisSchema,
|
||||
} from "../domain/webBundleAnalysis.js";
|
||||
import {
|
||||
observeWebSessionInputSchema,
|
||||
webObservationSessionSchema,
|
||||
} from "../domain/browserSession.js";
|
||||
import {
|
||||
discoverWebMcpToolsInputSchema,
|
||||
webMcpDiscoverySchema,
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import {
|
||||
compareWebCapturesInputSchema,
|
||||
webCaptureDiffSchema,
|
||||
} from "../domain/webCaptureDiff.js";
|
||||
import {
|
||||
captureWebScreenshotInputSchema,
|
||||
compareWebScreenshotsInputSchema,
|
||||
webScreenshotDiffSchema,
|
||||
webScreenshotSchema,
|
||||
} from "../domain/webScreenshot.js";
|
||||
|
||||
const evidenceResult = (schema: typeof browserTargetListSchema) =>
|
||||
const evidenceResult = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
evidenceSchema
|
||||
.omit({ normalized_result: true })
|
||||
.extend({ normalized_result: schema });
|
||||
@@ -15,6 +38,16 @@ const listOutputSchema = evidenceResult(browserTargetListSchema);
|
||||
const inspectionOutputSchema = evidenceSchema
|
||||
.omit({ normalized_result: true })
|
||||
.extend({ normalized_result: webPageInspectionSchema });
|
||||
const bundleOutputSchema = evidenceSchema
|
||||
.omit({ normalized_result: true })
|
||||
.extend({ normalized_result: webBundleAnalysisSchema });
|
||||
const observationSessionOutputSchema = evidenceSchema
|
||||
.omit({ normalized_result: true })
|
||||
.extend({ normalized_result: webObservationSessionSchema });
|
||||
const webMcpOutputSchema = evidenceResult(webMcpDiscoverySchema);
|
||||
const captureDiffOutputSchema = evidenceResult(webCaptureDiffSchema);
|
||||
const screenshotOutputSchema = evidenceResult(webScreenshotSchema);
|
||||
const screenshotDiffOutputSchema = evidenceResult(webScreenshotDiffSchema);
|
||||
|
||||
const endpoint = "http://127.0.0.1:9222";
|
||||
const origin = "https://app.example.test";
|
||||
@@ -69,12 +102,21 @@ export const BROWSER_TOOL_CONTRACTS = [
|
||||
approved: true,
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
observation_ms: 500,
|
||||
include_accessibility_text: false,
|
||||
include_console_text: false,
|
||||
console_text_approved: false,
|
||||
include_json_body_shapes: false,
|
||||
json_body_schema_approved: false,
|
||||
include_websocket_shapes: false,
|
||||
websocket_shape_approved: false,
|
||||
include_script_sources: false,
|
||||
include_storage_keys: false,
|
||||
limits: {
|
||||
max_frames: 200,
|
||||
max_dom_nodes: 2_000,
|
||||
max_ax_nodes: 2_000,
|
||||
max_ax_text_field_bytes: 1_024,
|
||||
max_total_ax_text_bytes: 65_536,
|
||||
max_scripts: 200,
|
||||
max_resources: 2_000,
|
||||
max_workers: 500,
|
||||
@@ -83,10 +125,296 @@ export const BROWSER_TOOL_CONTRACTS = [
|
||||
max_total_script_source_bytes: 4_194_304,
|
||||
max_network_events: 1_000,
|
||||
max_console_events: 200,
|
||||
max_console_text_field_bytes: 1_024,
|
||||
max_total_console_text_bytes: 65_536,
|
||||
max_json_body_bytes: 1_048_576,
|
||||
max_total_json_body_bytes: 4_194_304,
|
||||
max_json_shape_nodes: 5_000,
|
||||
max_json_shape_depth: 20,
|
||||
max_websocket_events: 500,
|
||||
max_websocket_shape_bytes: 65_536,
|
||||
max_total_websocket_shape_bytes: 1_048_576,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "analyze_web_bundle",
|
||||
description:
|
||||
"Capture explicitly approved JavaScript source from one approved CDP page and statically derive a bounded chunk graph, route and endpoint candidates, vendor fingerprints, page-declared WebMCP metadata, and optional separately approved source-map evidence. JavaScript is parsed but never executed.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: analyzeWebBundleInputSchema,
|
||||
outputSchema: bundleOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "Analyze an approved page bundle",
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
approved: true,
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
observation_ms: 500,
|
||||
include_accessibility_text: false,
|
||||
include_script_sources: true,
|
||||
include_storage_keys: false,
|
||||
source_capture_approved: true,
|
||||
fetch_source_maps: false,
|
||||
source_map_fetch_approved: false,
|
||||
analysis_limits: {
|
||||
max_findings: 1_000,
|
||||
max_ast_nodes: 250_000,
|
||||
max_source_maps: 100,
|
||||
max_source_map_bytes: 4_194_304,
|
||||
max_total_source_map_bytes: 16_777_216,
|
||||
max_source_map_mappings: 10_000,
|
||||
max_original_sources: 2_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "observe_web_session",
|
||||
description:
|
||||
"Arm a bounded CDP observation window while the user operates the page. Allows approved same-origin reload and SPA navigation, records ordered navigation, redirect, lifecycle, and failure metadata, and stops before retaining an out-of-policy destination.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: observeWebSessionInputSchema,
|
||||
outputSchema: observationSessionOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "Observe one external user action",
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
approved: true,
|
||||
observation_ms: 10_000,
|
||||
max_timeline_events: 2_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "discover_webmcp_tools",
|
||||
description:
|
||||
"Passively inventory page-registered WebMCP tools using the experimental CDP WebMCP domain. Metadata is bounded and page-declared-untrusted; REA never registers or invokes discovered tools.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: discoverWebMcpToolsInputSchema,
|
||||
outputSchema: webMcpOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "Discover current WebMCP declarations",
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
approved: true,
|
||||
observation_ms: 100,
|
||||
max_tools: 500,
|
||||
max_schema_bytes: 262_144,
|
||||
max_schema_nodes: 5_000,
|
||||
max_schema_depth: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "compare_web_captures",
|
||||
description:
|
||||
"Compare two normalized web captures across DOM, scripts, resources, network, safe metadata, and optional WebMCP inventories. Stable observed changes are distinguished from unknown absence caused by incomplete capture coverage.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: compareWebCapturesInputSchema,
|
||||
outputSchema: captureDiffOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "Compare two normalized captures",
|
||||
input: {
|
||||
before: { inspection: exampleInspection() },
|
||||
after: { inspection: exampleInspection() },
|
||||
max_changes: 2_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "capture_web_screenshot",
|
||||
description:
|
||||
"Capture the current visible viewport of one approved page as a bounded, content-addressed PNG artifact. Screenshot capture requires separate explicit approval and never scrolls, navigates, or evaluates page JavaScript.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: captureWebScreenshotInputSchema,
|
||||
outputSchema: screenshotOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "Capture an approved viewport",
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
approved: true,
|
||||
screenshot_approved: true,
|
||||
maximum_image_bytes: 4_194_304,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "compare_web_screenshots",
|
||||
description:
|
||||
"Compare two self-verifying PNG screenshot artifacts with bounded local pixel metrics. Returns exact changed-pixel ratios and channel deltas without OCR or external services.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: compareWebScreenshotsInputSchema,
|
||||
outputSchema: screenshotDiffOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "Compare two screenshot artifacts",
|
||||
input: {
|
||||
before: exampleScreenshot(),
|
||||
after: exampleScreenshot(),
|
||||
channel_threshold: 0,
|
||||
maximum_pixels: 16_000_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
function exampleCompleteness() {
|
||||
return {
|
||||
status: "complete_within_window",
|
||||
conditions: ["complete_within_window"],
|
||||
policy_filtered_sections: [],
|
||||
attach_limited_sections: [],
|
||||
truncated_sections: [],
|
||||
unavailable_sections: [],
|
||||
excluded: [],
|
||||
dropped_events: {
|
||||
scripts: 0,
|
||||
network_requests: 0,
|
||||
console_events: 0,
|
||||
websocket_connections: 0,
|
||||
websocket_frames: 0,
|
||||
webmcp_tools: 0,
|
||||
timeline_events: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function exampleInspection() {
|
||||
return {
|
||||
schema_version: 2,
|
||||
browser: {
|
||||
product: "Chrome/1",
|
||||
protocol_version: "1.3",
|
||||
revision: "example",
|
||||
user_agent: "Chrome",
|
||||
js_version: "1",
|
||||
},
|
||||
target: {
|
||||
target_id: "page-1",
|
||||
type: "page",
|
||||
title: "Example",
|
||||
url: `${origin}/`,
|
||||
origin,
|
||||
attached: false,
|
||||
},
|
||||
capture_window: {
|
||||
started_at: "2026-07-14T00:00:00.000Z",
|
||||
ended_at: "2026-07-14T00:00:01.000Z",
|
||||
observation_ms: 1_000,
|
||||
},
|
||||
completeness: exampleCompleteness(),
|
||||
frames: [],
|
||||
dom: { total_nodes: 0, nodes: [] },
|
||||
accessibility: {
|
||||
total_nodes: 0,
|
||||
text_capture: {
|
||||
status: "not_approved",
|
||||
retained_bytes: 0,
|
||||
excluded_fields: 0,
|
||||
truncated_fields: 0,
|
||||
},
|
||||
nodes: [],
|
||||
},
|
||||
scripts: { total: 0, items: [] },
|
||||
resources: [],
|
||||
network: {
|
||||
requests: [],
|
||||
websocket_events: [],
|
||||
coverage_started_at: "2026-07-14T00:00:00.000Z",
|
||||
prior_activity_available: false,
|
||||
},
|
||||
console: {
|
||||
events: [],
|
||||
coverage_started_at: "2026-07-14T00:00:00.000Z",
|
||||
prior_activity_available: false,
|
||||
},
|
||||
workers: [],
|
||||
metadata: {
|
||||
responses: [],
|
||||
dom_urls: [],
|
||||
agent_hints: [],
|
||||
excluded_dom_urls: 0,
|
||||
headers_allowlisted: true,
|
||||
},
|
||||
storage: {
|
||||
origin,
|
||||
usage_bytes: null,
|
||||
quota_bytes: null,
|
||||
local_storage_keys: [],
|
||||
session_storage_keys: [],
|
||||
indexed_db_names: [],
|
||||
cache_names: [],
|
||||
values_redacted: true,
|
||||
},
|
||||
limitations: [],
|
||||
};
|
||||
}
|
||||
|
||||
function exampleScreenshot() {
|
||||
return {
|
||||
uri: "rea://web-screenshot/sha256/153cf6c9a526a63053a37b10234c2fd85df38887c2dc0a800d90abfa6631d01c",
|
||||
sha256: "153cf6c9a526a63053a37b10234c2fd85df38887c2dc0a800d90abfa6631d01c",
|
||||
bytes: 70,
|
||||
media_type: "image/png",
|
||||
data_base64:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PzWvWQAAAABJRU5ErkJggg==",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ToolContract } from "./toolContracts.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import {
|
||||
electronPageInspectionSchema,
|
||||
electronTargetListSchema,
|
||||
inspectElectronPageInputSchema,
|
||||
listElectronTargetsInputSchema,
|
||||
} from "../domain/electronObservation.js";
|
||||
|
||||
const listOutputSchema = evidenceSchema
|
||||
.omit({ normalized_result: true })
|
||||
.extend({ normalized_result: electronTargetListSchema });
|
||||
const inspectionOutputSchema = evidenceSchema
|
||||
.omit({ normalized_result: true })
|
||||
.extend({ normalized_result: electronPageInspectionSchema });
|
||||
|
||||
const endpoint = "http://127.0.0.1:9223";
|
||||
const root = "/Applications/Example.app/Contents/Resources";
|
||||
|
||||
/** Root-confined Electron file-page discovery and inspection contracts. */
|
||||
export const ELECTRON_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "list_electron_targets",
|
||||
description:
|
||||
"List Electron file:// page targets from an approved user-owned loopback CDP endpoint. Every path is canonicalized and must remain contained by an approved filesystem root, including after symlink resolution.",
|
||||
kind: "electron-provider",
|
||||
inputSchema: listElectronTargetsInputSchema,
|
||||
outputSchema: listOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "List approved Electron file pages",
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_file_roots: [root],
|
||||
approved: true,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_electron_page",
|
||||
description:
|
||||
"Passively inspect one approved Electron file page through CDP. Returns root-confined frames, DOM structure, resource paths, and scripts without evaluating renderer JavaScript or invoking Electron APIs; script contents require separate approval.",
|
||||
kind: "electron-provider",
|
||||
inputSchema: inspectElectronPageInputSchema,
|
||||
outputSchema: inspectionOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect an approved Electron page",
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_file_roots: [root],
|
||||
target_id: "TARGET_ID_FROM_LIST_ELECTRON_TARGETS",
|
||||
approved: true,
|
||||
observation_ms: 100,
|
||||
include_script_sources: false,
|
||||
source_capture_approved: false,
|
||||
limits: {
|
||||
max_frames: 200,
|
||||
max_dom_nodes: 2_000,
|
||||
max_scripts: 500,
|
||||
max_resources: 2_000,
|
||||
max_script_source_bytes: 1_048_576,
|
||||
max_total_script_source_bytes: 4_194_304,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -23,6 +23,7 @@ export type ToolKind =
|
||||
| "native-provider"
|
||||
| "artifact-provider"
|
||||
| "browser-provider"
|
||||
| "electron-provider"
|
||||
| "session";
|
||||
|
||||
/** Single source of truth for a public MCP tool. */
|
||||
|
||||
@@ -27,6 +27,7 @@ import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||
import { BROWSER_TOOL_CONTRACTS } from "./browserToolContracts.js";
|
||||
import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
|
||||
import {
|
||||
closeBinaryInputSchema,
|
||||
openBinaryInputSchema,
|
||||
@@ -520,5 +521,6 @@ export const TOOL_CONTRACTS = [
|
||||
...NATIVE_TOOL_CONTRACTS,
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...BROWSER_TOOL_CONTRACTS,
|
||||
...ELECTRON_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Stable browser sections used by coverage and omission accounting. */
|
||||
const browserSectionSchema = z.enum([
|
||||
"frames",
|
||||
"dom",
|
||||
"accessibility",
|
||||
"scripts",
|
||||
"script_sources",
|
||||
"resources",
|
||||
"network_requests",
|
||||
"network_initiators",
|
||||
"console_events",
|
||||
"console_text",
|
||||
"websocket_connections",
|
||||
"websocket_frames",
|
||||
"websocket_shapes",
|
||||
"json_body_shapes",
|
||||
"workers",
|
||||
"storage",
|
||||
"storage_keys",
|
||||
"metadata",
|
||||
"webmcp_tools",
|
||||
"screenshot",
|
||||
"source_maps",
|
||||
"timeline",
|
||||
]);
|
||||
export type BrowserSection = z.infer<typeof browserSectionSchema>;
|
||||
|
||||
/** Why an eligible browser observation was not retained. */
|
||||
const browserExclusionReasonSchema = z.enum([
|
||||
"disallowed_origin",
|
||||
"unsupported_url",
|
||||
"unattributed_origin",
|
||||
"not_approved",
|
||||
"out_of_target_scope",
|
||||
"provider_unavailable",
|
||||
"invalid_protocol_value",
|
||||
]);
|
||||
export type BrowserExclusionReason = z.infer<
|
||||
typeof browserExclusionReasonSchema
|
||||
>;
|
||||
|
||||
const browserExclusionSchema = z.object({
|
||||
section: browserSectionSchema,
|
||||
reason: browserExclusionReasonSchema,
|
||||
count: z.number().int().min(0).nullable(),
|
||||
});
|
||||
export type BrowserExclusion = z.infer<typeof browserExclusionSchema>;
|
||||
|
||||
const browserDroppedEventsSchema = z.object({
|
||||
scripts: z.number().int().min(0),
|
||||
network_requests: z.number().int().min(0),
|
||||
console_events: z.number().int().min(0),
|
||||
websocket_connections: z.number().int().min(0),
|
||||
websocket_frames: z.number().int().min(0),
|
||||
webmcp_tools: z.number().int().min(0),
|
||||
timeline_events: z.number().int().min(0),
|
||||
total: z.number().int().min(0),
|
||||
});
|
||||
export type BrowserDroppedEvents = z.infer<typeof browserDroppedEventsSchema>;
|
||||
export type BrowserDroppedEventKind = Exclude<
|
||||
keyof BrowserDroppedEvents,
|
||||
"total"
|
||||
>;
|
||||
|
||||
const browserCompletenessConditionSchema = z.enum([
|
||||
"complete_within_window",
|
||||
"policy_filtered",
|
||||
"attach_limited",
|
||||
"truncated",
|
||||
]);
|
||||
|
||||
/** Explicit, non-lossy coverage semantics for a bounded browser observation. */
|
||||
export const browserCompletenessSchema = z.object({
|
||||
status: browserCompletenessConditionSchema,
|
||||
conditions: z.array(browserCompletenessConditionSchema).min(1).max(3),
|
||||
policy_filtered_sections: z.array(browserSectionSchema),
|
||||
attach_limited_sections: z.array(browserSectionSchema),
|
||||
truncated_sections: z.array(browserSectionSchema),
|
||||
unavailable_sections: z.array(browserSectionSchema),
|
||||
excluded: z.array(browserExclusionSchema),
|
||||
dropped_events: browserDroppedEventsSchema,
|
||||
});
|
||||
export type BrowserCompleteness = z.infer<typeof browserCompletenessSchema>;
|
||||
|
||||
/** Build canonical completeness output from one adapter-owned capture ledger. */
|
||||
export const classifyBrowserCompleteness = (input: {
|
||||
readonly policyFilteredSections: ReadonlySet<BrowserSection>;
|
||||
readonly attachLimitedSections: ReadonlySet<BrowserSection>;
|
||||
readonly truncatedSections: ReadonlySet<BrowserSection>;
|
||||
readonly unavailableSections: ReadonlySet<BrowserSection>;
|
||||
readonly excluded: readonly BrowserExclusion[];
|
||||
readonly droppedEvents: Omit<BrowserDroppedEvents, "total">;
|
||||
}): BrowserCompleteness => {
|
||||
const policyFiltered = input.policyFilteredSections.size > 0;
|
||||
const truncated =
|
||||
input.truncatedSections.size > 0 ||
|
||||
Object.values(input.droppedEvents).some((count) => count > 0);
|
||||
const attachLimited =
|
||||
input.attachLimitedSections.size > 0 || input.unavailableSections.size > 0;
|
||||
const status = truncated
|
||||
? "truncated"
|
||||
: policyFiltered
|
||||
? "policy_filtered"
|
||||
: attachLimited
|
||||
? "attach_limited"
|
||||
: "complete_within_window";
|
||||
const conditions = [
|
||||
...(attachLimited ? (["attach_limited"] as const) : []),
|
||||
...(policyFiltered ? (["policy_filtered"] as const) : []),
|
||||
...(truncated ? (["truncated"] as const) : []),
|
||||
];
|
||||
const droppedTotal = Object.values(input.droppedEvents).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
return browserCompletenessSchema.parse({
|
||||
status,
|
||||
conditions:
|
||||
conditions.length === 0 ? ["complete_within_window"] : conditions,
|
||||
policy_filtered_sections: sortedSections(input.policyFilteredSections),
|
||||
attach_limited_sections: sortedSections(input.attachLimitedSections),
|
||||
truncated_sections: sortedSections(input.truncatedSections),
|
||||
unavailable_sections: sortedSections(input.unavailableSections),
|
||||
excluded: canonicalExclusions(input.excluded),
|
||||
dropped_events: { ...input.droppedEvents, total: droppedTotal },
|
||||
});
|
||||
};
|
||||
|
||||
const sortedSections = (
|
||||
sections: ReadonlySet<BrowserSection>,
|
||||
): BrowserSection[] => [...sections].sort();
|
||||
|
||||
const canonicalExclusions = (
|
||||
exclusions: readonly BrowserExclusion[],
|
||||
): BrowserExclusion[] =>
|
||||
[...exclusions].sort(
|
||||
(left, right) =>
|
||||
left.section.localeCompare(right.section) ||
|
||||
left.reason.localeCompare(right.reason),
|
||||
);
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { browserCompletenessSchema } from "./browserCompleteness.js";
|
||||
import { webTextArtifactSchema } from "./webContentArtifact.js";
|
||||
import { jsonShapeSchema } from "./jsonShape.js";
|
||||
|
||||
const parseExactOrigin = (value: string): string | undefined => {
|
||||
let url: URL;
|
||||
try {
|
||||
@@ -78,7 +82,7 @@ export const browserEndpointSchema = z
|
||||
return url.origin;
|
||||
});
|
||||
|
||||
const allowedOriginsSchema = z
|
||||
export const browserAllowedOriginsSchema = z
|
||||
.array(browserOriginSchema)
|
||||
.min(1)
|
||||
.max(32)
|
||||
@@ -86,7 +90,7 @@ const allowedOriginsSchema = z
|
||||
|
||||
const approvedBrowserInput = {
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_origins: allowedOriginsSchema,
|
||||
allowed_origins: browserAllowedOriginsSchema,
|
||||
approved: z
|
||||
.literal(true)
|
||||
.describe("Explicit approval for browser observation"),
|
||||
@@ -103,6 +107,18 @@ const browserInspectionLimitsSchema = z.object({
|
||||
max_frames: z.number().int().min(1).max(1_000).default(200),
|
||||
max_dom_nodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
max_ax_nodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
max_ax_text_field_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024)
|
||||
.default(1_024),
|
||||
max_total_ax_text_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_024 * 1_024)
|
||||
.default(64 * 1_024),
|
||||
max_scripts: z.number().int().min(1).max(1_000).default(200),
|
||||
max_resources: z.number().int().min(1).max(10_000).default(2_000),
|
||||
max_workers: z.number().int().min(1).max(5_000).default(500),
|
||||
@@ -121,13 +137,53 @@ const browserInspectionLimitsSchema = z.object({
|
||||
.default(4 * 1_024 * 1_024),
|
||||
max_network_events: z.number().int().min(1).max(10_000).default(1_000),
|
||||
max_console_events: z.number().int().min(1).max(2_000).default(200),
|
||||
max_console_text_field_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024)
|
||||
.default(1_024),
|
||||
max_total_console_text_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_024 * 1_024)
|
||||
.default(64 * 1_024),
|
||||
max_json_body_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(4 * 1_024 * 1_024)
|
||||
.default(1_024 * 1_024),
|
||||
max_total_json_body_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
max_json_shape_nodes: z.number().int().min(1).max(100_000).default(5_000),
|
||||
max_json_shape_depth: z.number().int().min(1).max(100).default(20),
|
||||
max_websocket_events: z.number().int().min(1).max(5_000).default(500),
|
||||
max_websocket_shape_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(1_024 * 1_024)
|
||||
.default(64 * 1_024),
|
||||
max_total_websocket_shape_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(1_024 * 1_024),
|
||||
});
|
||||
|
||||
const DEFAULT_BROWSER_INSPECTION_LIMITS = {
|
||||
max_frames: 200,
|
||||
max_dom_nodes: 2_000,
|
||||
max_ax_nodes: 2_000,
|
||||
max_ax_text_field_bytes: 1_024,
|
||||
max_total_ax_text_bytes: 64 * 1_024,
|
||||
max_scripts: 200,
|
||||
max_resources: 2_000,
|
||||
max_workers: 500,
|
||||
@@ -136,7 +192,15 @@ const DEFAULT_BROWSER_INSPECTION_LIMITS = {
|
||||
max_total_script_source_bytes: 4 * 1_024 * 1_024,
|
||||
max_network_events: 1_000,
|
||||
max_console_events: 200,
|
||||
max_console_text_field_bytes: 1_024,
|
||||
max_total_console_text_bytes: 64 * 1_024,
|
||||
max_json_body_bytes: 1_024 * 1_024,
|
||||
max_total_json_body_bytes: 4 * 1_024 * 1_024,
|
||||
max_json_shape_nodes: 5_000,
|
||||
max_json_shape_depth: 20,
|
||||
max_websocket_events: 500,
|
||||
max_websocket_shape_bytes: 64 * 1_024,
|
||||
max_total_websocket_shape_bytes: 1_024 * 1_024,
|
||||
} as const;
|
||||
|
||||
/** Public input for one passive, bounded inspection of an existing page. */
|
||||
@@ -145,6 +209,13 @@ export const inspectWebPageInputSchema = z
|
||||
...approvedBrowserInput,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(500),
|
||||
include_accessibility_text: z.boolean().default(false),
|
||||
include_console_text: z.boolean().default(false),
|
||||
console_text_approved: z.boolean().default(false),
|
||||
include_json_body_shapes: z.boolean().default(false),
|
||||
json_body_schema_approved: z.boolean().default(false),
|
||||
include_websocket_shapes: z.boolean().default(false),
|
||||
websocket_shape_approved: z.boolean().default(false),
|
||||
include_script_sources: z.boolean().default(false),
|
||||
include_storage_keys: z.boolean().default(false),
|
||||
limits: browserInspectionLimitsSchema.default(
|
||||
@@ -161,6 +232,66 @@ export const inspectWebPageInputSchema = z
|
||||
path: ["limits", "max_script_source_bytes"],
|
||||
message: "Per-script source limit cannot exceed the total source limit",
|
||||
});
|
||||
for (const [include, approved, path, message] of [
|
||||
[
|
||||
input.include_console_text,
|
||||
input.console_text_approved,
|
||||
"console_text_approved",
|
||||
"Console text capture requires separate approval",
|
||||
],
|
||||
[
|
||||
input.include_json_body_shapes,
|
||||
input.json_body_schema_approved,
|
||||
"json_body_schema_approved",
|
||||
"JSON body schema capture requires separate approval",
|
||||
],
|
||||
[
|
||||
input.include_websocket_shapes,
|
||||
input.websocket_shape_approved,
|
||||
"websocket_shape_approved",
|
||||
"WebSocket shape capture requires separate approval",
|
||||
],
|
||||
] as const)
|
||||
if (include && !approved)
|
||||
context.addIssue({ code: "custom", path: [path], message });
|
||||
if (
|
||||
input.limits.max_console_text_field_bytes >
|
||||
input.limits.max_total_console_text_bytes
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["limits", "max_console_text_field_bytes"],
|
||||
message:
|
||||
"Per-field console text limit cannot exceed the total text limit",
|
||||
});
|
||||
if (
|
||||
input.limits.max_json_body_bytes > input.limits.max_total_json_body_bytes
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["limits", "max_json_body_bytes"],
|
||||
message: "Per-body JSON limit cannot exceed the total body limit",
|
||||
});
|
||||
if (
|
||||
input.limits.max_websocket_shape_bytes >
|
||||
input.limits.max_total_websocket_shape_bytes
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["limits", "max_websocket_shape_bytes"],
|
||||
message:
|
||||
"Per-frame WebSocket limit cannot exceed the total frame limit",
|
||||
});
|
||||
if (
|
||||
input.limits.max_ax_text_field_bytes >
|
||||
input.limits.max_total_ax_text_bytes
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["limits", "max_ax_text_field_bytes"],
|
||||
message:
|
||||
"Per-field accessibility text limit cannot exceed the total text limit",
|
||||
});
|
||||
});
|
||||
|
||||
export type ListBrowserTargetsInput = z.infer<
|
||||
@@ -280,13 +411,22 @@ const browserScriptSourceSchema = z.discriminatedUnion("included", [
|
||||
z.object({ included: z.literal(false), reason: z.string() }),
|
||||
z.object({
|
||||
included: z.literal(true),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
bytes: z.number().int().min(0),
|
||||
content: z.string(),
|
||||
artifact: webTextArtifactSchema,
|
||||
}),
|
||||
]);
|
||||
const browserResourceReconciliationSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("exact"), resource_key: z.string() }),
|
||||
z.object({
|
||||
status: z.literal("ambiguous"),
|
||||
candidate_resource_keys: z.array(z.string()),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unmatched"),
|
||||
reason: z.literal("no_exact_sanitized_url"),
|
||||
}),
|
||||
]);
|
||||
const browserScriptSchema = z.object({
|
||||
script_id: z.string(),
|
||||
script_key: z.string().regex(/^scr_[a-f0-9]{64}(?:_[0-9]+)?$/u),
|
||||
url: z.string(),
|
||||
origin: z.string().nullable(),
|
||||
cdp_hash: z.string(),
|
||||
@@ -294,9 +434,11 @@ const browserScriptSchema = z.object({
|
||||
is_module: z.boolean(),
|
||||
language: z.string().nullable(),
|
||||
source_map_url: z.string().nullable(),
|
||||
resource_reconciliation: browserResourceReconciliationSchema,
|
||||
source: browserScriptSourceSchema,
|
||||
});
|
||||
const browserResourceSchema = z.object({
|
||||
resource_key: z.string().regex(/^res_[a-f0-9]{64}$/u),
|
||||
url: z.string(),
|
||||
origin: z.string().nullable(),
|
||||
type: z.string(),
|
||||
@@ -318,6 +460,17 @@ const browserNetworkRequestSchema = z.object({
|
||||
line: z.number().int().min(0).nullable(),
|
||||
column: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
body_shapes: z.object({
|
||||
status: z.enum([
|
||||
"not_approved",
|
||||
"included",
|
||||
"partial",
|
||||
"unavailable",
|
||||
"truncated",
|
||||
]),
|
||||
request: jsonShapeSchema.nullable(),
|
||||
response: jsonShapeSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
const browserConsoleEventSchema = z.object({
|
||||
type: z.string(),
|
||||
@@ -326,12 +479,31 @@ const browserConsoleEventSchema = z.object({
|
||||
url: z.string().nullable(),
|
||||
line: z.number().int().min(0).nullable(),
|
||||
column: z.number().int().min(0).nullable(),
|
||||
text_capture: z.object({
|
||||
status: z.enum(["not_approved", "included", "truncated"]),
|
||||
values: z.array(
|
||||
z.object({
|
||||
argument_index: z.number().int().min(0),
|
||||
type: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
),
|
||||
retained_bytes: z.number().int().min(0),
|
||||
truncated_values: z.number().int().min(0),
|
||||
}),
|
||||
});
|
||||
const browserWebSocketEventSchema = z.object({
|
||||
request_id: z.string(),
|
||||
direction: z.enum(["sent", "received"]),
|
||||
opcode: z.number().int().min(0),
|
||||
payload_bytes: z.number().int().min(0),
|
||||
payload_shape: z
|
||||
.object({
|
||||
format: z.enum(["json", "text", "binary"]),
|
||||
json_shape: jsonShapeSchema.nullable(),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
const browserWorkerSchema = z.object({
|
||||
target_id: z.string(),
|
||||
@@ -340,10 +512,80 @@ const browserWorkerSchema = z.object({
|
||||
origin: z.string().nullable(),
|
||||
attached: z.boolean(),
|
||||
});
|
||||
const browserCspSourceSchema = z.object({
|
||||
kind: z.enum([
|
||||
"keyword",
|
||||
"scheme",
|
||||
"approved_origin",
|
||||
"external_origin",
|
||||
"other",
|
||||
]),
|
||||
value: z.string().nullable(),
|
||||
});
|
||||
const browserLinkMetadataSchema = z.object({
|
||||
href: z.string().nullable(),
|
||||
destination_scope: z.enum(["approved", "outside_policy", "unsupported"]),
|
||||
rel: z.array(z.string()),
|
||||
as: z.string().nullable(),
|
||||
type: z.string().nullable(),
|
||||
crossorigin: z.string().nullable(),
|
||||
});
|
||||
const browserAgentHintSchema = z.object({
|
||||
mechanism: z.enum([
|
||||
"link_rel",
|
||||
"dom_link_rel",
|
||||
"well_known_resource",
|
||||
"response_header",
|
||||
]),
|
||||
declaration: z.string(),
|
||||
url: z.string().nullable(),
|
||||
trust: z.literal("page-declared-untrusted"),
|
||||
});
|
||||
const browserSafeMetadataSchema = z.object({
|
||||
responses: z.array(
|
||||
z.object({
|
||||
request_id: z.string(),
|
||||
url: z.string(),
|
||||
mime_type: z.string().nullable(),
|
||||
content_length: z.number().int().min(0).nullable(),
|
||||
content_encoding: z.string().nullable(),
|
||||
csp: z.object({
|
||||
directives: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
sources: z.array(browserCspSourceSchema),
|
||||
}),
|
||||
),
|
||||
nonce_count: z.number().int().min(0),
|
||||
hash_count: z.number().int().min(0),
|
||||
}),
|
||||
links: z.array(browserLinkMetadataSchema),
|
||||
policies: z.object({
|
||||
coop: z.string().nullable(),
|
||||
coep: z.string().nullable(),
|
||||
corp: z.string().nullable(),
|
||||
referrer_policy: z.string().nullable(),
|
||||
x_content_type_options: z.string().nullable(),
|
||||
permissions_policy_features: z.array(z.string()),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
dom_urls: z.array(
|
||||
z.object({
|
||||
node_index: z.number().int().min(0),
|
||||
attribute: z.enum(["href", "src", "action", "formaction", "poster"]),
|
||||
url: z.string().nullable(),
|
||||
destination_scope: z.enum(["approved", "outside_policy", "unsupported"]),
|
||||
}),
|
||||
),
|
||||
agent_hints: z.array(browserAgentHintSchema),
|
||||
excluded_dom_urls: z.number().int().min(0),
|
||||
headers_allowlisted: z.literal(true),
|
||||
});
|
||||
|
||||
/** Provider-neutral normalized result for one passive web-page observation. */
|
||||
export const webPageInspectionSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
schema_version: z.literal(2),
|
||||
browser: browserVersionSchema,
|
||||
target: browserTargetSchema,
|
||||
capture_window: z.object({
|
||||
@@ -351,11 +593,7 @@ export const webPageInspectionSchema = z.object({
|
||||
ended_at: z.iso.datetime(),
|
||||
observation_ms: z.number().int().min(0),
|
||||
}),
|
||||
completeness: z.object({
|
||||
status: z.enum(["complete", "truncated"]),
|
||||
truncated_sections: z.array(z.string()),
|
||||
dropped_events: z.number().int().min(0),
|
||||
}),
|
||||
completeness: browserCompletenessSchema,
|
||||
frames: z.array(browserFrameSchema),
|
||||
dom: z.object({
|
||||
total_nodes: z.number().int().min(0),
|
||||
@@ -363,6 +601,12 @@ export const webPageInspectionSchema = z.object({
|
||||
}),
|
||||
accessibility: z.object({
|
||||
total_nodes: z.number().int().min(0),
|
||||
text_capture: z.object({
|
||||
status: z.enum(["not_approved", "included", "truncated", "unavailable"]),
|
||||
retained_bytes: z.number().int().min(0),
|
||||
excluded_fields: z.number().int().min(0),
|
||||
truncated_fields: z.number().int().min(0),
|
||||
}),
|
||||
nodes: z.array(browserAxNodeSchema),
|
||||
}),
|
||||
scripts: z.object({
|
||||
@@ -382,6 +626,7 @@ export const webPageInspectionSchema = z.object({
|
||||
prior_activity_available: z.literal(false),
|
||||
}),
|
||||
workers: z.array(browserWorkerSchema),
|
||||
metadata: browserSafeMetadataSchema,
|
||||
storage: z.object({
|
||||
origin: z.string(),
|
||||
usage_bytes: z.number().min(0).nullable(),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
browserAllowedOriginsSchema,
|
||||
browserEndpointSchema,
|
||||
} from "./browserObservation.js";
|
||||
import { browserCompletenessSchema } from "./browserCompleteness.js";
|
||||
|
||||
/** Input for a bounded, navigation-aware browser observation window. */
|
||||
export const observeWebSessionInputSchema = z.object({
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_origins: browserAllowedOriginsSchema,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
approved: z.literal(true),
|
||||
observation_ms: z.number().int().min(1).max(60_000).default(10_000),
|
||||
max_timeline_events: z.number().int().min(1).max(20_000).default(2_000),
|
||||
});
|
||||
export type ObserveWebSessionInput = z.infer<
|
||||
typeof observeWebSessionInputSchema
|
||||
>;
|
||||
|
||||
const timelineEventSchema = z.object({
|
||||
sequence: z.number().int().min(1),
|
||||
type: z.enum([
|
||||
"navigation_requested",
|
||||
"navigation_committed",
|
||||
"same_origin_reload",
|
||||
"same_document_navigation",
|
||||
"redirect",
|
||||
"load_failed",
|
||||
"lifecycle",
|
||||
"target_terminated",
|
||||
]),
|
||||
timestamp: z.number().min(0),
|
||||
frame_id: z.string().nullable(),
|
||||
loader_id: z.string().nullable(),
|
||||
request_id: z.string().nullable(),
|
||||
url: z.string().nullable(),
|
||||
destination_scope: z
|
||||
.enum(["approved", "outside_policy", "unsupported"])
|
||||
.nullable(),
|
||||
detail: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Navigation-aware session result for external user actions. */
|
||||
export const webObservationSessionSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
browser: z.object({
|
||||
product: z.string(),
|
||||
protocol_version: z.string(),
|
||||
revision: z.string(),
|
||||
user_agent: z.string(),
|
||||
js_version: z.string(),
|
||||
}),
|
||||
target: z.object({
|
||||
target_id: z.string(),
|
||||
initial_url: z.string(),
|
||||
final_url: z.string().nullable(),
|
||||
}),
|
||||
window: z.object({
|
||||
armed_at: z.iso.datetime(),
|
||||
ended_at: z.iso.datetime(),
|
||||
requested_ms: z.number().int().min(1),
|
||||
end_reason: z.enum([
|
||||
"window_elapsed",
|
||||
"target_left_scope",
|
||||
"target_terminated",
|
||||
]),
|
||||
}),
|
||||
timeline: z.array(timelineEventSchema),
|
||||
completeness: browserCompletenessSchema,
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type WebObservationSession = z.infer<typeof webObservationSessionSchema>;
|
||||
@@ -5,6 +5,8 @@ import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { functionComparisonResultSchema } from "./functionComparison.js";
|
||||
import {
|
||||
comparisonStatusSchema,
|
||||
deriveProcessComparisonStatus,
|
||||
PROCESS_COMPARISON_DIMENSIONS,
|
||||
processCaptureComparisonSchema,
|
||||
} from "./processCapture.js";
|
||||
import {
|
||||
@@ -205,20 +207,9 @@ const parseResult = (evidence: Evidence): void => {
|
||||
const result = processCaptureComparisonSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const dimensions = [
|
||||
result.terminal,
|
||||
result.exit,
|
||||
result.filesystem,
|
||||
result.protocol,
|
||||
result.process,
|
||||
];
|
||||
const expected = dimensions.includes("truncated")
|
||||
? "truncated"
|
||||
: dimensions.some(isChange)
|
||||
? "changed"
|
||||
: dimensions.includes("unknown")
|
||||
? "unknown"
|
||||
: "unchanged";
|
||||
const expected = deriveProcessComparisonStatus(
|
||||
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => result[dimension]),
|
||||
);
|
||||
if (result.status !== expected)
|
||||
throw new TypeError(
|
||||
"Process comparison status contradicts its dimensions",
|
||||
@@ -273,10 +264,12 @@ const processFindings = (evidence: Evidence): Finding[] => {
|
||||
);
|
||||
const dimensions = [
|
||||
["terminal", result.terminal, "runtime"],
|
||||
["interaction", result.interaction, "runtime"],
|
||||
["exit", result.exit, "runtime"],
|
||||
["filesystem", result.filesystem, "resource"],
|
||||
["protocol", result.protocol, "protocol"],
|
||||
["process", result.process, "runtime"],
|
||||
["shim", result.shim, "protocol"],
|
||||
] as const;
|
||||
return dimensions.flatMap(([dimension, status, scope]) =>
|
||||
status === "unchanged"
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { browserCompletenessSchema } from "./browserCompleteness.js";
|
||||
import { browserEndpointSchema } from "./browserObservation.js";
|
||||
import { webTextArtifactSchema } from "./webContentArtifact.js";
|
||||
|
||||
/** Absolute operator-approved filesystem roots for Electron file pages. */
|
||||
export const electronFileRootsSchema = z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16_384)
|
||||
.refine(isAbsolute, "Electron file roots must be absolute paths"),
|
||||
)
|
||||
.min(1)
|
||||
.max(32)
|
||||
.transform((roots) => [...new Set(roots)].sort());
|
||||
|
||||
const approvedElectronInput = {
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_file_roots: electronFileRootsSchema,
|
||||
approved: z.literal(true),
|
||||
};
|
||||
|
||||
/** Input for listing root-confined file:// page targets from Electron CDP. */
|
||||
export const listElectronTargetsInputSchema = z.object({
|
||||
...approvedElectronInput,
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(200).default(100),
|
||||
});
|
||||
export type ListElectronTargetsInput = z.infer<
|
||||
typeof listElectronTargetsInputSchema
|
||||
>;
|
||||
|
||||
/** Input for passive structural inspection of one Electron file page. */
|
||||
export const inspectElectronPageInputSchema = z
|
||||
.object({
|
||||
...approvedElectronInput,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(100),
|
||||
include_script_sources: z.boolean().default(false),
|
||||
source_capture_approved: z.boolean().default(false),
|
||||
limits: z
|
||||
.object({
|
||||
max_frames: z.number().int().min(1).max(1_000).default(200),
|
||||
max_dom_nodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
max_scripts: z.number().int().min(1).max(2_000).default(500),
|
||||
max_resources: z.number().int().min(1).max(10_000).default(2_000),
|
||||
max_script_source_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(4 * 1_024 * 1_024)
|
||||
.default(1_024 * 1_024),
|
||||
max_total_script_source_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
})
|
||||
.default({
|
||||
max_frames: 200,
|
||||
max_dom_nodes: 2_000,
|
||||
max_scripts: 500,
|
||||
max_resources: 2_000,
|
||||
max_script_source_bytes: 1_024 * 1_024,
|
||||
max_total_script_source_bytes: 4 * 1_024 * 1_024,
|
||||
}),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (input.include_script_sources && !input.source_capture_approved)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["source_capture_approved"],
|
||||
message: "Electron script source capture requires separate approval",
|
||||
});
|
||||
if (
|
||||
input.limits.max_script_source_bytes >
|
||||
input.limits.max_total_script_source_bytes
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["limits", "max_script_source_bytes"],
|
||||
message: "Per-script source limit cannot exceed the total source limit",
|
||||
});
|
||||
});
|
||||
export type InspectElectronPageInput = z.infer<
|
||||
typeof inspectElectronPageInputSchema
|
||||
>;
|
||||
|
||||
const browserVersionSchema = z.object({
|
||||
product: z.string(),
|
||||
protocol_version: z.string(),
|
||||
revision: z.string(),
|
||||
user_agent: z.string(),
|
||||
js_version: z.string(),
|
||||
});
|
||||
const electronTargetSchema = z.object({
|
||||
target_id: z.string(),
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
file_path: z.string(),
|
||||
attached: z.boolean(),
|
||||
});
|
||||
|
||||
/** Root-filtered Electron file target inventory. */
|
||||
export const electronTargetListSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
browser: browserVersionSchema,
|
||||
targets: z.object({
|
||||
items: z.array(electronTargetSchema),
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1),
|
||||
total: z.number().int().min(0),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
has_more: z.boolean(),
|
||||
}),
|
||||
excluded: z.object({
|
||||
outside_root: z.number().int().min(0),
|
||||
unsupported_url: z.number().int().min(0),
|
||||
non_page: z.number().int().min(0),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type ElectronTargetList = z.infer<typeof electronTargetListSchema>;
|
||||
|
||||
const electronSourceSchema = z.discriminatedUnion("included", [
|
||||
z.object({ included: z.literal(false), reason: z.string() }),
|
||||
z.object({ included: z.literal(true), artifact: webTextArtifactSchema }),
|
||||
]);
|
||||
|
||||
/** Provider-neutral passive Electron file-page structure and script inventory. */
|
||||
export const electronPageInspectionSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
browser: browserVersionSchema,
|
||||
target: electronTargetSchema,
|
||||
capture_window: z.object({
|
||||
started_at: z.iso.datetime(),
|
||||
ended_at: z.iso.datetime(),
|
||||
observation_ms: z.number().int().min(0),
|
||||
}),
|
||||
completeness: browserCompletenessSchema,
|
||||
frames: z.array(
|
||||
z.object({
|
||||
frame_id: z.string(),
|
||||
parent_frame_id: z.string().nullable(),
|
||||
file_path: z.string(),
|
||||
}),
|
||||
),
|
||||
dom: z.object({
|
||||
total_nodes: z.number().int().min(0),
|
||||
nodes: z.array(
|
||||
z.object({
|
||||
index: z.number().int().min(0),
|
||||
parent_index: z.number().int().min(-1),
|
||||
node_type: z.number().int().min(0),
|
||||
node_name: z.string(),
|
||||
node_value_length: z.number().int().min(0),
|
||||
attribute_names: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
scripts: z.object({
|
||||
total: z.number().int().min(0),
|
||||
items: z.array(
|
||||
z.object({
|
||||
script_key: z.string().regex(/^electron_script_[a-f0-9]{64}$/u),
|
||||
file_path: z.string(),
|
||||
cdp_hash: z.string(),
|
||||
length: z.number().int().min(0),
|
||||
is_module: z.boolean(),
|
||||
language: z.string().nullable(),
|
||||
source: electronSourceSchema,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
resources: z.array(
|
||||
z.object({
|
||||
resource_key: z.string().regex(/^electron_resource_[a-f0-9]{64}$/u),
|
||||
file_path: z.string(),
|
||||
type: z.string(),
|
||||
mime_type: z.string(),
|
||||
content_size: z.number().min(0).nullable(),
|
||||
}),
|
||||
),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type ElectronPageInspection = z.infer<
|
||||
typeof electronPageInspectionSchema
|
||||
>;
|
||||
+14
-1
@@ -144,12 +144,25 @@ export class ProviderAdapterError extends AnalysisError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Public browser and Electron operations that can fail at a CDP boundary. */
|
||||
export type BrowserObservationOperation =
|
||||
| "list_browser_targets"
|
||||
| "inspect_web_page"
|
||||
| "analyze_web_bundle"
|
||||
| "observe_web_session"
|
||||
| "discover_webmcp_tools"
|
||||
| "compare_web_captures"
|
||||
| "capture_web_screenshot"
|
||||
| "compare_web_screenshots"
|
||||
| "list_electron_targets"
|
||||
| "inspect_electron_page";
|
||||
|
||||
/** A bounded passive browser observation failed at its CDP boundary. */
|
||||
export class BrowserObservationError extends AnalysisError {
|
||||
readonly _tag = "BrowserObservationError";
|
||||
|
||||
constructor(
|
||||
readonly operation: "list_browser_targets" | "inspect_web_page",
|
||||
readonly operation: BrowserObservationOperation,
|
||||
readonly reason:
|
||||
| "endpoint_unreachable"
|
||||
| "invalid_endpoint_response"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Structural limits applied while traversing an unknown JSON value. */
|
||||
export interface JsonLimits {
|
||||
readonly maxDepth: number;
|
||||
readonly maxStringLength: number;
|
||||
readonly maxNodes: number;
|
||||
}
|
||||
|
||||
/** Return whether every node, key, and string fits within the supplied limits. */
|
||||
export const isJsonWithinLimits = (
|
||||
root: unknown,
|
||||
limits: JsonLimits,
|
||||
): boolean => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > limits.maxNodes || current.depth > limits.maxDepth)
|
||||
return false;
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > limits.maxStringLength
|
||||
)
|
||||
return false;
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > limits.maxStringLength) return false;
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const jsonValueTypeSchema = z.enum([
|
||||
"object",
|
||||
"array",
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null",
|
||||
]);
|
||||
type JsonValueType = z.infer<typeof jsonValueTypeSchema>;
|
||||
|
||||
/** Value-free structural summary of one approved JSON payload. */
|
||||
export const jsonShapeSchema = z.object({
|
||||
root_type: jsonValueTypeSchema,
|
||||
node_count: z.number().int().min(1),
|
||||
max_depth_observed: z.number().int().min(0),
|
||||
properties: z.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
types: z.array(jsonValueTypeSchema).min(1),
|
||||
observations: z.number().int().min(1),
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
export type JsonShape = z.infer<typeof jsonShapeSchema>;
|
||||
|
||||
export interface JsonShapeLimits {
|
||||
readonly maximumBytes: number;
|
||||
readonly maximumNodes: number;
|
||||
readonly maximumDepth: number;
|
||||
}
|
||||
|
||||
/** Parse approved JSON and immediately discard values after iterative shape inference. */
|
||||
export const inferJsonShape = (
|
||||
text: string,
|
||||
limits: JsonShapeLimits,
|
||||
): JsonShape | null => {
|
||||
if (Buffer.byteLength(text) > limits.maximumBytes) return null;
|
||||
let root: unknown;
|
||||
try {
|
||||
root = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const properties = new Map<
|
||||
string,
|
||||
{ readonly types: Set<JsonValueType>; observations: number }
|
||||
>();
|
||||
const pending: Array<{
|
||||
readonly value: unknown;
|
||||
readonly path: string;
|
||||
readonly depth: number;
|
||||
}> = [{ value: root, path: "", depth: 0 }];
|
||||
let nodeCount = 0;
|
||||
let maxDepthObserved = 0;
|
||||
let truncated = false;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
if (nodeCount >= limits.maximumNodes) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
nodeCount += 1;
|
||||
maxDepthObserved = Math.max(maxDepthObserved, current.depth);
|
||||
if (current.depth >= limits.maximumDepth) {
|
||||
if (hasChildren(current.value)) truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(current.value)) {
|
||||
const capacity = Math.max(
|
||||
0,
|
||||
limits.maximumNodes - nodeCount - pending.length,
|
||||
);
|
||||
const retained = Math.min(current.value.length, capacity);
|
||||
if (retained < current.value.length) truncated = true;
|
||||
for (let index = retained - 1; index >= 0; index -= 1)
|
||||
pending.push({
|
||||
value: current.value[index],
|
||||
path: `${current.path}/*`,
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(current.value)) continue;
|
||||
const allEntries = Object.entries(current.value);
|
||||
const capacity = Math.max(
|
||||
0,
|
||||
limits.maximumNodes - nodeCount - pending.length,
|
||||
);
|
||||
const entries = allEntries.slice(0, capacity);
|
||||
if (entries.length < allEntries.length) truncated = true;
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (entry === undefined) continue;
|
||||
const [name, value] = entry;
|
||||
const path = `${current.path}/${pointerName(name)}`.slice(0, 2_048);
|
||||
const type = jsonValueType(value);
|
||||
const existing = properties.get(path);
|
||||
if (existing === undefined)
|
||||
properties.set(path, { types: new Set([type]), observations: 1 });
|
||||
else {
|
||||
existing.types.add(type);
|
||||
existing.observations += 1;
|
||||
}
|
||||
pending.push({ value, path, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return jsonShapeSchema.parse({
|
||||
root_type: jsonValueType(root),
|
||||
node_count: nodeCount,
|
||||
max_depth_observed: maxDepthObserved,
|
||||
properties: [...properties.entries()]
|
||||
.map(([path, value]) => ({
|
||||
path,
|
||||
types: [...value.types].sort(),
|
||||
observations: value.observations,
|
||||
}))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
.slice(0, limits.maximumNodes),
|
||||
truncated,
|
||||
});
|
||||
};
|
||||
|
||||
const jsonValueType = (value: unknown): JsonValueType => {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return "string";
|
||||
case "number":
|
||||
return "number";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
default:
|
||||
return "object";
|
||||
}
|
||||
};
|
||||
|
||||
const hasChildren = (value: unknown): boolean =>
|
||||
Array.isArray(value)
|
||||
? value.length > 0
|
||||
: isRecord(value) && Object.keys(value).length > 0;
|
||||
|
||||
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const pointerName = (value: string): string =>
|
||||
value.slice(0, 256).replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
@@ -6,6 +6,7 @@ import { err, ok, type Result } from "./result.js";
|
||||
export type PermissionCapability =
|
||||
| "process_capture"
|
||||
| "browser_observe"
|
||||
| "electron_observe"
|
||||
| "evidence_read"
|
||||
| "evidence_write"
|
||||
| "investigation_input"
|
||||
@@ -165,10 +166,7 @@ export const evaluatePermission = (
|
||||
reason:
|
||||
coveringGrant !== undefined && isExpired(coveringGrant, now)
|
||||
? "grant_expired"
|
||||
: candidates.length === 0 &&
|
||||
policy.grants.some(
|
||||
(grant) => grant.capability === normalized.capability,
|
||||
)
|
||||
: coveringGrant !== undefined
|
||||
? "grant_revoked_or_consumed"
|
||||
: "grant_required",
|
||||
missing: missingFromScopes(normalized, candidates),
|
||||
|
||||
@@ -156,10 +156,12 @@ export interface ProcessCapture {
|
||||
readonly residual_unknowns: readonly {
|
||||
readonly scope:
|
||||
| "terminal"
|
||||
| "interaction"
|
||||
| "exit"
|
||||
| "process"
|
||||
| "filesystem"
|
||||
| "protocol"
|
||||
| "shim"
|
||||
| "cleanup"
|
||||
| "network";
|
||||
readonly reason: string;
|
||||
@@ -309,10 +311,12 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
||||
z.object({
|
||||
scope: z.enum([
|
||||
"terminal",
|
||||
"interaction",
|
||||
"exit",
|
||||
"process",
|
||||
"filesystem",
|
||||
"protocol",
|
||||
"shim",
|
||||
"cleanup",
|
||||
"network",
|
||||
]),
|
||||
@@ -349,5 +353,7 @@ export const parseProcessCapture = (input: unknown): ProcessCapture => {
|
||||
export {
|
||||
compareProcessCaptures,
|
||||
comparisonStatusSchema,
|
||||
deriveProcessComparisonStatus,
|
||||
PROCESS_COMPARISON_DIMENSIONS,
|
||||
processCaptureComparisonSchema,
|
||||
} from "./processComparison.js";
|
||||
|
||||
@@ -16,6 +16,31 @@ export const comparisonStatusSchema = z.enum([
|
||||
]);
|
||||
type ComparisonStatus = z.infer<typeof comparisonStatusSchema>;
|
||||
|
||||
/** Canonical ordered dimensions that contribute to an overall process status. */
|
||||
export const PROCESS_COMPARISON_DIMENSIONS = [
|
||||
"terminal",
|
||||
"interaction",
|
||||
"exit",
|
||||
"filesystem",
|
||||
"protocol",
|
||||
"process",
|
||||
"shim",
|
||||
] as const;
|
||||
|
||||
/** Derive the overall comparison status from every process dimension. */
|
||||
export const deriveProcessComparisonStatus = (
|
||||
dimensions: readonly ComparisonStatus[],
|
||||
): ComparisonStatus => {
|
||||
if (dimensions.includes("truncated")) return "truncated";
|
||||
if (
|
||||
dimensions.some((status) =>
|
||||
["changed", "added", "removed"].includes(status),
|
||||
)
|
||||
)
|
||||
return "changed";
|
||||
return dimensions.includes("unknown") ? "unknown" : "unchanged";
|
||||
};
|
||||
|
||||
/** Pure normalized comparison between two captures. */
|
||||
export const processCaptureComparisonSchema = z
|
||||
.object({
|
||||
@@ -51,24 +76,9 @@ export const processCaptureComparisonSchema = z
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((comparison, context) => {
|
||||
const dimensions = [
|
||||
comparison.terminal,
|
||||
comparison.interaction,
|
||||
comparison.exit,
|
||||
comparison.filesystem,
|
||||
comparison.protocol,
|
||||
comparison.process,
|
||||
comparison.shim,
|
||||
];
|
||||
const expected = dimensions.includes("truncated")
|
||||
? "truncated"
|
||||
: dimensions.some((status) =>
|
||||
["added", "removed", "changed"].includes(status),
|
||||
)
|
||||
? "changed"
|
||||
: dimensions.includes("unknown")
|
||||
? "unknown"
|
||||
: "unchanged";
|
||||
const expected = deriveProcessComparisonStatus(
|
||||
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => comparison[dimension]),
|
||||
);
|
||||
if (comparison.status !== expected)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
@@ -118,18 +128,6 @@ const hasUnknown = (
|
||||
scope: ProcessCapture["residual_unknowns"][number]["scope"],
|
||||
): boolean => capture.residual_unknowns.some((item) => item.scope === scope);
|
||||
|
||||
const comparisonStatus = (
|
||||
dimensions: readonly ComparisonStatus[],
|
||||
): ComparisonStatus => {
|
||||
if (
|
||||
dimensions.some((status) =>
|
||||
["changed", "added", "removed"].includes(status),
|
||||
)
|
||||
)
|
||||
return "changed";
|
||||
return dimensions.includes("unknown") ? "unknown" : "unchanged";
|
||||
};
|
||||
|
||||
type DivergenceDimension = Exclude<
|
||||
ProcessCaptureComparison["first_divergence"],
|
||||
{ readonly status: "none" | "unknown" }
|
||||
@@ -277,7 +275,7 @@ const compareDimensions = (
|
||||
terminalObservations(right),
|
||||
),
|
||||
interaction: classify(
|
||||
"terminal",
|
||||
"interaction",
|
||||
left.interaction_events,
|
||||
right.interaction_events,
|
||||
),
|
||||
@@ -295,9 +293,7 @@ const compareDimensions = (
|
||||
),
|
||||
protocol: classify("protocol", left.protocol_events, right.protocol_events),
|
||||
process: classify("process", left.process_samples, right.process_samples),
|
||||
shim: sameNormalization
|
||||
? classifyCollection(left.shim_events, right.shim_events)
|
||||
: "unknown",
|
||||
shim: classify("shim", left.shim_events, right.shim_events),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -367,15 +363,9 @@ export const compareProcessCaptures = (
|
||||
} as const)
|
||||
: observedFirstDivergence;
|
||||
return {
|
||||
status: comparisonStatus([
|
||||
dimensions.terminal,
|
||||
dimensions.interaction,
|
||||
dimensions.exit,
|
||||
dimensions.filesystem,
|
||||
dimensions.protocol,
|
||||
dimensions.process,
|
||||
dimensions.shim,
|
||||
]),
|
||||
status: deriveProcessComparisonStatus(
|
||||
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]),
|
||||
),
|
||||
...dimensions,
|
||||
first_divergence: firstDivergence,
|
||||
limitations: [
|
||||
|
||||
@@ -10,6 +10,8 @@ import { parseEvidenceBundle } from "./evidenceBundle.js";
|
||||
import { functionComparisonResultSchema } from "./functionComparison.js";
|
||||
import { functionDossierSchema } from "./hopperValues.js";
|
||||
import {
|
||||
deriveProcessComparisonStatus,
|
||||
PROCESS_COMPARISON_DIMENSIONS,
|
||||
processCaptureComparisonSchema,
|
||||
processCaptureSchema,
|
||||
} from "./processCapture.js";
|
||||
@@ -319,22 +321,9 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => {
|
||||
const validateProcessStatus = (
|
||||
result: z.infer<typeof processCaptureComparisonSchema>,
|
||||
): void => {
|
||||
const dimensions = [
|
||||
result.terminal,
|
||||
result.exit,
|
||||
result.filesystem,
|
||||
result.protocol,
|
||||
result.process,
|
||||
];
|
||||
const expected = dimensions.includes("truncated")
|
||||
? "truncated"
|
||||
: dimensions.some((status) =>
|
||||
["added", "removed", "changed"].includes(status),
|
||||
)
|
||||
? "changed"
|
||||
: dimensions.includes("unknown")
|
||||
? "unknown"
|
||||
: "unchanged";
|
||||
const expected = deriveProcessComparisonStatus(
|
||||
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => result[dimension]),
|
||||
);
|
||||
if (result.status !== expected)
|
||||
throw new TypeError("Process comparison status contradicts its dimensions");
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user