feat(web): export captured scripts for JavaScript application analysis (#801)
* feat(web): export captured scripts for JavaScript application analysis Add capture adapters, conservative source path projection, verified exclusive publication and a shared CLI/MCP workflow. Retain source identities, missing content and competing URL versions in a complete manifest for existing static analysis. Refs: #774 Closes: #799 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * test(web): include script export in independent effect audit Register the new contract family in the independent inventory audit and verify the filesystem, session, network and idempotence metadata of the named export contract. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(web): preserve capture limitation values in export manifests Align the export boundary with passive capture’s string contract instead of rejecting an admitted empty limitation. Cover the publisher boundary and regenerate canonical schemas and identities. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
parent
894c30ef02
commit
472b72a530
|
|
@ -6,12 +6,19 @@ on:
|
|||
- ".github/workflows/real-browser.yml"
|
||||
- "scripts/verify-real-browser.mjs"
|
||||
- "scripts/verify-browser-network.mjs"
|
||||
- "scripts/verify-browser-scripts.mjs"
|
||||
- "scripts/lib/browser-*.mjs"
|
||||
- "scripts/fixtures/browser-network-site.mjs"
|
||||
- "scripts/fixtures/browser-script-site.mjs"
|
||||
- "scripts/fixtures/browser-verifier-site.mjs"
|
||||
- "scripts/fixtures/page-cdp-proxy.mjs"
|
||||
- "src/browser/**"
|
||||
- "src/domain/browser*.ts"
|
||||
- "src/domain/webScript*.ts"
|
||||
- "src/application/WebScriptExportService.ts"
|
||||
- "src/contracts/webScriptToolContracts.ts"
|
||||
- "src/server/registerWebScriptTool.ts"
|
||||
- "src/cli/webScriptCommands.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -350,20 +350,20 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
|||
|
||||
## Tool catalog for investigation
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
| Application workflows | 7 | cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
| Application workflows | 8 | captured website script export; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||
|
||||
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 records direct behavioral captures.
|
||||
|
||||
|
|
@ -377,6 +377,13 @@ Static Android APK inspection is verified on Linux with headless JADX; see [Andr
|
|||
- **Packages and resources:** Inspect directories, ZIP, APK, IPA, ASAR, plists, compiled Interface Builder files, and Apple asset catalogs. Artifact requests name the input and requested extraction or traversal directly; macOS DMG traversal also requires the host's native mounting support.
|
||||
- **JavaScript and Electron:** Map modules, imports, source maps, routes, IPC channels, storage, and native add-ons without running the app. Compare builds and trace a feature across the recovered graph. Dynamic and ambiguous relationships remain unresolved. See [JavaScript application workflows](docs/javascript-application-workflows.md).
|
||||
- **Websites:** Inspect a selected page in an existing Chrome-family browser. Capture page structure, network metadata, script evidence, and screenshots requested by the call. Passive observation does not navigate or execute page JavaScript. See [browser observation](docs/browser-observation.md).
|
||||
|
||||
Export retained scripts with `export_web_scripts` / `rea export-web-scripts`
|
||||
into a verified local directory, then use the existing JavaScript analysis
|
||||
and tracing tools. Source URLs, frame or transaction references, competing
|
||||
versions, and missing-source states remain inline. See
|
||||
[captured website scripts](docs/website-script-export.md).
|
||||
|
||||
- **Electron and Node runtime observation:** Inspect selected Electron pages or attach to a Node/Electron V8 Inspector target. Inspector observation records script locations and execution-context events; it does not infer imports, IPC activity, or which modules executed. See [runtime observation](docs/javascript-runtime-observation.md).
|
||||
- **.NET assemblies:** Inspect metadata and CIL instructions, compare builds, and check declared native dependencies without loading or running the assembly. Imported decompiler output is labeled as analyst inference. See [managed-code analysis](docs/managed-code-analysis.md).
|
||||
- **Controlled behavior capture:** Run process, browser, or Electron scenarios with the target, actions, and lifecycle declared in each request, then compare the resulting evidence. Missing observations cannot establish that two runs behaved the same way.
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ rea setup
|
|||
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 7 | تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| سير عمل التطبيقات | 8 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||
|
||||
## خطة العمل
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
|||
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
||||
| アプリワークフロー | 7 | 層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| アプリワークフロー | 8 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
|||
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 7 | 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 앱 워크플로 | 8 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
|||
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 7 | 跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 应用工作流 | 8 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ graph TD
|
|||
IDA["src/ida/<br/>Upstream MCP contract adaptation<br/>live GUI binding or owned headless database"]
|
||||
IDA_MCP["External ida-pro-mcp<br/>GUI proxy or database supervisor<br/>upstream installation and engine lifecycle"]
|
||||
AUX["src/artifacts/ + src/native/<br/>disjoint artifact and native capabilities"]
|
||||
BROWSER["src/browser/<br/>Loopback CDP transport<br/>exact-origin passive observation"]
|
||||
BROWSER["src/browser/<br/>CDP / Playwright capture<br/>captured script adapters and publication"]
|
||||
DOTNET["src/dotnet/<br/>Managed static metadata provider<br/>PE/CLI metadata inventory, members, IL"]
|
||||
FIRMWARE["src/firmware/<br/>Binwalk / Unblob adapters<br/>owned processes, report normalization, private extraction"]
|
||||
ANDROID["src/android/<br/>Static APK adapter<br/>owned SDK stdio, pinned headless JADX, normalization"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
# Website observation with CDP
|
||||
|
||||
Captured Debugger sources can be exported for REA's existing static JavaScript
|
||||
analysis with `export_web_scripts`. See [captured website scripts](website-script-export.md)
|
||||
for the capture options, source mappings, and browser/local resolution limits.
|
||||
|
||||
REA can attach to a user-owned Chrome-family browser through the Chrome DevTools Protocol (CDP) and produce bounded Evidence about an existing page. This is a passive reverse-engineering capability, not a general browser automation or remote-control surface.
|
||||
|
||||
## Shipped surfaces
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"skill_version": "26"
|
||||
},
|
||||
"tools": {
|
||||
"total": 125,
|
||||
"total": 126,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
|
|
@ -179,13 +179,14 @@
|
|||
{
|
||||
"id": "application",
|
||||
"surface": "application-workflow",
|
||||
"count": 7,
|
||||
"count": 8,
|
||||
"tools": [
|
||||
"build_reconstruction_obligation_ledger",
|
||||
"compare_application_versions",
|
||||
"compare_javascript_export_shapes",
|
||||
"compare_source_to_bundle",
|
||||
"evaluate_reconstruction_coverage",
|
||||
"export_web_scripts",
|
||||
"trace_application_feature",
|
||||
"trace_javascript_semantics"
|
||||
]
|
||||
|
|
@ -450,6 +451,12 @@
|
|||
"observe_javascript_runtime"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rea-web-script-export",
|
||||
"name": "REA captured script export",
|
||||
"version": "1",
|
||||
"capabilities": ["export_web_scripts"]
|
||||
},
|
||||
{
|
||||
"id": "unblob",
|
||||
"name": "Unblob",
|
||||
|
|
@ -532,7 +539,7 @@
|
|||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 80,
|
||||
"primary_count": 81,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
|
|
@ -562,6 +569,7 @@
|
|||
"evaluate-reconstruction-coverage",
|
||||
"evidence-export",
|
||||
"evidence-import",
|
||||
"export-web-scripts",
|
||||
"extract-artifact",
|
||||
"extract-firmware",
|
||||
"function",
|
||||
|
|
@ -624,15 +632,15 @@
|
|||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 80,
|
||||
"mcp_tools": 125,
|
||||
"cli_commands": 81,
|
||||
"mcp_tools": 126,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "24bd870a291b473a8d22fa93fcb0c0595e534f0d69e4275832c6f37d37ab9068",
|
||||
"tools_sha256": "569db1182f19869a3bdb22cf217bc75526f317b59635c8866d0e62741077cd52",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "1e812d0cef0fc9a479a62b873c26023d2c9491837434ec59857fb03f17bda5fc",
|
||||
"providers_sha256": "044634314b36b966bd918f99f116f4684108556ac1dabc912e76ccd351d97e3e"
|
||||
"combined_sha256": "e21455dd7d64af94e0b07aff55ab82f03a37e46729ece51be38b50083d2d5b55",
|
||||
"providers_sha256": "7949ddcf2eb5ba41b022bb3dfa1951ab96dbf5640285fa7fd5d35b0e4a5bdab3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@ installed Chrome-family browser. An optional script argument selects an already
|
|||
installed package's `scripts/rea.mjs` entry point for packaged-artifact checks.
|
||||
The complete `verify:browser` lane includes these same checks.
|
||||
|
||||
`verify:browser:scripts` checks active script capture → exact-byte export →
|
||||
existing static JavaScript analysis through CLI and stdio MCP, including
|
||||
manifest readback, competing query variants, and resolved relative imports.
|
||||
It uses an installed browser and accepts an optional installed REA entrypoint.
|
||||
The complete `verify:browser` lane also exercises passive script export through
|
||||
both public adapters. See [website script export](website-script-export.md).
|
||||
|
||||
The [test suite audit](test-suite-audit.md) records the pruning decisions,
|
||||
replacement evidence and remaining priorities.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"manifest_id": "ecm_be55eec2622069e55136c644322ea30c22bb7ebc8d7fc45961b536607d947352",
|
||||
"manifest_id": "ecm_d68a1575bdaa1a044ccce8e27c5dd335612f909bc81c52ce3089b576fd772940",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "f329bad02e83305faa35e4fd0e46a698fe5b00e5d849063890227c4bae79ca11"
|
||||
"sha256": "d2d971cc67bb0f7b80b4e39382a6bd1146445ed1dbf52043f89e2da2985c97ba"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
# Captured website scripts and static JavaScript analysis
|
||||
|
||||
`export_web_scripts` bridges retained browser sources into the existing local
|
||||
JavaScript analyzer. Both CLI and MCP use the same workflow. It reads a saved
|
||||
`inspect_web_page` or `capture_browser_scenario` capture and writes scripts plus a
|
||||
verified manifest into one caller-selected, absent directory.
|
||||
|
||||
## Capture and export
|
||||
|
||||
For passive inspection, select `include_script_sources: true` (CLI:
|
||||
`--include-script-sources`). For scenarios, select
|
||||
`capture.network.response_body: true`. Save either the complete Evidence record
|
||||
or its normalized result as UTF-8 JSON. The compact MCP wrapper's `evidence`
|
||||
field contains the complete Evidence record.
|
||||
|
||||
```sh
|
||||
rea export-web-scripts /analysis/capture.json /analysis/exported-scripts --json
|
||||
rea analyze-javascript-application /analysis/exported-scripts/files --json
|
||||
```
|
||||
|
||||
MCP uses `export_web_scripts` with `capture_path` and `output_directory` absolute
|
||||
filesystem paths. Its inline result includes `analysis_input`, which can be
|
||||
passed directly to `analyze_javascript_application`. When no script bytes are
|
||||
exportable, `analysis_input` is null and the manifest retains the unavailable
|
||||
sources and their reasons. Capture again with the corresponding content option.
|
||||
|
||||
The destination must not exist. Use a new directory for each capture; an
|
||||
existing directory or symlink is rejected without overwriting its contents.
|
||||
REA creates private output, verifies every written file by digest and durable
|
||||
readback, and removes its own partial output on failure or cancellation.
|
||||
A failed rollback reports residual paths.
|
||||
|
||||
## Sources and mappings
|
||||
|
||||
Every source is returned inline with its URL and its script/frame identity or
|
||||
request transaction and event sequence references. The result also contains
|
||||
the input capture's byte digest, original coverage, source Evidence link when
|
||||
provided, and the persisted manifest's digest and byte count. A saved Evidence
|
||||
envelope must have a valid semantic identifier and matching operation.
|
||||
|
||||
Exported `relative_path` values are relative to `analysis_input.input_path`.
|
||||
The persisted `manifest.json` contains the same inline manifest fields, excluding
|
||||
its own descriptor. It does not duplicate script bytes.
|
||||
|
||||
- Passive sources are the Debugger-exposed text encoded as UTF-8, with the
|
||||
verified source digest and source-map declaration retained. Unavailable or
|
||||
explicitly non-JavaScript sources remain unavailable.
|
||||
- Scenario sources use observed requests classified as `script`, matched to
|
||||
completed response bytes by transaction and event identity. URLs never select
|
||||
the response. Binary and empty retained bytes are written exactly.
|
||||
- Ordinary query-free `.js`, `.mjs`, and `.cjs` URL paths are arranged under a
|
||||
directory for each origin, preserving their relative module layout when it
|
||||
is unambiguous.
|
||||
- Competing versions, query/fragment variants, case and file/directory prefix
|
||||
collisions, inline sources, and unrepresentable paths use unique isolated
|
||||
names with a stated reason. Missing competing sources also prevent selection
|
||||
of an apparently canonical version.
|
||||
|
||||
## Interpretation and limits
|
||||
|
||||
Export does not fetch URLs or execute JavaScript. Retained Debugger sources and
|
||||
response bytes do not establish execution or a complete website. Response bytes
|
||||
are browser-decoded bytes; they do not represent transfer encoding. Declared
|
||||
redaction is preserved and can change JavaScript syntax; passive captures do
|
||||
not report a byte-redaction flag, so that value remains unknown.
|
||||
|
||||
The existing analyzer inventories and parses the actual exported files. It can
|
||||
resolve ordinary captured relative modules, but does not implement browser
|
||||
import maps, root-relative URL loading, remote imports, or runtime behavior.
|
||||
Its local resolver may strip import queries/fragments; isolated variants are
|
||||
kept away from canonical paths to prevent those from selecting a different
|
||||
observed version. Missing dependencies remain unknown. Source maps are not
|
||||
fetched or reconstructed during export.
|
||||
|
||||
The export Evidence describes a derivation from the saved capture. Subsequent
|
||||
application-analysis Evidence describes the local exported artifact. Their
|
||||
identities and authorities remain separate.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run verify:browser:scripts` exercises active capture, exact source export,
|
||||
manifest readback, query variants, and relative-module analysis through a real
|
||||
browser, CLI, and stdio MCP. Set `REA_BROWSER_EXECUTABLE` to an installed
|
||||
Chrome-family browser. The verifier accepts an optional installed REA entrypoint
|
||||
argument for package verification. `npm run verify:browser` additionally covers
|
||||
passive `inspect_web_page` export through both public adapters.
|
||||
|
|
@ -174,7 +174,8 @@
|
|||
"verify:ghidra:windows:package": "node scripts/verify-packaged-ghidra-windows.mjs",
|
||||
"build:ghidra:nativeaot": "node scripts/build-ghidra-nativeaot.mjs",
|
||||
"build:fixtures:nativeaot": "node scripts/generate-nativeaot-fixtures.mjs",
|
||||
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs"
|
||||
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs",
|
||||
"verify:browser:scripts": "npm run build:cached && node scripts/verify-browser-scripts.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { createServer } from "node:http";
|
||||
|
||||
/** Independently authored modules and competing query variants for export verification. */
|
||||
export async function startBrowserScriptSite() {
|
||||
const assets = new Map([
|
||||
[
|
||||
"/app/main.js",
|
||||
"import { sourceMarker } from './lib/dep.js'; export const result = sourceMarker; document.body.dataset.ready = 'true';\n",
|
||||
],
|
||||
[
|
||||
"/app/lib/dep.js",
|
||||
"export const sourceMarker = 'independent-source-marker';\n",
|
||||
],
|
||||
["/variant.js?v=1", "globalThis.variantOne = 'query-variant-one';\n"],
|
||||
["/variant.js?v=2", "globalThis.variantTwo = 'query-variant-two';\n"],
|
||||
]);
|
||||
const counts = new Map();
|
||||
const server = createServer((request, response) => {
|
||||
counts.set(request.url, (counts.get(request.url) ?? 0) + 1);
|
||||
const source = assets.get(request.url);
|
||||
if (source !== undefined) {
|
||||
response.setHeader("content-type", "text/javascript; charset=utf-8");
|
||||
response.end(source);
|
||||
return;
|
||||
}
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.end(
|
||||
'<!doctype html><html><body><script src="/variant.js?v=1"></script><script src="/variant.js?v=2"></script><script type="module" src="/app/main.js"></script></body></html>',
|
||||
);
|
||||
});
|
||||
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("Script fixture did not bind a port");
|
||||
return {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
assets,
|
||||
counts,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) =>
|
||||
server.close((cause) =>
|
||||
cause === undefined ? resolve() : reject(cause),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { webScriptExportResultSchema } from "../../dist/domain/webScriptExport.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../dist/domain/javascriptApplicationAnalysis.js";
|
||||
|
||||
/** Verify exact exported bytes and the independently authored source manifest. */
|
||||
export const assertWebScriptExport = async (evidence, capture, assets) => {
|
||||
const result = webScriptExportResultSchema.parse(evidence.normalized_result);
|
||||
assert.equal(result.source_evidence_id, capture.evidence_id);
|
||||
assert.deepEqual(evidence.evidence_links, [capture.evidence_id]);
|
||||
const manifest = await readFile(result.manifest.path);
|
||||
assert.equal(
|
||||
createHash("sha256").update(manifest).digest("hex"),
|
||||
result.manifest.sha256,
|
||||
);
|
||||
const { manifest: descriptor, ...inline } = result;
|
||||
assert.equal(descriptor.bytes, manifest.length);
|
||||
assert.deepEqual(JSON.parse(manifest.toString()), inline);
|
||||
assert.ok(result.analysis_input);
|
||||
assert.ok(result.scripts.length > 0);
|
||||
for (const script of result.scripts) {
|
||||
assert.equal(script.content.state, "exported");
|
||||
const bytes = await readFile(
|
||||
join(result.analysis_input.input_path, script.content.relative_path),
|
||||
);
|
||||
assert.equal(
|
||||
createHash("sha256").update(bytes).digest("hex"),
|
||||
script.content.sha256,
|
||||
);
|
||||
if (assets) {
|
||||
const url = new URL(script.url);
|
||||
assert.equal(
|
||||
bytes.toString(),
|
||||
assets.get(`${url.pathname}${url.search}`),
|
||||
`Wrong bytes for ${script.url}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (assets) {
|
||||
assert.equal(result.scripts.length, assets.size);
|
||||
const variants = result.scripts.filter(({ url }) =>
|
||||
url.includes("/variant.js?"),
|
||||
);
|
||||
assert.equal(variants.length, 2);
|
||||
assert.ok(variants.every(({ content }) => content.layout === "isolated"));
|
||||
assert.equal(
|
||||
new Set(variants.map(({ content }) => content.relative_path)).size,
|
||||
2,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
/** Verify existing application analysis and an observed relative module layout. */
|
||||
export const assertExportedScriptAnalysis = (evidence, modules, imports) => {
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assert.equal(result.statistics.parsed_javascript_files, modules);
|
||||
if (imports)
|
||||
assert.ok(
|
||||
result.graph.edges.some(
|
||||
({ relation, properties }) =>
|
||||
relation === "imports" &&
|
||||
properties.specifier === "./lib/dep.js" &&
|
||||
properties.resolution_status === "resolved",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import { startBrowserScriptSite } from "../fixtures/browser-script-site.mjs";
|
||||
import {
|
||||
assertWebScriptExport,
|
||||
assertExportedScriptAnalysis,
|
||||
} from "./browser-script-export-assertions.mjs";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Verify capture → export → existing analysis through real CLI and stdio MCP. */
|
||||
export async function verifyBrowserScriptExport(
|
||||
executable,
|
||||
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
attachment,
|
||||
) {
|
||||
const site = await startBrowserScriptSite();
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-browser-script-export-"));
|
||||
const env = {
|
||||
...process.env,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
|
||||
};
|
||||
const cli = exportCli(entrypoint, env);
|
||||
const scenario = scriptCaptureScenario(executable, site.origin);
|
||||
const save = (name, capture) => saveCapture(root, name, capture);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "web-script-real-e2e", version: "1" });
|
||||
try {
|
||||
const cliCapture = await cli(
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
);
|
||||
const cliInput = await save("cli", cliCapture);
|
||||
const counts = new Map(site.counts);
|
||||
const cliExport = await cli(
|
||||
"export-web-scripts",
|
||||
cliInput.capture_path,
|
||||
cliInput.output_directory,
|
||||
);
|
||||
const cliResult = await assertWebScriptExport(
|
||||
cliExport,
|
||||
cliCapture,
|
||||
site.assets,
|
||||
);
|
||||
await cli(
|
||||
"analyze-javascript-application",
|
||||
cliResult.analysis_input.input_path,
|
||||
).then((value) => assertExportedScriptAnalysis(value, 4, true));
|
||||
assert.deepEqual(
|
||||
site.counts,
|
||||
counts,
|
||||
"Export/analysis refetched a captured asset",
|
||||
);
|
||||
await client.connect(transport);
|
||||
const call = async (name, args) => {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
|
||||
};
|
||||
const mcpCapture = await call("capture_browser_scenario", scenario);
|
||||
const mcpInput = await save("mcp", mcpCapture);
|
||||
const mcpCounts = new Map(site.counts);
|
||||
const mcpResult = await assertWebScriptExport(
|
||||
await call("export_web_scripts", mcpInput),
|
||||
mcpCapture,
|
||||
site.assets,
|
||||
);
|
||||
assertExportedScriptAnalysis(
|
||||
await call("analyze_javascript_application", mcpResult.analysis_input),
|
||||
4,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(
|
||||
site.counts,
|
||||
mcpCounts,
|
||||
"MCP export/analysis refetched a captured asset",
|
||||
);
|
||||
const passive = attachment
|
||||
? await verifyPassiveExport(attachment, { cli, call, save })
|
||||
: false;
|
||||
return {
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
active_scripts: 4,
|
||||
passive,
|
||||
exact_bytes: true,
|
||||
manifest_readback: true,
|
||||
relative_import_resolved: true,
|
||||
query_variants_isolated: true,
|
||||
implicit_refetches: 0,
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await site.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const verifyPassiveExport = async (attachment, { cli, call, save }) => {
|
||||
const input = {
|
||||
...attachment,
|
||||
observation_ms: 100,
|
||||
include_script_sources: true,
|
||||
};
|
||||
const page = await call("inspect_web_page", input);
|
||||
const pageInput = await save("passive", page);
|
||||
const exported = await assertWebScriptExport(
|
||||
await call("export_web_scripts", pageInput),
|
||||
page,
|
||||
);
|
||||
assertExportedScriptAnalysis(
|
||||
await call("analyze_javascript_application", exported.analysis_input),
|
||||
exported.scripts.length,
|
||||
false,
|
||||
);
|
||||
const pageCli = await cli(
|
||||
"inspect-web-page",
|
||||
attachment.cdp_endpoint,
|
||||
attachment.target_id,
|
||||
"--include-script-sources",
|
||||
"--observation-ms",
|
||||
"100",
|
||||
);
|
||||
const pageCliInput = await save("passive-cli", pageCli);
|
||||
const pageCliResult = await assertWebScriptExport(
|
||||
await cli(
|
||||
"export-web-scripts",
|
||||
pageCliInput.capture_path,
|
||||
pageCliInput.output_directory,
|
||||
),
|
||||
pageCli,
|
||||
);
|
||||
assertExportedScriptAnalysis(
|
||||
await cli(
|
||||
"analyze-javascript-application",
|
||||
pageCliResult.analysis_input.input_path,
|
||||
),
|
||||
pageCliResult.scripts.length,
|
||||
false,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const exportCli =
|
||||
(entrypoint, env) =>
|
||||
async (...args) => {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--json"],
|
||||
{ env, timeout: 60000, maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
};
|
||||
|
||||
const saveCapture = async (root, name, capture) => {
|
||||
const capturePath = join(root, `${name}.json`);
|
||||
await writeFile(capturePath, JSON.stringify(capture));
|
||||
return {
|
||||
capture_path: capturePath,
|
||||
output_directory: join(root, `${name}-export`),
|
||||
};
|
||||
};
|
||||
|
||||
const scriptCaptureScenario = (executable, origin) => ({
|
||||
browser: { mode: "launch", executable_path: executable },
|
||||
start_url: { url: origin },
|
||||
actions: [
|
||||
{
|
||||
step_id: "ready",
|
||||
action: "wait_for",
|
||||
locator: { kind: "css", selector: '[data-ready="true"]' },
|
||||
state: "visible",
|
||||
timeout_ms: 10000,
|
||||
},
|
||||
],
|
||||
capture: { network: { response_body: true } },
|
||||
});
|
||||
|
|
@ -64,7 +64,10 @@ export const toolFamilyCatalog = (sources) => {
|
|||
{
|
||||
id: "application",
|
||||
surface: "application-workflow",
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
contracts: [
|
||||
...sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
...sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "session",
|
||||
|
|
@ -184,6 +187,10 @@ export const providerCatalog = (sources) => {
|
|||
sources.artifactProviders.JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.WEB_SCRIPT_EXPORT_PROVIDER,
|
||||
contracts: sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
},
|
||||
]
|
||||
.map(({ identity, contracts }) => ({
|
||||
id: identity.id,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const SOURCE_PATHS = {
|
|||
javascriptRuntimeObservationContracts:
|
||||
"dist/contracts/javascriptRuntimeObservationToolContracts.js",
|
||||
applicationContracts: "dist/contracts/applicationToolContracts.js",
|
||||
webScriptContracts: "dist/contracts/webScriptToolContracts.js",
|
||||
supportedClients: "dist/application/SupportedClients.js",
|
||||
hopperProvider: "dist/hopper/HopperProvider.js",
|
||||
ghidraProvider: "dist/ghidra/GhidraProvider.js",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
import { access } from "node:fs/promises";
|
||||
import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const executable = process.env.REA_BROWSER_EXECUTABLE;
|
||||
if (executable === undefined || executable === "")
|
||||
throw new Error(
|
||||
"verify:browser:scripts requires REA_BROWSER_EXECUTABLE pointing to an installed Chrome-family browser",
|
||||
);
|
||||
await access(executable);
|
||||
const run = createVerifierRun();
|
||||
const proof = await verifyBrowserScriptExport(executable, process.argv[2]);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...proof, verifier_run: await completeVerifierRun(run), verified: true })}\n`,
|
||||
);
|
||||
|
|
@ -36,6 +36,7 @@ import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
|||
import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs";
|
||||
import { verifyPopupEventCoverage } from "./lib/browser-popup-e2e.mjs";
|
||||
import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs";
|
||||
import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs";
|
||||
|
||||
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
|
||||
|
|
@ -326,6 +327,10 @@ try {
|
|||
undefined,
|
||||
{ cdp_endpoint: endpoint, target_id: target },
|
||||
);
|
||||
const scriptExport = await verifyBrowserScriptExport(executable, undefined, {
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
|
|
@ -346,6 +351,7 @@ try {
|
|||
largeScreenshot,
|
||||
popupEvents,
|
||||
networkEvidence,
|
||||
scriptExport,
|
||||
browserScenarioCli: true,
|
||||
browserScenarioAttachCleanup: "disconnected-external",
|
||||
browserScenarioLaunchCleanup: "terminated-owned-process",
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ name: reverse-engineer-anything
|
|||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "26"
|
||||
tool_count: 125
|
||||
catalog_digest: "1e812d0cef0fc9a479a62b873c26023d2c9491837434ec59857fb03f17bda5fc"
|
||||
tool_count: 126
|
||||
catalog_digest: "e21455dd7d64af94e0b07aff55ab82f03a37e46729ece51be38b50083d2d5b55"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ export const ANDROID_APPLICATION_PROVIDER = {
|
|||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Provider identity for verified publication of retained website scripts. */
|
||||
export const WEB_SCRIPT_EXPORT_PROVIDER = {
|
||||
id: "rea-web-script-export",
|
||||
name: "REA captured script export",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Provider identity for deterministic static JavaScript application analysis. */
|
||||
export const JAVASCRIPT_APPLICATION_PROVIDER = {
|
||||
id: "rea-javascript-application",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { publishWebScripts } from "../browser/assets/PublishWebScripts.js";
|
||||
import { selectScriptCapture } from "../browser/assets/ScriptCaptureAdapters.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import {
|
||||
exportWebScriptsInputSchema,
|
||||
type ExportWebScriptsInput,
|
||||
} from "../domain/webScriptExport.js";
|
||||
import { WebScriptExportError } from "../domain/webScriptExportError.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import { WEB_SCRIPT_EXPORT_PROVIDER as PROVIDER } from "./InvestigationProviders.js";
|
||||
|
||||
const OPERATION = "export_web_scripts";
|
||||
|
||||
/** Export one local capture through the shared CLI/MCP application workflow. */
|
||||
export const exportWebScripts = async (
|
||||
rawInput: unknown,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const input = exportWebScriptsInputSchema.safeParse(rawInput);
|
||||
return input.success
|
||||
? exportWebScriptsValidated(input.data, options)
|
||||
: err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
undefined,
|
||||
projectInputIssues(input.error.issues, rawInput),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** Publish input already parsed by a named adapter contract. */
|
||||
export const exportWebScriptsValidated = async (
|
||||
input: ExportWebScriptsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (!isAbsolute(input.capture_path) || !isAbsolute(input.output_directory))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_format",
|
||||
message:
|
||||
"capture_path and output_directory must be absolute filesystem paths on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
try {
|
||||
options.signal?.throwIfAborted();
|
||||
const bytes = await readFile(input.capture_path, {
|
||||
signal: options.signal,
|
||||
});
|
||||
const loaded = parseCapture(bytes);
|
||||
if (!loaded.ok) return loaded;
|
||||
options.signal?.throwIfAborted();
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const result = await publishWebScripts(
|
||||
input,
|
||||
loaded.value,
|
||||
sha256,
|
||||
options.signal,
|
||||
);
|
||||
return ok(
|
||||
createEvidence(
|
||||
{ path: input.capture_path, sha256, format: "file" },
|
||||
PROVIDER,
|
||||
{
|
||||
predicateType: "rea.web-script-export",
|
||||
operation: OPERATION,
|
||||
parameters: {
|
||||
capture_path: input.capture_path,
|
||||
output_directory: input.output_directory,
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence: "derived",
|
||||
authority: "historical-reference",
|
||||
limitations: result.limitations,
|
||||
locations: [{ kind: "artifact-path", path: result.manifest.path }],
|
||||
evidenceLinks:
|
||||
loaded.value.sourceEvidenceId === null
|
||||
? []
|
||||
: [loaded.value.sourceEvidenceId],
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof WebScriptExportError) return err(cause);
|
||||
if (options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return err(
|
||||
new WebScriptExportError(
|
||||
cause.reason,
|
||||
input.output_directory,
|
||||
cause.message,
|
||||
),
|
||||
);
|
||||
if (cause instanceof z.ZodError)
|
||||
return err(new AnalysisOutputError(OPERATION, cause.message, { cause }));
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
typeof cause.code === "string"
|
||||
)
|
||||
return err(
|
||||
new WebScriptExportError(
|
||||
"io",
|
||||
`${input.capture_path} → ${input.output_directory}`,
|
||||
cause.message,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new ProviderAdapterError(PROVIDER.id, OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
capture_path: input.capture_path,
|
||||
output_directory: input.output_directory,
|
||||
error_name: cause instanceof Error ? cause.name : "UnknownError",
|
||||
error_message:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: typeof cause === "string"
|
||||
? cause
|
||||
: "Unknown script export failure",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const parseCapture = (
|
||||
bytes: Buffer,
|
||||
): Result<ReturnType<typeof selectScriptCapture>, AnalysisError> => {
|
||||
try {
|
||||
const text = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(bytes);
|
||||
const json = safeParseJson(text);
|
||||
if (!json.ok) throw new TypeError(json.error);
|
||||
return ok(selectScriptCapture(json.value));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["capture_path"],
|
||||
reason: "invalid_format",
|
||||
message:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Capture is not valid UTF-8 JSON evidence.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { WebPageInspection } from "../../domain/browserObservation.js";
|
||||
import type { CapturedWebScript } from "../../domain/webScriptExport.js";
|
||||
|
||||
/** Project verified Debugger sources without fetching resource URLs. */
|
||||
export const pageScripts = (
|
||||
capture: Pick<WebPageInspection, "scripts">,
|
||||
): CapturedWebScript[] =>
|
||||
capture.scripts.items.map((script) => ({
|
||||
source: {
|
||||
kind: "page-script",
|
||||
script_key: script.script_key,
|
||||
frame_id: script.frame_id,
|
||||
is_module: script.is_module,
|
||||
language: script.language,
|
||||
source_map_url: script.source_map_url,
|
||||
},
|
||||
url: script.url,
|
||||
content: !script.source.included
|
||||
? {
|
||||
state: "unavailable",
|
||||
reason: "source-not-included",
|
||||
message: script.source.reason || "Debugger source was not included.",
|
||||
}
|
||||
: script.language !== null && script.language !== "JavaScript"
|
||||
? {
|
||||
state: "unavailable",
|
||||
reason: "unsupported-language",
|
||||
message: `Debugger reported language ${script.language}; JavaScript export was not inferred.`,
|
||||
}
|
||||
: {
|
||||
state: "captured",
|
||||
bytes: Buffer.from(script.source.artifact.text, "utf8"),
|
||||
sha256: script.source.artifact.sha256,
|
||||
media_type: script.source.artifact.media_type,
|
||||
redacted: null,
|
||||
representation: "debugger-source-utf8",
|
||||
},
|
||||
}));
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
|
||||
import {
|
||||
webScriptExportManifestSchema,
|
||||
webScriptExportResultSchema,
|
||||
type ExportWebScriptsInput,
|
||||
type WebScriptExportResult,
|
||||
} from "../../domain/webScriptExport.js";
|
||||
import { planWebScriptExport } from "../../domain/webScriptExportPlan.js";
|
||||
import { WebScriptExportError } from "../../domain/webScriptExportError.js";
|
||||
import type { SelectedScriptCapture } from "./ScriptCaptureAdapters.js";
|
||||
|
||||
const LIMITATIONS = [
|
||||
"Only script bytes retained in this capture are exported. Sources are not refetched or executed, and a response or Debugger source does not prove execution.",
|
||||
"Ordinary unambiguous URL paths preserve relative module layouts by origin. Query variants, collisions, inline sources, and unrepresentable paths are isolated; their browser module relationships remain unknown.",
|
||||
"Local JavaScript analysis does not implement browser import maps, root-relative URL resolution, remote URL loading, or browser runtime behavior. It may strip import queries and fragments; isolated variants are not canonical targets.",
|
||||
"Source-map declarations are retained as metadata. No source maps or missing dependencies are fetched or reconstructed.",
|
||||
"Captured response bytes are browser-decoded bytes; Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.",
|
||||
];
|
||||
|
||||
/** Exclusively publish verified bytes and a manifest, rolling back partial work. */
|
||||
export const publishWebScripts = async (
|
||||
input: ExportWebScriptsInput,
|
||||
capture: SelectedScriptCapture & { readonly sourceEvidenceId: string | null },
|
||||
captureSha256: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WebScriptExportResult> => {
|
||||
const planned = planWebScriptExport(capture.scripts);
|
||||
const tree = await SafeOutputTree.create(input.output_directory);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const records = planned.map(({ record }) => record);
|
||||
const manifest = webScriptExportManifestSchema.parse({
|
||||
capture_path: input.capture_path,
|
||||
capture_sha256: captureSha256,
|
||||
capture_kind: capture.kind,
|
||||
source_evidence_id: capture.sourceEvidenceId,
|
||||
capture_completeness: capture.completeness,
|
||||
output_directory: tree.outputRoot,
|
||||
analysis_input: records.some(
|
||||
({ content }) => content.state === "exported",
|
||||
)
|
||||
? { input_path: join(tree.outputRoot, "files"), format: "directory" }
|
||||
: null,
|
||||
scripts: records,
|
||||
limitations: [
|
||||
...capture.limitations,
|
||||
...LIMITATIONS,
|
||||
...(records.some(({ content }) => content.state === "exported")
|
||||
? []
|
||||
: [
|
||||
"No script bytes were exportable. Include script sources in inspect_web_page or select response_body in capture_browser_scenario, then capture again.",
|
||||
]),
|
||||
],
|
||||
});
|
||||
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
const result = webScriptExportResultSchema.parse({
|
||||
...manifest,
|
||||
manifest: {
|
||||
path: join(tree.outputRoot, "manifest.json"),
|
||||
sha256: digest,
|
||||
bytes: bytes.length,
|
||||
},
|
||||
});
|
||||
for (const { script, record } of planned) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
script.content.state === "captured" &&
|
||||
record.content.state === "exported"
|
||||
)
|
||||
await tree.write(
|
||||
`files/${record.content.relative_path}`,
|
||||
Readable.from([script.content.bytes]),
|
||||
script.content.sha256,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
await tree.write("manifest.json", Readable.from([bytes]), digest, signal);
|
||||
signal?.throwIfAborted();
|
||||
await tree.commit();
|
||||
return result;
|
||||
} catch (cause: unknown) {
|
||||
try {
|
||||
await tree.rollback();
|
||||
} catch (cleanupCause: unknown) {
|
||||
throw new WebScriptExportError(
|
||||
"io",
|
||||
tree.outputRoot,
|
||||
`Publication failed (${message(cause)}); rollback failed (${message(cleanupCause)}). Remove the residual output directory before retrying.`,
|
||||
[tree.outputRoot],
|
||||
);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const message = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : "Unknown publication failure";
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import type { BrowserScenarioCapture } from "../../domain/browserScenarioCapture.js";
|
||||
import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js";
|
||||
import type { BrowserNetworkBody } from "../../domain/browserNetworkEvidence.js";
|
||||
import type { CapturedWebScript } from "../../domain/webScriptExport.js";
|
||||
|
||||
type RequestEvent = Extract<BrowserScenarioEvent, { kind: "request" }>;
|
||||
type ResponseEvent = Extract<BrowserScenarioEvent, { kind: "response" }>;
|
||||
type ContentEvent = Extract<BrowserScenarioEvent, { kind: "network-content" }>;
|
||||
|
||||
interface ResponseIndex {
|
||||
readonly responses: Map<string, ResponseEvent[]>;
|
||||
readonly contents: Map<number, ContentEvent[]>;
|
||||
readonly requests: Map<string, number>;
|
||||
}
|
||||
|
||||
/** Join script response bytes only through explicit transaction and event identity. */
|
||||
export const scenarioScripts = (
|
||||
capture: BrowserScenarioCapture,
|
||||
): CapturedWebScript[] => {
|
||||
const index = indexResponses(capture.events.items);
|
||||
return capture.events.items.flatMap((request): CapturedWebScript[] => {
|
||||
if (request.kind !== "request" || request.resource_type !== "script")
|
||||
return [];
|
||||
return [projectRequest(request, index)];
|
||||
});
|
||||
};
|
||||
|
||||
const indexResponses = (
|
||||
events: readonly BrowserScenarioEvent[],
|
||||
): ResponseIndex => {
|
||||
const index: ResponseIndex = {
|
||||
responses: new Map(),
|
||||
contents: new Map(),
|
||||
requests: new Map(),
|
||||
};
|
||||
for (const event of events) {
|
||||
if (event.kind === "request" && event.transaction_id !== undefined)
|
||||
index.requests.set(
|
||||
event.transaction_id,
|
||||
(index.requests.get(event.transaction_id) ?? 0) + 1,
|
||||
);
|
||||
if (event.kind === "response" && event.transaction_id !== undefined)
|
||||
append(index.responses, event.transaction_id, event);
|
||||
if (event.kind === "network-content" && event.phase === "response")
|
||||
append(index.contents, event.source_event_sequence, event);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const projectRequest = (
|
||||
request: RequestEvent,
|
||||
index: ResponseIndex,
|
||||
): CapturedWebScript => {
|
||||
const id = request.transaction_id;
|
||||
const matches = id === undefined ? [] : (index.responses.get(id) ?? []);
|
||||
const response = matches.length === 1 ? matches[0] : undefined;
|
||||
const bodies =
|
||||
response === undefined ? [] : (index.contents.get(response.sequence) ?? []);
|
||||
const body = bodies.length === 1 ? bodies[0]?.body : undefined;
|
||||
const ambiguous =
|
||||
(id !== undefined && index.requests.get(id) !== 1) ||
|
||||
matches.length > 1 ||
|
||||
bodies.length > 1;
|
||||
return {
|
||||
source: {
|
||||
kind: "scenario-response",
|
||||
transaction_id: id ?? null,
|
||||
request_sequence: request.sequence,
|
||||
response_sequence: ambiguous ? null : (response?.sequence ?? null),
|
||||
status: ambiguous ? null : (response?.status ?? null),
|
||||
},
|
||||
url: request.url.url,
|
||||
content: projectBody(body, id, ambiguous),
|
||||
};
|
||||
};
|
||||
|
||||
const unavailable = (
|
||||
reason: string,
|
||||
message: string,
|
||||
): CapturedWebScript["content"] => ({ state: "unavailable", reason, message });
|
||||
|
||||
const projectBody = (
|
||||
body: BrowserNetworkBody | undefined,
|
||||
transactionId: string | undefined,
|
||||
ambiguous: boolean,
|
||||
): CapturedWebScript["content"] => {
|
||||
if (ambiguous)
|
||||
return unavailable(
|
||||
"ambiguous-transaction",
|
||||
"The capture does not identify one request, response, and content record for this transaction.",
|
||||
);
|
||||
if (body === undefined)
|
||||
return unavailable(
|
||||
transactionId === undefined
|
||||
? "transaction-identity-unavailable"
|
||||
: "response-content-not-retained",
|
||||
"No selected, completed script response bytes were retained for this request.",
|
||||
);
|
||||
if (body.state === "unavailable")
|
||||
return unavailable(body.reason, body.message);
|
||||
if (body.state !== "captured")
|
||||
return unavailable(
|
||||
body.state,
|
||||
"No selected, completed script response bytes were retained for this request.",
|
||||
);
|
||||
if (body.representation !== "browser-decoded-response-bytes")
|
||||
return unavailable(
|
||||
"unsupported-body-representation",
|
||||
`Captured representation ${body.representation} is not script response bytes.`,
|
||||
);
|
||||
return {
|
||||
state: "captured",
|
||||
bytes: Buffer.from(body.content, "base64"),
|
||||
sha256: body.sha256,
|
||||
media_type: body.media_type,
|
||||
redacted: body.redacted,
|
||||
representation: body.representation,
|
||||
};
|
||||
};
|
||||
|
||||
const append = <Key, Value>(
|
||||
map: Map<Key, Value[]>,
|
||||
key: Key,
|
||||
value: Value,
|
||||
): void => {
|
||||
const existing = map.get(key);
|
||||
if (existing === undefined) map.set(key, [value]);
|
||||
else existing.push(value);
|
||||
};
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
scriptCaptureEvidenceFixture,
|
||||
scriptScenarioFixture,
|
||||
} from "../../../tests/fixtures/webScriptCapture.js";
|
||||
import { createWebTextArtifact } from "../../domain/webContentArtifact.js";
|
||||
import { pageScripts } from "./PageScriptCapture.js";
|
||||
import { selectScriptCapture } from "./ScriptCaptureAdapters.js";
|
||||
|
||||
describe("passive script projection", () => {
|
||||
it("preserves passive source bytes, declarations, and unavailable languages", () => {
|
||||
const artifact = createWebTextArtifact(
|
||||
"export const 中文 = '值';",
|
||||
"text/javascript",
|
||||
);
|
||||
const base = {
|
||||
script_key: `scr_${"a".repeat(64)}`,
|
||||
frame_id: "frame",
|
||||
url: "https://a.test/main.js",
|
||||
origin: "https://a.test",
|
||||
cdp_hash: "cdp-hash",
|
||||
length: artifact.text.length,
|
||||
is_module: true,
|
||||
language: "JavaScript",
|
||||
source_map_url: "main.js.map",
|
||||
resource_reconciliation: {
|
||||
status: "exact",
|
||||
resource_key: `res_${"b".repeat(64)}`,
|
||||
} as const,
|
||||
source: { included: true, artifact } as const,
|
||||
};
|
||||
const scripts = pageScripts({
|
||||
scripts: {
|
||||
total: 3,
|
||||
items: [
|
||||
base,
|
||||
{ ...base, language: "WebAssembly" },
|
||||
{ ...base, source: { included: false, reason: "not selected" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(scripts[0]?.content).toMatchObject({
|
||||
state: "captured",
|
||||
sha256: artifact.sha256,
|
||||
bytes: Buffer.from(artifact.text),
|
||||
representation: "debugger-source-utf8",
|
||||
redacted: null,
|
||||
});
|
||||
expect(scripts[0]?.source).toMatchObject({
|
||||
source_map_url: "main.js.map",
|
||||
frame_id: "frame",
|
||||
});
|
||||
expect(scripts[1]?.content).toMatchObject({
|
||||
state: "unavailable",
|
||||
reason: "unsupported-language",
|
||||
});
|
||||
expect(scripts[2]?.content).toMatchObject({
|
||||
state: "unavailable",
|
||||
message: "not selected",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("script capture identity and response projection", () => {
|
||||
it("joins identical URLs by transaction and source event, retaining binary bytes", () => {
|
||||
const capture = scriptScenarioFixture([
|
||||
{
|
||||
url: "https://fixture.test/main.js",
|
||||
bytes: Buffer.from([255, 0, 254]),
|
||||
},
|
||||
{ url: "https://fixture.test/main.js", bytes: Buffer.from("second") },
|
||||
]);
|
||||
capture.events.items.reverse();
|
||||
const scripts = selectScriptCapture(capture).scripts;
|
||||
expect(scripts[0]?.content).toMatchObject({ bytes: Buffer.from("second") });
|
||||
expect(scripts[1]?.content).toMatchObject({
|
||||
bytes: Buffer.from([255, 0, 254]),
|
||||
});
|
||||
expect(scripts[0]?.source).toMatchObject({
|
||||
transaction_id: "request-2",
|
||||
response_sequence: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a metadata-only script request explicitly unavailable", () => {
|
||||
const capture = scriptScenarioFixture();
|
||||
capture.events.items = capture.events.items.filter(
|
||||
({ kind }) => kind !== "network-content",
|
||||
);
|
||||
capture.events.retained = capture.events.items.length;
|
||||
expect(selectScriptCapture(capture).scripts[0]?.content).toMatchObject({
|
||||
state: "unavailable",
|
||||
reason: "response-content-not-retained",
|
||||
});
|
||||
});
|
||||
|
||||
it("authenticates saved Evidence and rejects changed result bytes", () => {
|
||||
const evidence = scriptCaptureEvidenceFixture();
|
||||
expect(selectScriptCapture(evidence).sourceEvidenceId).toBe(
|
||||
evidence.evidence_id,
|
||||
);
|
||||
expect(() =>
|
||||
selectScriptCapture({
|
||||
...evidence,
|
||||
normalized_result: scriptScenarioFixture([]),
|
||||
}),
|
||||
).toThrow("semantic identifier");
|
||||
});
|
||||
|
||||
it("rejects invalid content digests before publication", () => {
|
||||
const capture = scriptScenarioFixture();
|
||||
const content = capture.events.items.find(
|
||||
({ kind }) => kind === "network-content",
|
||||
);
|
||||
if (
|
||||
content?.kind !== "network-content" ||
|
||||
content.body.state !== "captured"
|
||||
)
|
||||
throw new Error("Missing fixture body");
|
||||
content.body.sha256 = "0".repeat(64);
|
||||
expect(() => selectScriptCapture(capture)).toThrow("Expected a valid");
|
||||
});
|
||||
|
||||
it("refuses to select one byte source from duplicate transaction identities", () => {
|
||||
const capture = scriptScenarioFixture();
|
||||
const request = capture.events.items[0];
|
||||
if (request?.kind !== "request") throw new Error("Missing request");
|
||||
capture.events.items.push({ ...request, sequence: 4 });
|
||||
capture.events.retained = 4;
|
||||
expect(selectScriptCapture(capture).scripts[0]?.source).toMatchObject({
|
||||
response_sequence: null,
|
||||
status: null,
|
||||
});
|
||||
expect(
|
||||
selectScriptCapture(capture).scripts.every(
|
||||
({ content }) =>
|
||||
content.state === "unavailable" &&
|
||||
content.reason === "ambiguous-transaction",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retains declared redaction and rejects an unexpected response representation", () => {
|
||||
const capture = scriptScenarioFixture();
|
||||
const content = capture.events.items.find(
|
||||
({ kind }) => kind === "network-content",
|
||||
);
|
||||
if (
|
||||
content?.kind !== "network-content" ||
|
||||
content.body.state !== "captured"
|
||||
)
|
||||
throw new Error("Missing response bytes");
|
||||
content.body.redacted = true;
|
||||
expect(selectScriptCapture(capture).scripts[0]?.content).toMatchObject({
|
||||
state: "captured",
|
||||
redacted: true,
|
||||
});
|
||||
content.body.representation = "browser-exposed-request-bytes";
|
||||
expect(selectScriptCapture(capture).scripts[0]?.content).toMatchObject({
|
||||
state: "unavailable",
|
||||
reason: "unsupported-body-representation",
|
||||
message: expect.stringContaining("browser-exposed-request-bytes"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not join legacy metadata by URL when transaction identity is absent", () => {
|
||||
const capture = scriptScenarioFixture();
|
||||
capture.events.items = capture.events.items.filter(
|
||||
({ kind }) => kind !== "network-content",
|
||||
);
|
||||
for (const event of capture.events.items)
|
||||
if (event.kind === "request" || event.kind === "response")
|
||||
delete event.transaction_id;
|
||||
capture.events.retained = capture.events.items.length;
|
||||
expect(selectScriptCapture(capture).scripts[0]).toMatchObject({
|
||||
source: { transaction_id: null },
|
||||
content: {
|
||||
state: "unavailable",
|
||||
reason: "transaction-identity-unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { webPageInspectionSchema } from "../../domain/browserObservation.js";
|
||||
import { browserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import type {
|
||||
CapturedWebScript,
|
||||
WebScriptExportResult,
|
||||
} from "../../domain/webScriptExport.js";
|
||||
import { pageScripts } from "./PageScriptCapture.js";
|
||||
import { scenarioScripts } from "./ScenarioScriptCapture.js";
|
||||
|
||||
/** Capture-specific parsing and source selection, independent of publication. */
|
||||
export interface ScriptCaptureAdapter {
|
||||
readonly operation: string;
|
||||
readonly parse: (input: unknown) => SelectedScriptCapture | undefined;
|
||||
}
|
||||
|
||||
/** Verified source records with the original capture coverage and limitations. */
|
||||
export interface SelectedScriptCapture {
|
||||
readonly kind: WebScriptExportResult["capture_kind"];
|
||||
readonly completeness: WebScriptExportResult["capture_completeness"];
|
||||
readonly scripts: readonly CapturedWebScript[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Built-in adapters; add capture formats here without changing the publisher. */
|
||||
export const SCRIPT_CAPTURE_ADAPTERS: readonly ScriptCaptureAdapter[] = [
|
||||
{
|
||||
operation: "inspect_web_page",
|
||||
parse: (input) => {
|
||||
const parsed = webPageInspectionSchema.safeParse(input);
|
||||
return parsed.success
|
||||
? {
|
||||
kind: "page-inspection",
|
||||
completeness: parsed.data.completeness,
|
||||
scripts: pageScripts(parsed.data),
|
||||
limitations: parsed.data.limitations,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "capture_browser_scenario",
|
||||
parse: (input) => {
|
||||
const parsed = browserScenarioCaptureSchema.safeParse(input);
|
||||
return parsed.success
|
||||
? {
|
||||
kind: "browser-scenario",
|
||||
completeness: parsed.data.completeness,
|
||||
scripts: scenarioScripts(parsed.data),
|
||||
limitations: parsed.data.limitations,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Reject unsupported/malformed captures and tampered Evidence before writing. */
|
||||
export const selectScriptCapture = (
|
||||
raw: unknown,
|
||||
adapters: readonly ScriptCaptureAdapter[] = SCRIPT_CAPTURE_ADAPTERS,
|
||||
): SelectedScriptCapture & { readonly sourceEvidenceId: string | null } => {
|
||||
if (
|
||||
typeof raw === "object" &&
|
||||
raw !== null &&
|
||||
("normalized_result" in raw || "evidence_id" in raw)
|
||||
) {
|
||||
const evidence = parseEvidence(raw);
|
||||
const selected = adapters
|
||||
.find(({ operation }) => operation === evidence.operation)
|
||||
?.parse(evidence.normalized_result);
|
||||
if (selected !== undefined)
|
||||
return { ...selected, sourceEvidenceId: evidence.evidence_id };
|
||||
} else {
|
||||
for (const adapter of adapters) {
|
||||
const selected = adapter.parse(raw);
|
||||
if (selected !== undefined)
|
||||
return { ...selected, sourceEvidenceId: null };
|
||||
}
|
||||
}
|
||||
throw new TypeError(
|
||||
"Expected a valid inspect_web_page or capture_browser_scenario capture, with retained script sources or response bytes. Evidence envelopes must have a matching operation and semantic identifier.",
|
||||
);
|
||||
};
|
||||
|
|
@ -12,6 +12,7 @@ import { registerAndroidCommands } from "./cli/androidCommands.js";
|
|||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
import { registerProcessCommands } from "./cli/processCommands.js";
|
||||
import { registerBrowserCommands } from "./cli/browserCommands.js";
|
||||
import { registerWebScriptCommands } from "./cli/webScriptCommands.js";
|
||||
import { registerAdvancedBrowserCommands } from "./cli/browserAdvancedCommands.js";
|
||||
import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js";
|
||||
import { registerElectronCommands } from "./cli/electronCommands.js";
|
||||
|
|
@ -54,6 +55,7 @@ export const createCli = (
|
|||
registerEvidenceCommands(cli, logger);
|
||||
registerProcessCommands(cli, logger, environment);
|
||||
registerBrowserCommands(cli, logger);
|
||||
registerWebScriptCommands(cli, logger);
|
||||
registerAdvancedBrowserCommands(cli, logger);
|
||||
registerBrowserScenarioCommands(cli, logger);
|
||||
registerElectronCommands(cli, logger);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { Cli, z } from "incur";
|
||||
|
||||
import { exportWebScripts } from "../application/WebScriptExportService.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
/** Register the local captured-script export CLI workflow. */
|
||||
export const registerWebScriptCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.exportWebScripts, {
|
||||
description:
|
||||
"Export retained website scripts into an absent directory for static JavaScript analysis",
|
||||
args: z.object({
|
||||
capturePath: z
|
||||
.string()
|
||||
.describe("Absolute path to saved page or scenario capture JSON"),
|
||||
outputDirectory: z
|
||||
.string()
|
||||
.describe(
|
||||
"Absolute absent directory for verified scripts and manifest",
|
||||
),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.exportWebScripts, async () => {
|
||||
const result = await exportWebScripts({
|
||||
capture_path: args.capturePath,
|
||||
output_directory: args.outputDirectory,
|
||||
});
|
||||
return result.ok
|
||||
? result.value
|
||||
: {
|
||||
error: "Script export failed",
|
||||
...projectAnalysisError(result.error),
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
@ -60,6 +60,7 @@ export const CLI_COMMANDS = Object.freeze({
|
|||
listBrowserTargets: "list-browser-targets",
|
||||
inspectWebPage: "inspect-web-page",
|
||||
analyzeWebBundle: "analyze-web-bundle",
|
||||
exportWebScripts: "export-web-scripts",
|
||||
observeWebSession: "observe-web-session",
|
||||
discoverWebMcpTools: "discover-webmcp-tools",
|
||||
compareWebCaptures: "compare-web-captures",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "./browserScenarioToolContracts.
|
|||
import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
|
||||
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
|
||||
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
||||
import { TOOL_EFFECTS } from "./toolEffects.js";
|
||||
import { TOOL_CONTRACTS } from "./toolContracts.js";
|
||||
import { TOOL_CONTRACTS, toolContract } from "./toolContracts.js";
|
||||
import {
|
||||
CLI_COMMAND_ALIASES,
|
||||
CLI_COMMAND_NAMES,
|
||||
|
|
@ -84,11 +85,32 @@ describe("tool contract surface", () => {
|
|||
...ELECTRON_TOOL_CONTRACTS,
|
||||
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||
...APPLICATION_TOOL_CONTRACTS,
|
||||
...WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
].map(({ name }) => name);
|
||||
expect(Object.keys(TOOL_EFFECTS).sort()).toEqual(names.sort());
|
||||
});
|
||||
|
||||
it("advertises local script publication and exclusive output authority", () => {
|
||||
const contract = toolContract("export_web_scripts");
|
||||
expect(contract.effects).toEqual({
|
||||
mutatesTarget: false,
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: false,
|
||||
accessesNetwork: false,
|
||||
changesUiState: false,
|
||||
mayDiscardData: false,
|
||||
idempotent: false,
|
||||
});
|
||||
expect(contract.annotations).toEqual({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks process scenario capture as open world", () => {
|
||||
expect(
|
||||
SESSION_TOOL_CONTRACTS.find(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ const PUBLISHED_TOOL_NAME_FLOOR = [
|
|||
"evaluate_reconstruction_coverage",
|
||||
"export_evidence_bundle",
|
||||
"extract_artifact",
|
||||
"export_web_scripts",
|
||||
"find_changed_behavior",
|
||||
"find_xrefs_to_name",
|
||||
"get_call_graph",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.
|
|||
import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
|
||||
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
|
||||
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
||||
|
|
@ -28,6 +29,7 @@ export const TOOL_CONTRACTS = [
|
|||
...ELECTRON_TOOL_CONTRACTS,
|
||||
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||
...APPLICATION_TOOL_CONTRACTS,
|
||||
...WEB_SCRIPT_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -215,6 +215,11 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
|||
list_javascript_runtime_targets: browserEvidence,
|
||||
observe_javascript_runtime: browserEvidence,
|
||||
analyze_javascript_application: evidence,
|
||||
export_web_scripts: effects({
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
reconcile_javascript_runtime: evidence,
|
||||
trace_application_feature: evidence,
|
||||
trace_javascript_semantics: evidence,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import {
|
||||
exportWebScriptsInputSchema,
|
||||
webScriptExportResultSchema,
|
||||
} from "../domain/webScriptExport.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemas.js";
|
||||
|
||||
/** Local captured-script publication contracts shared by CLI and MCP. */
|
||||
export const WEB_SCRIPT_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "export_web_scripts",
|
||||
...toolContractMetadata("export_web_scripts"),
|
||||
description:
|
||||
"Export JavaScript bytes already retained in one local inspect_web_page or capture_browser_scenario JSON capture (normalized result or complete Evidence). Writes scripts and a digest-verified manifest into an absent absolute output_directory. Returns all source URLs, script or transaction/event references, unavailable states, and an analysis_input for analyze_javascript_application when any bytes were exported. Include script sources in inspect_web_page or select capture.network.response_body in scenarios. No refetch or execution. Safe unambiguous URL layouts preserve relative module paths; versions, query variants, inline scripts, and path collisions are isolated with explicit resolution limitations.",
|
||||
kind: "application",
|
||||
inputSchema: exportWebScriptsInputSchema,
|
||||
outputSchema: evidenceResultOf(webScriptExportResultSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Export a saved website capture for static JavaScript analysis",
|
||||
input: {
|
||||
capture_path: "/analysis/capture.json",
|
||||
output_directory: "/analysis/exported-scripts",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
@ -12,6 +12,7 @@ export class ArtifactOperationError extends AnalysisError {
|
|||
| "decode_interface_builder"
|
||||
| "inspect_asset_catalog"
|
||||
| "inspect_keyed_archive"
|
||||
| "export_web_scripts"
|
||||
| "analyze_javascript_application",
|
||||
readonly reason:
|
||||
| "cancelled"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { browserCompletenessSchema } from "./browserCompleteness.js";
|
||||
import { browserScenarioCompletenessSchema } from "./browserScenarioCaptureValues.js";
|
||||
import { digestSchema, prefixedDigestSchema } from "./digests.js";
|
||||
|
||||
/** Export already captured scripts into one absent, caller-selected directory. */
|
||||
export const exportWebScriptsInputSchema = z.strictObject({
|
||||
capture_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Absolute filesystem path to saved inspect_web_page or capture_browser_scenario JSON, normalized result or complete Evidence",
|
||||
),
|
||||
output_directory: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Absolute absent filesystem directory for verified scripts and manifest; parent directory must exist",
|
||||
),
|
||||
});
|
||||
export type ExportWebScriptsInput = z.output<
|
||||
typeof exportWebScriptsInputSchema
|
||||
>;
|
||||
|
||||
const sourceSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("page-script"),
|
||||
script_key: z.string().min(1),
|
||||
frame_id: z.string().nullable(),
|
||||
is_module: z.boolean(),
|
||||
language: z.string().nullable(),
|
||||
source_map_url: z.string().nullable(),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("scenario-response"),
|
||||
transaction_id: z.string().nullable(),
|
||||
request_sequence: z.number().int().min(1),
|
||||
response_sequence: z.number().int().min(1).nullable(),
|
||||
status: z.number().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Exact retained-source identity and publication outcome. */
|
||||
export const exportedWebScriptSchema = z.strictObject({
|
||||
source: sourceSchema,
|
||||
url: z.string(),
|
||||
content: z.discriminatedUnion("state", [
|
||||
z.strictObject({
|
||||
state: z.literal("exported"),
|
||||
relative_path: z.string().min(1),
|
||||
layout: z.enum(["url-path", "isolated"]),
|
||||
layout_reason: z.string().nullable(),
|
||||
sha256: digestSchema,
|
||||
bytes: z.number().int().min(0),
|
||||
media_type: z.string().nullable(),
|
||||
redacted: z.boolean().nullable(),
|
||||
representation: z.enum([
|
||||
"debugger-source-utf8",
|
||||
"browser-decoded-response-bytes",
|
||||
]),
|
||||
}),
|
||||
z.strictObject({
|
||||
state: z.literal("unavailable"),
|
||||
reason: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
export type ExportedWebScript = z.output<typeof exportedWebScriptSchema>;
|
||||
|
||||
/** Persisted manifest, also returned inline without duplicating script bytes. */
|
||||
export const webScriptExportManifestSchema = z.strictObject({
|
||||
capture_path: z.string().min(1),
|
||||
capture_sha256: digestSchema,
|
||||
capture_kind: z.enum(["page-inspection", "browser-scenario"]),
|
||||
source_evidence_id: prefixedDigestSchema("ev").nullable(),
|
||||
capture_completeness: z.union([
|
||||
browserCompletenessSchema,
|
||||
browserScenarioCompletenessSchema,
|
||||
]),
|
||||
output_directory: z.string().min(1),
|
||||
analysis_input: z
|
||||
.strictObject({
|
||||
input_path: z.string().min(1),
|
||||
format: z.literal("directory"),
|
||||
})
|
||||
.nullable(),
|
||||
scripts: z.array(exportedWebScriptSchema),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** Verified publication including the digest of the persisted manifest. */
|
||||
export const webScriptExportResultSchema = webScriptExportManifestSchema.extend(
|
||||
{
|
||||
manifest: z.strictObject({
|
||||
path: z.string().min(1),
|
||||
sha256: digestSchema,
|
||||
bytes: z.number().int().min(0),
|
||||
}),
|
||||
},
|
||||
);
|
||||
export type WebScriptExportResult = z.output<
|
||||
typeof webScriptExportResultSchema
|
||||
>;
|
||||
|
||||
/** Adapter input to the provider-neutral path planner and publisher. */
|
||||
export interface CapturedWebScript {
|
||||
readonly source: ExportedWebScript["source"];
|
||||
readonly url: string;
|
||||
readonly content:
|
||||
| {
|
||||
readonly state: "captured";
|
||||
readonly bytes: Buffer;
|
||||
readonly sha256: string;
|
||||
readonly media_type: string | null;
|
||||
readonly redacted: boolean | null;
|
||||
readonly representation:
|
||||
| "debugger-source-utf8"
|
||||
| "browser-decoded-response-bytes";
|
||||
}
|
||||
| Extract<ExportedWebScript["content"], { state: "unavailable" }>;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { ArtifactOperationError } from "./artifactOperationError.js";
|
||||
|
||||
/** Publication failure retaining its input/output target and cleanup state. */
|
||||
export class WebScriptExportError extends ArtifactOperationError {
|
||||
override readonly userMessage: string;
|
||||
override readonly cleanupIncomplete: boolean;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
|
||||
constructor(
|
||||
reason: ArtifactOperationError["reason"],
|
||||
target: string,
|
||||
diagnostic: string,
|
||||
residualPaths: readonly string[] = [],
|
||||
) {
|
||||
super("export_web_scripts", reason);
|
||||
this.userMessage = `Script export failed for ${target}: ${diagnostic}`;
|
||||
this.cleanupResources = residualPaths;
|
||||
this.cleanupIncomplete = residualPaths.length > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CapturedWebScript } from "./webScriptExport.js";
|
||||
import { planWebScriptExport } from "./webScriptExportPlan.js";
|
||||
|
||||
const source = (url: string, index = 1): CapturedWebScript => {
|
||||
const bytes = Buffer.from(`export const marker = ${index};`);
|
||||
return {
|
||||
url,
|
||||
source: {
|
||||
kind: "page-script",
|
||||
script_key: `script-${index}`,
|
||||
frame_id: null,
|
||||
is_module: true,
|
||||
language: "JavaScript",
|
||||
source_map_url: null,
|
||||
},
|
||||
content: {
|
||||
state: "captured",
|
||||
bytes,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
media_type: "text/javascript",
|
||||
redacted: null,
|
||||
representation: "debugger-source-utf8",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("captured script path projection", () => {
|
||||
it("preserves ordinary relative layouts separately for each origin", () => {
|
||||
const planned = planWebScriptExport([
|
||||
source("https://a.test/app/main.js"),
|
||||
source("https://a.test/app/lib/value.mjs", 2),
|
||||
source("https://b.test/app/main.js", 3),
|
||||
]);
|
||||
const paths = planned.map(({ record }) =>
|
||||
record.content.state === "exported" ? record.content.relative_path : "",
|
||||
);
|
||||
expect(paths[0]).toMatch(/^origins\/[a-f0-9]{64}\/app\/main.js$/u);
|
||||
expect(paths[1]?.replace("lib/value.mjs", "main.js")).toBe(paths[0]);
|
||||
expect(paths[2]).not.toBe(paths[0]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://a.test/main.js", "https://a.test/main.js"],
|
||||
["https://a.test/main.js", "https://a.test/main.js?v=2"],
|
||||
["https://a.test/A/main.js", "https://a.test/a/other.js"],
|
||||
["https://a.test/main.js", "https://a.test/main.js/child.js"],
|
||||
])(
|
||||
"isolates every version in a conflicting layout (%s, %s)",
|
||||
(first, second) => {
|
||||
const planned = planWebScriptExport([source(first), source(second, 2)]);
|
||||
expect(
|
||||
planned.every(
|
||||
({ record }) =>
|
||||
record.content.state === "exported" &&
|
||||
record.content.layout === "isolated",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
new Set(
|
||||
planned.map(({ record }) =>
|
||||
record.content.state === "exported"
|
||||
? record.content.relative_path
|
||||
: null,
|
||||
),
|
||||
).size,
|
||||
).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"https://a.test/main.js?v=2",
|
||||
"https://a.test/main.js#variant",
|
||||
"data:text/javascript,export{}",
|
||||
"https://a.test/%2fescape.js",
|
||||
"https://a.test/CON.js",
|
||||
"https://a.test/目录/main.js",
|
||||
"https://a.test/app",
|
||||
])(
|
||||
"retains unrepresentable URLs without using them as filesystem paths: %s",
|
||||
(url) => {
|
||||
const [{ record } = { record: undefined }] = planWebScriptExport([
|
||||
source(url),
|
||||
]);
|
||||
expect(record).toMatchObject({
|
||||
url,
|
||||
content: {
|
||||
state: "exported",
|
||||
layout: "isolated",
|
||||
relative_path: expect.stringMatching(
|
||||
/^isolated\/source-1-[a-f0-9]{64}\.js$/u,
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not choose a canonical version when a competing source is unavailable", () => {
|
||||
const missing: CapturedWebScript = {
|
||||
...source("https://a.test/main.js?v=2", 2),
|
||||
content: {
|
||||
state: "unavailable",
|
||||
reason: "not_requested",
|
||||
message: "Not retained",
|
||||
},
|
||||
};
|
||||
const planned = planWebScriptExport([
|
||||
source("https://a.test/main.js"),
|
||||
missing,
|
||||
]);
|
||||
expect(planned[0]?.record.content).toMatchObject({
|
||||
state: "exported",
|
||||
layout: "isolated",
|
||||
});
|
||||
expect(planned[1]?.record.content).toEqual(missing.content);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import type {
|
||||
CapturedWebScript,
|
||||
ExportedWebScript,
|
||||
} from "./webScriptExport.js";
|
||||
|
||||
const hash = (text: string): string =>
|
||||
createHash("sha256").update(text).digest("hex");
|
||||
const PORTABLE_SEGMENT = /^[A-Za-z0-9_~.-]+$/u;
|
||||
const RESERVED_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
|
||||
|
||||
/** One planned file, or an explicit missing source with no filesystem effect. */
|
||||
export interface PlannedWebScript {
|
||||
readonly script: CapturedWebScript;
|
||||
readonly record: ExportedWebScript;
|
||||
}
|
||||
|
||||
/** Preserve safe URL layouts only when every captured path is unambiguous. */
|
||||
export const planWebScriptExport = (
|
||||
scripts: readonly CapturedWebScript[],
|
||||
): readonly PlannedWebScript[] => {
|
||||
const candidates = scripts.map(candidatePath);
|
||||
const conflicts = conflictingPaths(candidates);
|
||||
return scripts.map((script, index) => {
|
||||
if (script.content.state === "unavailable")
|
||||
return {
|
||||
script,
|
||||
record: {
|
||||
source: script.source,
|
||||
url: script.url,
|
||||
content: script.content,
|
||||
},
|
||||
};
|
||||
const candidate = candidates[index];
|
||||
const reason = conflicts.has(index)
|
||||
? "Captured paths collide by content version, case, or file/directory prefix."
|
||||
: (candidate?.reason ?? null);
|
||||
const path = reason === null ? candidate?.path : undefined;
|
||||
return {
|
||||
script,
|
||||
record: {
|
||||
source: script.source,
|
||||
url: script.url,
|
||||
content: {
|
||||
state: "exported",
|
||||
relative_path:
|
||||
path ??
|
||||
`isolated/source-${index + 1}-${hash(JSON.stringify(script.source))}.js`,
|
||||
layout: path === undefined ? "isolated" : "url-path",
|
||||
layout_reason: reason,
|
||||
sha256: script.content.sha256,
|
||||
bytes: script.content.bytes.length,
|
||||
media_type: script.content.media_type,
|
||||
redacted: script.content.redacted,
|
||||
representation: script.content.representation,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface CandidatePath {
|
||||
readonly path?: string;
|
||||
readonly reason: string | null;
|
||||
}
|
||||
|
||||
const candidatePath = (script: CapturedWebScript): CandidatePath => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(script.url);
|
||||
} catch {
|
||||
return {
|
||||
reason: "Source has no absolute HTTP URL (for example an inline script).",
|
||||
};
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:")
|
||||
return { reason: "Source URL scheme has no local module layout mapping." };
|
||||
const parts = url.pathname.slice(1).split("/");
|
||||
if (
|
||||
!/\.(?:js|mjs|cjs)$/u.test(url.pathname) ||
|
||||
parts.some(
|
||||
(part) =>
|
||||
!PORTABLE_SEGMENT.test(part) ||
|
||||
part === "." ||
|
||||
part === ".." ||
|
||||
part.endsWith(".") ||
|
||||
RESERVED_SEGMENT.test(part),
|
||||
)
|
||||
)
|
||||
return {
|
||||
reason: "URL path is not an ordinary portable JavaScript module path.",
|
||||
};
|
||||
return {
|
||||
path: `origins/${hash(url.origin)}/${parts.join("/")}`,
|
||||
reason:
|
||||
url.search !== "" ||
|
||||
url.hash !== "" ||
|
||||
url.username !== "" ||
|
||||
url.password !== ""
|
||||
? "URL query, fragment, or credentials cannot select a canonical local module."
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
// Register every prefix so case collisions in parent directories and a file
|
||||
// conflicting with a directory isolate all affected sources, not just the last.
|
||||
const conflictingPaths = (
|
||||
candidates: readonly CandidatePath[],
|
||||
): Set<number> => {
|
||||
const prefixes = new Map<
|
||||
string,
|
||||
{
|
||||
spellings: Set<string>;
|
||||
indices: number[];
|
||||
files: number[];
|
||||
}
|
||||
>();
|
||||
candidates.forEach(({ path }, index) => {
|
||||
if (path === undefined) return;
|
||||
const parts = path.split("/");
|
||||
parts.forEach((_, position) => {
|
||||
const spelling = parts.slice(0, position + 1).join("/");
|
||||
const key = spelling.toLowerCase();
|
||||
const entry = prefixes.get(key) ?? {
|
||||
spellings: new Set<string>(),
|
||||
indices: [],
|
||||
files: [],
|
||||
};
|
||||
entry.spellings.add(spelling);
|
||||
entry.indices.push(index);
|
||||
if (position === parts.length - 1) entry.files.push(index);
|
||||
prefixes.set(key, entry);
|
||||
});
|
||||
});
|
||||
const conflicts = new Set<number>();
|
||||
for (const entry of prefixes.values())
|
||||
if (
|
||||
entry.spellings.size > 1 ||
|
||||
entry.files.length > 1 ||
|
||||
(entry.files.length > 0 && entry.indices.length > entry.files.length)
|
||||
)
|
||||
for (const index of entry.indices) conflicts.add(index);
|
||||
return conflicts;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -14,6 +14,7 @@ import { registerApplicationTools } from "./registerApplicationTools.js";
|
|||
import { registerArtifactTools } from "./registerArtifactTools.js";
|
||||
import { registerBrowserScenarioTool } from "./registerBrowserScenarioTool.js";
|
||||
import { registerBrowserTools } from "./registerBrowserTools.js";
|
||||
import { registerWebScriptTool } from "./registerWebScriptTool.js";
|
||||
import { registerElectronTools } from "./registerElectronTools.js";
|
||||
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
|
||||
|
|
@ -252,6 +253,7 @@ const registerObservationTools = ({
|
|||
recordEvidenceWithUnknown,
|
||||
}: ServerToolContext): void => {
|
||||
const common = { logger, recordEvidence };
|
||||
registerWebScriptTool(server, common);
|
||||
registerBrowserTools(server, {
|
||||
...common,
|
||||
browser: options.browserObservation,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import { exportWebScriptsValidated } from "../application/WebScriptExportService.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
||||
/** Register local script export without requiring a live browser provider. */
|
||||
export const registerWebScriptTool = (
|
||||
server: McpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
|
||||
},
|
||||
): void => {
|
||||
const contract = toolContract("export_web_scripts");
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input, context) => {
|
||||
const result = await logToolExecution(options.logger, contract.name, () =>
|
||||
exportWebScriptsValidated(input, { signal: context.mcpReq.signal }),
|
||||
);
|
||||
if (!result.ok) return toCallToolResult(result, contract);
|
||||
const recorded = options.recordEvidence?.(result.value);
|
||||
return recorded !== undefined && !recorded.ok
|
||||
? toCallToolResult(recorded, contract)
|
||||
: toCallToolResult(result, contract);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ARTIFACT_GRAPH_PROVIDER,
|
||||
JAVASCRIPT_APPLICATION_PROVIDER,
|
||||
JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
|
||||
WEB_SCRIPT_EXPORT_PROVIDER,
|
||||
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
|
||||
MANAGED_STATIC_PROVIDER,
|
||||
MANAGED_WORKFLOW_PROVIDER,
|
||||
|
|
@ -115,6 +116,7 @@ describe("canonical product catalog", () => {
|
|||
JAVASCRIPT_APPLICATION_PROVIDER,
|
||||
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
|
||||
JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
|
||||
WEB_SCRIPT_EXPORT_PROVIDER,
|
||||
]
|
||||
.map(({ id }) => id)
|
||||
.sort(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
readFile,
|
||||
readdir,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js";
|
||||
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
|
||||
import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js";
|
||||
import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js";
|
||||
import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
scriptCaptureEvidenceFixture,
|
||||
scriptScenarioFixture,
|
||||
} from "../../fixtures/webScriptCapture.js";
|
||||
|
||||
const setup = async (capture: unknown = scriptCaptureEvidenceFixture()) => {
|
||||
const root = await createTestTempDirectory("rea-web-script-export-");
|
||||
const input = {
|
||||
capture_path: join(root, "capture.json"),
|
||||
output_directory: join(root, "export"),
|
||||
};
|
||||
await writeFile(input.capture_path, JSON.stringify(capture));
|
||||
return { root, input };
|
||||
};
|
||||
|
||||
describe("captured script publication boundary", () => {
|
||||
it("verifies durable bytes, manifest, capture identity, and existing module analysis", async () => {
|
||||
const capture = scriptScenarioFixture([
|
||||
{
|
||||
url: "https://fixture.test/app/main.js",
|
||||
bytes: Buffer.from(
|
||||
"import { marker } from './lib/dep.js'; export const result = marker;\n",
|
||||
),
|
||||
},
|
||||
{
|
||||
url: "https://fixture.test/app/lib/dep.js",
|
||||
bytes: Buffer.from("export const marker = 'source-owned';\n"),
|
||||
},
|
||||
]);
|
||||
const { input } = await setup(capture);
|
||||
const exported = await exportWebScripts(input);
|
||||
if (!exported.ok) throw exported.error;
|
||||
const result = webScriptExportResultSchema.parse(
|
||||
exported.value.normalized_result,
|
||||
);
|
||||
expect(result.capture_sha256).toBe(
|
||||
createHash("sha256")
|
||||
.update(await readFile(input.capture_path))
|
||||
.digest("hex"),
|
||||
);
|
||||
const manifest = await readFile(result.manifest.path);
|
||||
expect(result.manifest.sha256).toBe(
|
||||
createHash("sha256").update(manifest).digest("hex"),
|
||||
);
|
||||
const { manifest: descriptor, ...inline } = result;
|
||||
expect(descriptor.bytes).toBe(manifest.length);
|
||||
expect(JSON.parse(manifest.toString())).toEqual(inline);
|
||||
if (result.analysis_input === null)
|
||||
throw new Error("Missing analysis input");
|
||||
for (const [index, script] of result.scripts.entries()) {
|
||||
if (script.content.state !== "exported")
|
||||
throw new Error("Expected exported script");
|
||||
const bytes = await readFile(
|
||||
join(result.analysis_input.input_path, script.content.relative_path),
|
||||
);
|
||||
expect(bytes).toEqual(
|
||||
capture.events.items
|
||||
.filter(({ kind }) => kind === "network-content")
|
||||
.map((event) =>
|
||||
event.kind === "network-content" && event.body.state === "captured"
|
||||
? Buffer.from(event.body.content, "base64")
|
||||
: Buffer.alloc(0),
|
||||
)[index],
|
||||
);
|
||||
}
|
||||
const analyzed = await analyzeJavaScriptApplication(result.analysis_input);
|
||||
if (!analyzed.ok) throw analyzed.error;
|
||||
const analysis = javascriptApplicationAnalysisResultSchema.parse(
|
||||
analyzed.value.normalized_result,
|
||||
);
|
||||
expect(analysis.statistics.parsed_javascript_files).toBe(2);
|
||||
expect(analysis.graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "imports",
|
||||
properties: expect.objectContaining({
|
||||
specifier: "./lib/dep.js",
|
||||
resolution_status: "resolved",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("links authenticated input Evidence and preserves binary and empty source bytes", async () => {
|
||||
const { input } = await setup();
|
||||
const result = await exportWebScripts(input);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.evidence_links).toEqual([
|
||||
scriptCaptureEvidenceFixture().evidence_id,
|
||||
]);
|
||||
const binary = Buffer.from([255, 0, 254, 1]);
|
||||
const { input: binaryInput } = await setup(
|
||||
scriptScenarioFixture([
|
||||
{ url: "https://fixture.test/binary.js", bytes: binary },
|
||||
{ url: "https://fixture.test/empty.js", bytes: Buffer.alloc(0) },
|
||||
]),
|
||||
);
|
||||
const binaryExport = await exportWebScripts(binaryInput);
|
||||
if (!binaryExport.ok) throw binaryExport.error;
|
||||
const parsed = webScriptExportResultSchema.parse(
|
||||
binaryExport.value.normalized_result,
|
||||
);
|
||||
const [first, second] = parsed.scripts;
|
||||
if (
|
||||
first?.content.state !== "exported" ||
|
||||
second?.content.state !== "exported" ||
|
||||
parsed.analysis_input === null
|
||||
)
|
||||
throw new Error("Missing exported bytes");
|
||||
expect(
|
||||
await readFile(
|
||||
join(parsed.analysis_input.input_path, first.content.relative_path),
|
||||
),
|
||||
).toEqual(binary);
|
||||
expect(
|
||||
await readFile(
|
||||
join(parsed.analysis_input.input_path, second.content.relative_path),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("retains missing sources in a manifest without inventing an analysis directory", async () => {
|
||||
const capture = scriptScenarioFixture();
|
||||
capture.events.items = capture.events.items.filter(
|
||||
({ kind }) => kind !== "network-content",
|
||||
);
|
||||
capture.events.retained = capture.events.items.length;
|
||||
const { input } = await setup(capture);
|
||||
const result = await exportWebScripts(input);
|
||||
if (!result.ok) throw result.error;
|
||||
const parsed = webScriptExportResultSchema.parse(
|
||||
result.value.normalized_result,
|
||||
);
|
||||
expect(parsed.analysis_input).toBeNull();
|
||||
expect(parsed.scripts[0]?.content.state).toBe("unavailable");
|
||||
expect(await readdir(input.output_directory)).toEqual(["manifest.json"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captured script publication failures and cleanup", () => {
|
||||
it("preserves adapter-reported limitations without strengthening their string contract", async () => {
|
||||
const { input } = await setup();
|
||||
const capture = {
|
||||
...selectScriptCapture(scriptScenarioFixture()),
|
||||
limitations: ["", "producer-reported limitation"],
|
||||
};
|
||||
const result = await publishWebScripts(input, capture, "a".repeat(64));
|
||||
expect(result.limitations.slice(0, 2)).toEqual(capture.limitations);
|
||||
});
|
||||
|
||||
it.each([
|
||||
Buffer.from("{"),
|
||||
Buffer.from([255, 254]),
|
||||
Buffer.from(JSON.stringify({ unsupported: true })),
|
||||
])(
|
||||
"rejects malformed capture bytes before creating output",
|
||||
async (bytes) => {
|
||||
const { input } = await setup();
|
||||
await writeFile(input.capture_path, bytes);
|
||||
const result = await exportWebScripts(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected invalid input");
|
||||
expect(result.error._tag).toBe("AnalysisInputError");
|
||||
await expect(access(input.output_directory)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves an existing destination and refuses output through a symlink", async () => {
|
||||
const { root, input } = await setup();
|
||||
const marker = join(root, "marker.js");
|
||||
await writeFile(marker, "keep");
|
||||
await symlink(
|
||||
root,
|
||||
input.output_directory,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
const result = await exportWebScripts(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected exclusive output failure");
|
||||
expect(result.error.userMessage).toContain("already exists");
|
||||
expect(await readFile(marker, "utf8")).toBe("keep");
|
||||
});
|
||||
|
||||
it("rolls back only the new owned output when durable byte verification fails", async () => {
|
||||
const { root, input } = await setup();
|
||||
const capture = selectScriptCapture(
|
||||
scriptScenarioFixture([
|
||||
{ url: "https://fixture.test/a.js", bytes: Buffer.from("first") },
|
||||
{ url: "https://fixture.test/b.js", bytes: Buffer.from("second") },
|
||||
]),
|
||||
);
|
||||
const second = capture.scripts[1];
|
||||
if (second?.content.state !== "captured") throw new Error("Missing source");
|
||||
const broken = {
|
||||
...capture,
|
||||
scripts: [
|
||||
capture.scripts[0],
|
||||
{ ...second, content: { ...second.content, sha256: "0".repeat(64) } },
|
||||
].filter((value) => value !== undefined),
|
||||
};
|
||||
await expect(
|
||||
publishWebScripts(input, broken, "a".repeat(64)),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
expect(await readdir(root)).toEqual(["capture.json"]);
|
||||
});
|
||||
|
||||
it("cancels before work and rolls back cancellation after output creation", async () => {
|
||||
const { root, input } = await setup();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await exportWebScripts(input, { signal: controller.signal });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
await expect(
|
||||
publishWebScripts(
|
||||
input,
|
||||
selectScriptCapture(scriptScenarioFixture()),
|
||||
"a".repeat(64),
|
||||
controller.signal,
|
||||
),
|
||||
).rejects.toBeDefined();
|
||||
expect(await readdir(root)).toEqual(["capture.json"]);
|
||||
});
|
||||
|
||||
it("identifies a missing capture file and rejected host path syntax", async () => {
|
||||
const { input } = await setup();
|
||||
const missing = await exportWebScripts({
|
||||
...input,
|
||||
capture_path: `${input.capture_path}.missing`,
|
||||
});
|
||||
expect(missing.ok).toBe(false);
|
||||
if (missing.ok) throw new Error("Expected unavailable input");
|
||||
expect(missing.error.userMessage).toContain(
|
||||
`${input.capture_path}.missing`,
|
||||
);
|
||||
const relative = await exportWebScripts({
|
||||
...input,
|
||||
capture_path: "capture.json",
|
||||
});
|
||||
expect(relative.ok).toBe(false);
|
||||
if (relative.ok) throw new Error("Expected host path error");
|
||||
expect(relative.error._tag).toBe("AnalysisInputError");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import { sanitizeBrowserUrl } from "../../src/domain/browserObservation.js";
|
||||
import { browserScenarioCaptureSchema } from "../../src/domain/browserScenarioCapture.js";
|
||||
import { browserScenarioEventSchema } from "../../src/domain/browserScenarioCaptureValues.js";
|
||||
import { createEvidence } from "../../src/domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../src/domain/jsonValue.js";
|
||||
|
||||
/** Source-owned script capture with independently authored bytes. */
|
||||
export const scriptScenarioFixture = (
|
||||
scripts = [
|
||||
{
|
||||
url: "https://fixture.test/app/main.js",
|
||||
bytes: Buffer.from("export const marker = 'fixture-source';\n"),
|
||||
},
|
||||
],
|
||||
) => {
|
||||
const items = scripts.flatMap(({ url, bytes }, index) => {
|
||||
const request = {
|
||||
transaction_id: `request-${index + 1}`,
|
||||
redirected_from_transaction_id: null,
|
||||
method: "GET",
|
||||
url: sanitizeBrowserUrl(url),
|
||||
resource_type: "script",
|
||||
header_names: [],
|
||||
failure: null,
|
||||
step_index: 0,
|
||||
};
|
||||
const start = index * 3 + 1;
|
||||
return [
|
||||
browserScenarioEventSchema.parse({
|
||||
...request,
|
||||
sequence: start,
|
||||
kind: "request",
|
||||
status: null,
|
||||
}),
|
||||
browserScenarioEventSchema.parse({
|
||||
...request,
|
||||
sequence: start + 1,
|
||||
kind: "response",
|
||||
status: 200,
|
||||
}),
|
||||
browserScenarioEventSchema.parse({
|
||||
sequence: start + 2,
|
||||
step_index: 0,
|
||||
kind: "network-content",
|
||||
transaction_id: request.transaction_id,
|
||||
phase: "response",
|
||||
source_event_sequence: start + 1,
|
||||
headers: { state: "not_requested" },
|
||||
body: {
|
||||
state: "captured",
|
||||
representation: "browser-decoded-response-bytes",
|
||||
encoding: "base64",
|
||||
content: bytes.toString("base64"),
|
||||
bytes: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
media_type: "text/javascript",
|
||||
redacted: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
});
|
||||
const completeness = {
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
missing_sections: [],
|
||||
truncated_sections: [],
|
||||
};
|
||||
const step = (index: number) => ({
|
||||
step_index: index,
|
||||
step_id: index === 0 ? "scenario_start" : "ready",
|
||||
action: index === 0 ? "goto_start" : "wait_for_timeout",
|
||||
status: "completed",
|
||||
elapsed_ms: 1,
|
||||
before_url: sanitizeBrowserUrl("https://fixture.test/"),
|
||||
after_url: sanitizeBrowserUrl("https://fixture.test/"),
|
||||
error: null,
|
||||
event_sequence_start: 1,
|
||||
event_sequence_end: items.length,
|
||||
artifacts: {
|
||||
screenshot: { state: "not_requested" },
|
||||
dom: { state: "not_requested" },
|
||||
accessibility: { state: "not_requested" },
|
||||
url: { state: "not_requested" },
|
||||
history: { state: "not_requested" },
|
||||
storage: { state: "not_requested" },
|
||||
},
|
||||
completeness,
|
||||
});
|
||||
return browserScenarioCaptureSchema.parse({
|
||||
browser: {
|
||||
mode: "launch",
|
||||
process_ownership: "provider-owned",
|
||||
cleanup: "terminated-owned-process",
|
||||
product: "Fixture",
|
||||
version: "1",
|
||||
},
|
||||
scenario: {
|
||||
start_origin: "https://fixture.test",
|
||||
action_count: 1,
|
||||
secret_references: [],
|
||||
network_content: {
|
||||
request_body: false,
|
||||
response_body: true,
|
||||
header_values: false,
|
||||
},
|
||||
},
|
||||
duration_ms: 2,
|
||||
steps: [step(0), step(1)],
|
||||
events: { items, retained: items.length, dropped: 0 },
|
||||
completeness,
|
||||
limitations: [],
|
||||
});
|
||||
};
|
||||
|
||||
/** Complete Evidence envelope for authentication and file-boundary tests. */
|
||||
export const scriptCaptureEvidenceFixture = () =>
|
||||
createEvidence(
|
||||
undefined,
|
||||
{ id: "source-fixture", name: "Source fixture", version: "1" },
|
||||
{
|
||||
operation: "capture_browser_scenario",
|
||||
result: jsonValueSchema.parse(scriptScenarioFixture()),
|
||||
parameters: {},
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue