mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
feat(browser): trace captured website module imports (#879)
* feat(browser): trace captured website module imports Compose verified exported scripts, inert native import syntax and a narrow Chromium resolution port. Preserve query/fragment identity, import-map context, all reported capture candidates and explicit unknown execution. Share named CLI/MCP contracts and retain native reports, diagnostics and cleanup failures. Add focused boundary regressions and a real module-loading oracle to the existing conditional Chrome lane. No captured application code is executed by module tracing, and no selected website asset is refetched. Refs #851, #774 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): preserve module contexts and source bindings Keep capture completeness inline, validate actual source text against its digest, and separate unsupported URL contexts from malformed input. Use the importer origin for native module resolution and the selected map base independently, including mixed HTTP/HTTPS and escaped HTML attributes. Refs #851, #774 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
@@ -7,18 +7,31 @@ on:
|
||||
- "scripts/verify-real-browser.mjs"
|
||||
- "scripts/verify-browser-network.mjs"
|
||||
- "scripts/verify-browser-scripts.mjs"
|
||||
- "scripts/verify-browser-modules.mjs"
|
||||
- "scripts/verify-browser-dom-destinations.mjs"
|
||||
- "scripts/lib/browser-*.mjs"
|
||||
- "scripts/fixtures/browser-network-site.mjs"
|
||||
- "scripts/fixtures/browser-script-site.mjs"
|
||||
- "scripts/fixtures/browser-module-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/domain/webModule*.ts"
|
||||
- "src/application/WebModule*.ts"
|
||||
- "src/composition/webModules.ts"
|
||||
- "src/artifacts/readStableArtifact.ts"
|
||||
- "src/artifacts/ArtifactPaths.ts"
|
||||
- "src/application/WebScriptExportService.ts"
|
||||
- "src/contracts/webScriptToolContracts.ts"
|
||||
- "src/server/registerWebScriptTool.ts"
|
||||
- "src/server/registerWebModuleTool.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli/webModuleCommands.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/cli/webScriptCommands.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
@@ -411,7 +411,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
| 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 | 11 | captured website script export; Android/Apple inventory projections; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Application workflows | 12 | captured website script export; Android/Apple inventory projections; 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.
|
||||
@@ -432,6 +432,10 @@ Static Android APK inspection is verified on Linux with headless JADX; see [Andr
|
||||
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).
|
||||
Trace one exported script's native imports with `trace_web_module_imports` /
|
||||
`rea trace-web-module-imports`, preserving query/fragment identity and optional
|
||||
import-map context. Requires caller-supplied Chromium via
|
||||
`REA_BROWSER_EXECUTABLE`; see [module relationships](docs/website-module-trace.md).
|
||||
Recover readable modules from selected local bundles with
|
||||
`recover_javascript_sources` / `rea recover-javascript-sources`, then pass
|
||||
the returned `analysis_input` to static analysis. This optional Linux x64
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ rea setup
|
||||
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 11 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| سير عمل التطبيقات | 12 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||
|
||||
## خطة العمل
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
||||
| アプリワークフロー | 11 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| アプリワークフロー | 12 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 11 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 앱 워크플로 | 12 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 11 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 应用工作流 | 12 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
||||
@@ -27,7 +27,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/>CDP / Playwright capture<br/>captured script adapters and publication"]
|
||||
BROWSER["src/browser/<br/>CDP / Playwright capture<br/>captured scripts and native module resolution"]
|
||||
INSPECTOR["src/inspector/<br/>passive V8 Inspector discovery and capture<br/>script and target locations"]
|
||||
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"]
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
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.
|
||||
Use [captured module relationships](website-module-trace.md) to resolve one
|
||||
exported script's native imports under an explicit URL/import-map context.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+12
-10
@@ -10,7 +10,7 @@
|
||||
"skill_version": "26"
|
||||
},
|
||||
"tools": {
|
||||
"total": 129,
|
||||
"total": 130,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
@@ -179,7 +179,7 @@
|
||||
{
|
||||
"id": "application",
|
||||
"surface": "application-workflow",
|
||||
"count": 11,
|
||||
"count": 12,
|
||||
"tools": [
|
||||
"build_reconstruction_obligation_ledger",
|
||||
"compare_application_versions",
|
||||
@@ -191,7 +191,8 @@
|
||||
"project_apple_application_graph",
|
||||
"recover_javascript_sources",
|
||||
"trace_application_feature",
|
||||
"trace_javascript_semantics"
|
||||
"trace_javascript_semantics",
|
||||
"trace_web_module_imports"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -470,7 +471,7 @@
|
||||
"id": "rea-web-script-export",
|
||||
"name": "REA captured script export",
|
||||
"version": "1",
|
||||
"capabilities": ["export_web_scripts"]
|
||||
"capabilities": ["export_web_scripts", "trace_web_module_imports"]
|
||||
},
|
||||
{
|
||||
"id": "unblob",
|
||||
@@ -560,7 +561,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 84,
|
||||
"primary_count": 85,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
@@ -642,6 +643,7 @@
|
||||
"trace-javascript-semantics",
|
||||
"trace-native-ui-action",
|
||||
"trace-native-values",
|
||||
"trace-web-module-imports",
|
||||
"uninstall",
|
||||
"update",
|
||||
"verify-managed-native-boundaries",
|
||||
@@ -656,15 +658,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 84,
|
||||
"mcp_tools": 129,
|
||||
"cli_commands": 85,
|
||||
"mcp_tools": 130,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "31c4f5129fe81c0a84f5a374621cd7edc2e995b91b3f40a5ce4921a34088ac33",
|
||||
"tools_sha256": "676a0ae5ff16933e3a9b2db0e5a95fcecf57ac926d18ce8b7a31909e040a9972",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "43e6ac2812f72123ce1d115c5e1be9fc963007987930b21892ee0e90b211dc68",
|
||||
"providers_sha256": "6e1de7ee30b9b1ff633abfa4fd2b3848bb58e6e76a2ed196aa01836a202e1941"
|
||||
"combined_sha256": "c32a78528d109b2c25582a74c02d25ccb779cb511f6e2477017a2e8805c07bac",
|
||||
"providers_sha256": "67251a90abf28e1cd9020cf68f579bf2460922d90dc113798a391039279db009"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,14 @@ 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).
|
||||
|
||||
`verify:browser:modules` compares CLI and stdio MCP traces against an independent
|
||||
real Chromium module-loading fixture: import-map scopes, package prefixes,
|
||||
null/backtracking rejection, query/fragment identities, repeated module instances,
|
||||
lazy and computed unknowns, exact source/map identities and no implicit refetch.
|
||||
Set `REA_BROWSER_EXECUTABLE`; the optional script argument selects an installed
|
||||
package entrypoint. The complete `verify:browser` lane and existing conditional
|
||||
Chrome CI include this verifier. See [module relationships](website-module-trace.md).
|
||||
|
||||
## Real-toolchain verification lanes
|
||||
|
||||
Each real-toolchain command must require only the host tools needed to prove
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_3290efdc772b6cd6ce4bcdf010a1c7f9e1f87bb321ccdf5f6adf8bbb412fb5ac",
|
||||
"manifest_id": "ecm_df760a39c0bf1144d883fe0b80e22cc93364d23289b9a9a14d0efff72a1031bb",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "663a5891fc00ca2a8ab1c18e49f989a6b67c132a5313be4b0ee623ffdef5400b"
|
||||
"sha256": "e6e0645e84a6514c7d7d3e42d5b0a424bfb0110951eff41471f901de39dbec17"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Captured website module relationships
|
||||
|
||||
`trace_web_module_imports` / `rea trace-web-module-imports` traces one script's
|
||||
outgoing native ES imports and re-exports from an
|
||||
[exported capture](website-script-export.md). It verifies the selected source's
|
||||
SHA-256 and byte count, preserves browser URL identities, and returns every
|
||||
matching capture candidate inline. It does not refetch website assets.
|
||||
|
||||
## Select the source and context
|
||||
|
||||
Provide a caller-supplied Chromium executable supporting `import.meta.resolve`.
|
||||
REA does not install or upgrade a browser. Real verification covers Linux x64;
|
||||
other hosts have not received this feature's real workflow verification.
|
||||
|
||||
```bash
|
||||
REA_BROWSER_EXECUTABLE=/absolute/path/to/chrome \
|
||||
rea trace-web-module-imports /analysis/export/manifest.json 0 \
|
||||
--importer-url 'https://example.test/app/main.js?build=2#entry' \
|
||||
--import-map-path /analysis/import-map.json \
|
||||
--import-map-base-url https://example.test/maps/ \
|
||||
--json
|
||||
```
|
||||
|
||||
The MCP input expresses the same choices:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_path": "/analysis/export/manifest.json",
|
||||
"script_index": 0,
|
||||
"importer_url": "https://example.test/app/main.js?build=2#entry",
|
||||
"import_map": {
|
||||
"path": "/analysis/import-map.json",
|
||||
"base_url": "https://example.test/maps/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`script_index` is zero-based in `manifest.scripts`. If `importer_url` is omitted,
|
||||
the trace selects the reported source URL. An inline script's document base
|
||||
cannot be established from that URL alone; choose the context explicitly when
|
||||
known. `import_map` is optional and reads local JSON only. Its exact bytes and
|
||||
base URL are retained; selecting it does not prove the map was installed in the
|
||||
captured page. HTTP(S) importer contexts without URL userinfo are supported initially.
|
||||
|
||||
## Interpret the evidence
|
||||
|
||||
- Static imports, re-exports and literal dynamic imports receive the native
|
||||
engine's resolved URL or exact rejection message. Empty strings are retained.
|
||||
- Computed dynamic imports retain their expression and source positions with
|
||||
`computed-specifier` unknown resolution. Erased type-only imports/exports and
|
||||
inferred bundler IDs are excluded from native import claims.
|
||||
- Locations use UTF-16 offsets and columns, with one-based lines. Parse recovery
|
||||
diagnostics and unparseable input are explicit.
|
||||
- Query variants and fragments remain distinct. An exact reported URL is an
|
||||
`exact-reported-url` candidate. A response URL stripped of its fragment is a
|
||||
weaker `response-url-without-fragment` content candidate. Multiple captured
|
||||
versions are all returned; the trace selects none as canonical.
|
||||
- Candidate metadata remains reported capture evidence; only the selected
|
||||
source's bytes are reverified. Missing/lazy targets can resolve successfully
|
||||
with no captured candidate. Every relationship reports execution as unknown.
|
||||
- Source-map declarations remain declarations; this operation does not read or
|
||||
invent map bytes, original-source mappings, worker ownership or UI causality.
|
||||
|
||||
The selected source is loaded from `files/` under the manifest's current
|
||||
directory. Relocation retains the manifest's originally reported paths and the
|
||||
actual current source path separately. Unsafe paths, symlink source directories,
|
||||
nonregular files, wrong digests/sizes and malformed UTF-8 fail explicitly.
|
||||
|
||||
## Engine and lifecycle
|
||||
|
||||
An owned temporary browser context runs REA's trusted resolver stub. The selected
|
||||
application is parsed as data and is never executed by this trace. Page requests
|
||||
are fulfilled with REA's synthetic document/stub or blocked; no website asset is
|
||||
downloaded. This does not establish an OS sandbox or browser-wide network
|
||||
containment. The engine version and native report are retained in Evidence.
|
||||
|
||||
Sources without literal imports do not acquire a browser. The manifest, source
|
||||
and import map have 32 MiB, 16 MiB and 4 MiB input limits. Native resolution has
|
||||
one 20-second deadline; cleanup may extend that deadline. One renderer is
|
||||
requested, and there is no aggregate host memory-limit claim. Cancellation
|
||||
closes only the owned browser; cleanup failure cannot return a successful trace.
|
||||
`REA_BROWSER_NO_SANDBOX=true` passes Chromium's corresponding launch option when
|
||||
the host requires it; it does not enable an REA security boundary.
|
||||
|
||||
See [testing](testing.md) for the native loading oracle and CLI/MCP real lane.
|
||||
@@ -177,6 +177,7 @@
|
||||
"build:fixtures:nativeaot": "node scripts/generate-nativeaot-fixtures.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",
|
||||
"verify:browser:modules": "npm run build:cached && node scripts/verify-browser-modules.mjs",
|
||||
"verify:test-discovery": "node scripts/verify-test-discovery.mjs",
|
||||
"verify:javascript:recovery": "node scripts/verify-javascript-recovery.mjs",
|
||||
"verify:module-boundaries": "node scripts/verify-module-boundaries.mjs"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
/** Source-owned module graph with actual native loading and an uncalled lazy edge. */
|
||||
export async function startBrowserModuleSite() {
|
||||
const requests = [];
|
||||
const importMap = {
|
||||
imports: { "pkg/": "/pkg/", alias: "./global.js", blocked: null },
|
||||
scopes: { "/scoped/": { alias: "./scoped.js" } },
|
||||
};
|
||||
const main = `
|
||||
import {marker as alias} from 'alias';
|
||||
import {marker as pkg} from 'pkg/nested.js';
|
||||
const a = await import('/query.js?v=1#one');
|
||||
const b = await import('/query.js?v=1#two');
|
||||
const c = await import('/query.js?v=2');
|
||||
const again = await import('/query.js?v=1#one');
|
||||
const specifiers = ['alias', 'pkg/nested.js', '/query.js?v=1#one', '/query.js?v=1#two', '/query.js?v=2', '/query.js?v=1#one', './lazy.js', 'blocked', 'pkg/../escape.js', 'pkg/%2e%2e/escape.js', ''];
|
||||
globalThis.__moduleProof = {alias, pkg, urls: [a.url,b.url,c.url], instances: [a.instance,b.instance,c.instance], same_module: again === a, different_fragments: a !== b, resolutions: specifiers.map(specifier => {try{return {state: 'resolved', url: import.meta.resolve(specifier)}}catch(error){return {state: 'rejected', message: String(error)}}})};
|
||||
document.body.dataset.ready = 'true';
|
||||
function uncalled(){return import('./lazy.js')}
|
||||
function blocked(){return import('blocked')}
|
||||
function escaped(){return import('pkg/../escape.js')}
|
||||
function encodedEscape(){return import('pkg/%2e%2e/escape.js')}
|
||||
function empty(){return import('')}
|
||||
function computed(name){return import(name)}
|
||||
`;
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url);
|
||||
if (request.url === "/")
|
||||
return response
|
||||
.writeHead(200, { "Content-Type": "text/html" })
|
||||
.end(
|
||||
`<body><base href="/maps/"><script type="importmap">${JSON.stringify(importMap)}</script><script type="module" src="/scoped/main.js?build=2#entry"></script></body>`,
|
||||
);
|
||||
response.setHeader("Content-Type", "text/javascript");
|
||||
if (request.url?.startsWith("/scoped/main.js?")) return response.end(main);
|
||||
if (request.url === "/maps/scoped.js")
|
||||
return response.end("export const marker = 'scoped';");
|
||||
if (request.url === "/pkg/nested.js")
|
||||
return response.end("export const marker = 'pkg';");
|
||||
if (request.url?.startsWith("/query.js?"))
|
||||
return response.end(
|
||||
"globalThis.__moduleCount = (globalThis.__moduleCount ?? 0) + 1; export const instance = globalThis.__moduleCount; export const url = import.meta.url;",
|
||||
);
|
||||
response.writeHead(404).end("Not selected");
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Module fixture has no TCP address");
|
||||
return {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
importMap,
|
||||
main,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, 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 { chromium } from "playwright-core";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import { webModuleTraceResultSchema } from "../../dist/domain/webModuleTrace.js";
|
||||
import { NativeModuleResolver } from "../../dist/browser/modules/NativeModuleResolver.js";
|
||||
import { startBrowserModuleSite } from "../fixtures/browser-module-site.mjs";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Check native URL resolution against an independently loaded module graph through CLI/MCP. */
|
||||
export async function verifyBrowserModules(
|
||||
executable,
|
||||
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
) {
|
||||
const site = await startBrowserModuleSite();
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-browser-modules-"));
|
||||
const env = {
|
||||
...process.env,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_BROWSER_EXECUTABLE: executable,
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
|
||||
};
|
||||
const cli = async (...args) => {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--json"],
|
||||
{ env, timeout: 45000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "module-real-e2e", version: "1" });
|
||||
try {
|
||||
const oracle = await loadingOracle(executable, site);
|
||||
const scenario = {
|
||||
browser: { mode: "launch", executable_path: executable },
|
||||
start_url: { url: site.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 } },
|
||||
};
|
||||
const capture = await cli(
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
);
|
||||
const capturePath = join(root, "capture.json");
|
||||
await writeFile(capturePath, JSON.stringify(capture));
|
||||
const exported = await cli(
|
||||
"export-web-scripts",
|
||||
capturePath,
|
||||
join(root, "export"),
|
||||
);
|
||||
const manifest = exported.normalized_result;
|
||||
const scriptIndex = manifest.scripts.findIndex(
|
||||
(script) => script.url === `${site.origin}/scoped/main.js?build=2`,
|
||||
);
|
||||
assert.ok(
|
||||
scriptIndex >= 0,
|
||||
"Real capture did not retain main module response",
|
||||
);
|
||||
const mapPath = join(root, "import-map.json");
|
||||
await writeFile(mapPath, JSON.stringify(site.importMap));
|
||||
const input = {
|
||||
manifest_path: manifest.manifest.path,
|
||||
script_index: scriptIndex,
|
||||
importer_url: `${site.origin}/scoped/main.js?build=2#entry`,
|
||||
import_map: { path: mapPath, base_url: `${site.origin}/maps/` },
|
||||
};
|
||||
const before = [...site.requests];
|
||||
const cliEvidence = await cli(
|
||||
"trace-web-module-imports",
|
||||
input.manifest_path,
|
||||
String(scriptIndex),
|
||||
"--importer-url",
|
||||
input.importer_url,
|
||||
"--import-map-path",
|
||||
mapPath,
|
||||
"--import-map-base-url",
|
||||
input.import_map.base_url,
|
||||
);
|
||||
const cliResult = assertTrace(cliEvidence, oracle, site, scriptIndex);
|
||||
await client.connect(transport);
|
||||
const response = await client.callTool({
|
||||
name: "trace_web_module_imports",
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
const mcpEvidence = parseEvidence(
|
||||
JSON.parse(mcpTextValue(response)).evidence,
|
||||
);
|
||||
const mcpResult = assertTrace(mcpEvidence, oracle, site, scriptIndex);
|
||||
assert.deepEqual(mcpResult, cliResult, "CLI and MCP module traces differ");
|
||||
assert.equal(
|
||||
cliEvidence.subject.digest.sha256,
|
||||
createHash("sha256").update(site.main).digest("hex"),
|
||||
);
|
||||
const mapBytes = await readFile(mapPath);
|
||||
assert.equal(
|
||||
cliResult.import_map.sha256,
|
||||
createHash("sha256").update(mapBytes).digest("hex"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
site.requests,
|
||||
before,
|
||||
"Tracing refetched or executed a captured website dependency",
|
||||
);
|
||||
for (const surface of ["cli", "stdio_mcp"]) {
|
||||
const withoutMap =
|
||||
surface === "cli"
|
||||
? await cli(
|
||||
"trace-web-module-imports",
|
||||
input.manifest_path,
|
||||
String(scriptIndex),
|
||||
)
|
||||
: parseEvidence(
|
||||
JSON.parse(
|
||||
mcpTextValue(
|
||||
await client.callTool({
|
||||
name: "trace_web_module_imports",
|
||||
arguments: {
|
||||
manifest_path: input.manifest_path,
|
||||
script_index: scriptIndex,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).evidence,
|
||||
);
|
||||
const result = webModuleTraceResultSchema.parse(
|
||||
withoutMap.normalized_result,
|
||||
);
|
||||
assert.equal(result.import_map, null);
|
||||
assert.equal(result.imports[0].resolution.state, "rejected");
|
||||
assert.equal(
|
||||
result.imports[2].resolution.url,
|
||||
`${site.origin}/query.js?v=1#one`,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
site.requests,
|
||||
before,
|
||||
"Map-free tracing refetched an asset",
|
||||
);
|
||||
const warnings = await new NativeModuleResolver(env).resolve({
|
||||
importerUrl: input.importer_url,
|
||||
specifiers: ["alias"],
|
||||
importMap: {
|
||||
baseUrl: input.import_map.base_url,
|
||||
value: { imports: { alias: "./global.js", ignored: 42 } },
|
||||
},
|
||||
});
|
||||
assert.equal(warnings.ok, true, warnings.ok ? "" : warnings.error.message);
|
||||
assert.ok(
|
||||
warnings.value.diagnostics.length > 0,
|
||||
"Native import-map warnings were discarded",
|
||||
);
|
||||
assert.equal(
|
||||
warnings.value.resolutions[0].url,
|
||||
`${site.origin}/maps/global.js`,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
let owned;
|
||||
const interrupted = await new NativeModuleResolver(env, async (options) => {
|
||||
owned = await chromium.launch(options);
|
||||
controller.abort();
|
||||
return owned;
|
||||
}).resolve(
|
||||
{
|
||||
importerUrl: input.importer_url,
|
||||
importMap: null,
|
||||
specifiers: ["./dep.js"],
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
assert.equal(
|
||||
interrupted.ok,
|
||||
false,
|
||||
"Cancellation returned successful native resolution",
|
||||
);
|
||||
assert.equal(interrupted.error._tag, "AnalysisCancelledError");
|
||||
assert.equal(
|
||||
owned.isConnected(),
|
||||
false,
|
||||
"Cancelled native resolver left its owned browser connected",
|
||||
);
|
||||
const syntheticImporter = "http://app.invalid/main.js?build=1#entry";
|
||||
const syntheticMapBase = "https://maps.invalid/maps/map.json?x=1&y=2#base";
|
||||
for (const useMap of [false, true])
|
||||
for (const surface of ["cli", "stdio_mcp"]) {
|
||||
let evidence;
|
||||
if (surface === "cli")
|
||||
evidence = await cli(
|
||||
"trace-web-module-imports",
|
||||
input.manifest_path,
|
||||
String(scriptIndex),
|
||||
"--importer-url",
|
||||
syntheticImporter,
|
||||
...(useMap
|
||||
? [
|
||||
"--import-map-path",
|
||||
mapPath,
|
||||
"--import-map-base-url",
|
||||
syntheticMapBase,
|
||||
]
|
||||
: []),
|
||||
);
|
||||
else {
|
||||
const response = await client.callTool({
|
||||
name: "trace_web_module_imports",
|
||||
arguments: {
|
||||
manifest_path: input.manifest_path,
|
||||
script_index: scriptIndex,
|
||||
importer_url: syntheticImporter,
|
||||
...(useMap
|
||||
? { import_map: { path: mapPath, base_url: syntheticMapBase } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
evidence = parseEvidence(JSON.parse(mcpTextValue(response)).evidence);
|
||||
}
|
||||
const result = webModuleTraceResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
if (useMap)
|
||||
assert.deepEqual(result.imports[0].resolution, {
|
||||
state: "resolved",
|
||||
url: "https://maps.invalid/maps/global.js",
|
||||
});
|
||||
else assert.equal(result.imports[0].resolution.state, "rejected");
|
||||
assert.equal(
|
||||
result.imports[2].resolution.url,
|
||||
"http://app.invalid/query.js?v=1#one",
|
||||
);
|
||||
assert.equal(
|
||||
evidence.raw_result.native_report.importer_url,
|
||||
syntheticImporter,
|
||||
);
|
||||
assert.equal(
|
||||
result.source.script.url,
|
||||
`${site.origin}/scoped/main.js?build=2`,
|
||||
);
|
||||
}
|
||||
const escapedBase = await new NativeModuleResolver(env).resolve({
|
||||
importerUrl: syntheticImporter,
|
||||
importMap: {
|
||||
baseUrl: "https://maps.invalid/maps/a&b/map.json?x=1&y=2#base",
|
||||
value: { imports: { alias: "./alias.js" } },
|
||||
},
|
||||
specifiers: ["alias"],
|
||||
});
|
||||
assert.equal(
|
||||
escapedBase.ok,
|
||||
true,
|
||||
escapedBase.ok ? "" : escapedBase.error.message,
|
||||
);
|
||||
assert.deepEqual(escapedBase.value.resolutions[0], {
|
||||
state: "resolved",
|
||||
url: "https://maps.invalid/maps/a&b/alias.js",
|
||||
});
|
||||
assert.deepEqual(
|
||||
site.requests,
|
||||
before,
|
||||
"Synthetic-context tracing refetched or executed a website asset",
|
||||
);
|
||||
return {
|
||||
independent_http_importer_and_https_map_base: true,
|
||||
cancellation_cleanup: true,
|
||||
native_warnings_retained: true,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
public_trace_cases: 8,
|
||||
literal_imports: 11,
|
||||
computed_unknowns: 1,
|
||||
native_loading_oracle: true,
|
||||
independent_module_instances: 3,
|
||||
import_map_scopes: true,
|
||||
null_and_backtracking_errors: true,
|
||||
query_fragment_identity: true,
|
||||
source_identity_verified: true,
|
||||
map_identity_verified: true,
|
||||
implicit_refetches: 0,
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await site.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const assertTrace = (evidence, oracle, site, scriptIndex) => {
|
||||
const result = webModuleTraceResultSchema.parse(evidence.normalized_result);
|
||||
assert.equal(result.source.script_index, scriptIndex);
|
||||
assert.equal(result.parser.state, "parsed");
|
||||
assert.equal(result.imports.length, 12);
|
||||
assert.deepEqual(
|
||||
result.imports.slice(0, 11).map((item) => item.resolution),
|
||||
oracle.resolutions,
|
||||
);
|
||||
assert.deepEqual(result.imports[11].resolution, {
|
||||
state: "unknown",
|
||||
reason: "computed-specifier",
|
||||
});
|
||||
assert.ok(result.imports.every((item) => item.execution === "unknown"));
|
||||
assert.equal(
|
||||
result.imports[6].captured_candidates.length,
|
||||
0,
|
||||
"An unloaded lazy URL became a captured source",
|
||||
);
|
||||
for (const index of [2, 3]) {
|
||||
assert.ok(result.imports[index].captured_candidates.length > 0);
|
||||
assert.ok(
|
||||
result.imports[index].captured_candidates.every(
|
||||
(candidate) => candidate.match === "response-url-without-fragment",
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.equal(result.imports[4].resolution.url, `${site.origin}/query.js?v=2`);
|
||||
assert.equal(
|
||||
result.importer.url,
|
||||
`${site.origin}/scoped/main.js?build=2#entry`,
|
||||
);
|
||||
assert.equal(result.engine.id, "chromium-native-module-resolver");
|
||||
return result;
|
||||
};
|
||||
|
||||
const loadingOracle = async (executable, site) => {
|
||||
const browser = await chromium.launch({
|
||||
executablePath: executable,
|
||||
headless: true,
|
||||
timeout: 15000,
|
||||
args: [
|
||||
"--disable-dev-shm-usage",
|
||||
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(site.origin);
|
||||
await page.waitForFunction(
|
||||
() => globalThis.__moduleProof !== undefined,
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const proof = await page.evaluate(() => globalThis.__moduleProof);
|
||||
assert.equal(proof.alias, "scoped");
|
||||
assert.equal(proof.pkg, "pkg");
|
||||
assert.equal(proof.same_module, true);
|
||||
assert.equal(proof.different_fragments, true);
|
||||
assert.equal(new Set(proof.instances).size, 3);
|
||||
assert.deepEqual(proof.urls, [
|
||||
`${site.origin}/query.js?v=1#one`,
|
||||
`${site.origin}/query.js?v=1#two`,
|
||||
`${site.origin}/query.js?v=2`,
|
||||
]);
|
||||
assert.ok(
|
||||
!site.requests.some(
|
||||
(url) =>
|
||||
url.includes("lazy") ||
|
||||
url.includes("escape") ||
|
||||
url.includes("blocked"),
|
||||
),
|
||||
);
|
||||
return proof;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import { access } from "node:fs/promises";
|
||||
import { verifyBrowserModules } from "./lib/browser-module-e2e.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const executable = process.env.REA_BROWSER_EXECUTABLE;
|
||||
if (!executable)
|
||||
throw new Error(
|
||||
"verify:browser:modules requires REA_BROWSER_EXECUTABLE for caller-supplied Chromium",
|
||||
);
|
||||
await access(executable);
|
||||
const run = createVerifierRun();
|
||||
const proof = await verifyBrowserModules(executable, process.argv[2]);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...proof, verifier_run: await completeVerifierRun(run), verified: true })}\n`,
|
||||
);
|
||||
@@ -37,6 +37,7 @@ 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";
|
||||
import { verifyBrowserModules } from "./lib/browser-module-e2e.mjs";
|
||||
import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
|
||||
|
||||
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
|
||||
@@ -336,6 +337,7 @@ try {
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
});
|
||||
const moduleTrace = await verifyBrowserModules(executable);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
@@ -357,6 +359,7 @@ try {
|
||||
popupEvents,
|
||||
networkEvidence,
|
||||
scriptExport,
|
||||
moduleTrace,
|
||||
domDestinations,
|
||||
browserScenarioCli: true,
|
||||
browserScenarioAttachCleanup: "disconnected-external",
|
||||
|
||||
@@ -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: 129
|
||||
catalog_digest: "43e6ac2812f72123ce1d115c5e1be9fc963007987930b21892ee0e90b211dc68"
|
||||
tool_count: 130
|
||||
catalog_digest: "c32a78528d109b2c25582a74c02d25ccb779cb511f6e2477017a2e8805c07bac"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -2,6 +2,35 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCapabilityInventory } from "./CapabilityInventory.js";
|
||||
|
||||
it("reports module syntax and native resolution availability separately", () => {
|
||||
const input = { open: false, capabilities: [] };
|
||||
const unavailable = buildCapabilityInventory(input, {
|
||||
processCaptureEnabled: false,
|
||||
}).find((tool) => tool.name === "trace_web_module_imports");
|
||||
expect(unavailable).toMatchObject({
|
||||
available: true,
|
||||
default_mode_available: false,
|
||||
modes: [
|
||||
expect.objectContaining({
|
||||
name: "sources-without-literal-imports",
|
||||
available: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "native-literal-resolution",
|
||||
available: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const enabled = buildCapabilityInventory(input, {
|
||||
processCaptureEnabled: false,
|
||||
webModuleResolutionEnabled: true,
|
||||
}).find((tool) => tool.name === "trace_web_module_imports");
|
||||
expect(enabled).toMatchObject({
|
||||
available: true,
|
||||
default_mode_available: true,
|
||||
});
|
||||
});
|
||||
|
||||
const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
|
||||
processCaptureEnabled: true,
|
||||
browserObservationEnabled: true,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type AvailabilityPolicy = {
|
||||
readonly firmwareExtractionEnabled?: boolean;
|
||||
readonly androidAnalysisEnabled?: boolean;
|
||||
readonly javascriptRecoveryEnabled?: boolean;
|
||||
readonly webModuleResolutionEnabled?: boolean;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
readonly browserScenarioEnabled?: boolean;
|
||||
readonly electronObservationEnabled?: boolean;
|
||||
@@ -237,6 +238,34 @@ const workflowAvailabilityFor = ({
|
||||
kind,
|
||||
policy,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
if (name === "trace_web_module_imports")
|
||||
return {
|
||||
reason: "available",
|
||||
remediation: null,
|
||||
defaultModeAvailable: policy.webModuleResolutionEnabled === true,
|
||||
modes: [
|
||||
{
|
||||
name: "sources-without-literal-imports",
|
||||
available: true,
|
||||
missing_operations: [],
|
||||
remediation: null,
|
||||
},
|
||||
policy.webModuleResolutionEnabled === true
|
||||
? {
|
||||
name: "native-literal-resolution",
|
||||
available: true,
|
||||
missing_operations: [],
|
||||
remediation: null,
|
||||
}
|
||||
: {
|
||||
name: "native-literal-resolution",
|
||||
available: false,
|
||||
missing_operations: ["native-module-resolver"],
|
||||
remediation:
|
||||
"Provide an absolute REA_BROWSER_EXECUTABLE supporting import.meta.resolve; no binary target is required.",
|
||||
},
|
||||
],
|
||||
};
|
||||
if (name === "recover_javascript_sources")
|
||||
return policy.javascriptRecoveryEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
WebModuleFile,
|
||||
WebModuleResolution,
|
||||
WebModuleTraceInput,
|
||||
} from "../domain/webModuleTrace.js";
|
||||
import type { webScriptExportManifestSchema } from "../domain/webScriptExport.js";
|
||||
import type { z } from "zod";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
|
||||
type JsonObject = Record<string, JsonValue>;
|
||||
|
||||
/** Verified selected source and capture metadata; provider protocols stay outside. */
|
||||
export interface WebModuleArtifacts {
|
||||
readonly manifest: z.output<typeof webScriptExportManifestSchema>;
|
||||
readonly manifestFile: WebModuleFile;
|
||||
readonly sourceFile: WebModuleFile;
|
||||
readonly source: string;
|
||||
readonly importMap: {
|
||||
readonly file: WebModuleFile;
|
||||
readonly baseUrl: string;
|
||||
readonly value: JsonObject;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Read selected local artifacts without refetching website assets. */
|
||||
export interface WebModuleArtifactPort {
|
||||
load(
|
||||
input: WebModuleTraceInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebModuleArtifacts, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** Resolve literal specifiers in a selected URL/map context without executing the selected application. */
|
||||
export interface WebModuleResolutionBatch {
|
||||
readonly engine: { readonly id: string; readonly version: string };
|
||||
readonly resolutions: readonly WebModuleResolution[];
|
||||
readonly diagnostics: readonly string[];
|
||||
readonly rawResult: JsonObject;
|
||||
}
|
||||
|
||||
/** Replaceable native resolution engine, with no provider protocol in callers. */
|
||||
export interface WebModuleResolutionPort {
|
||||
resolve(
|
||||
input: {
|
||||
readonly importerUrl: string;
|
||||
readonly importMap: {
|
||||
readonly baseUrl: string;
|
||||
readonly value: JsonObject;
|
||||
} | null;
|
||||
readonly specifiers: readonly string[];
|
||||
},
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebModuleResolutionBatch, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WebModuleTraceService } from "./WebModuleTraceService.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
webModuleTraceResultSchema,
|
||||
type WebModuleResolution,
|
||||
} from "../domain/webModuleTrace.js";
|
||||
import {
|
||||
webModuleArtifactsFixture,
|
||||
webModuleResolverFixture,
|
||||
} from "../../tests/fixtures/webModuleTrace.js";
|
||||
|
||||
const args = { manifest_path: "/analysis/manifest.json", script_index: 0 };
|
||||
it.each([
|
||||
{ ...args, importer_url: "file:///tmp/main.js" },
|
||||
{ ...args, import_map: { path: "/map.json", base_url: "file:///tmp/maps/" } },
|
||||
])(
|
||||
"distinguishes valid unsupported URL contexts from malformed input: %j",
|
||||
async (input) => {
|
||||
const result = await new WebModuleTraceService(
|
||||
{
|
||||
load: () => {
|
||||
throw new Error("must not acquire artifacts");
|
||||
},
|
||||
},
|
||||
webModuleResolverFixture,
|
||||
).trace(input);
|
||||
if (result.ok) throw new Error("expected unsupported context");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
message: expect.stringContaining("file:"),
|
||||
});
|
||||
},
|
||||
);
|
||||
it("rejects source text changed across the artifact port while retaining the original digest", async () => {
|
||||
const artifacts = {
|
||||
...webModuleArtifactsFixture("import(name)"),
|
||||
source: "import(other)",
|
||||
};
|
||||
const result = await new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
{
|
||||
resolve: () => {
|
||||
throw new Error("must not acquire engine");
|
||||
},
|
||||
},
|
||||
).trace(args);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
it("rejects sparse resolver arrays rather than relabeling literal imports as computed unknowns", async () => {
|
||||
const artifacts = webModuleArtifactsFixture('import "./dep.js";');
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
{
|
||||
resolve: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
engine: { id: "fixture", version: "1" },
|
||||
resolutions: new Array<WebModuleResolution>(1),
|
||||
diagnostics: [],
|
||||
rawResult: {},
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
const result = await service.trace(args);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
describe("selected module trace workflow", () => {
|
||||
it("composes selected source, derived resolution and explicit computed/execution unknowns", async () => {
|
||||
const artifacts = webModuleArtifactsFixture();
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
webModuleResolverFixture,
|
||||
);
|
||||
const response = await service.trace(args);
|
||||
if (!response.ok) throw response.error;
|
||||
const result = webModuleTraceResultSchema.parse(
|
||||
response.value.normalized_result,
|
||||
);
|
||||
expect(result.importer).toEqual({
|
||||
url: artifacts.manifest.scripts[0]?.url,
|
||||
basis: "reported-source-url",
|
||||
});
|
||||
expect(result.manifest.capture_completeness).toEqual(
|
||||
artifacts.manifest.capture_completeness,
|
||||
);
|
||||
expect(result.imports[0]?.resolution).toEqual({
|
||||
state: "resolved",
|
||||
url: "https://app.test/dep.js",
|
||||
});
|
||||
expect(result.imports[1]?.resolution).toEqual({
|
||||
state: "unknown",
|
||||
reason: "computed-specifier",
|
||||
});
|
||||
expect(result.imports.every((item) => item.execution === "unknown")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(response.value.subject).toMatchObject({
|
||||
local_path: artifacts.sourceFile.path,
|
||||
digest: { sha256: artifacts.sourceFile.sha256 },
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ ...args, manifest_path: "relative.json" },
|
||||
{
|
||||
...args,
|
||||
import_map: { path: "relative.json", base_url: "https://app.test/" },
|
||||
},
|
||||
{ ...args, importer_url: "not a URL" },
|
||||
{ ...args, import_map: { path: "/map.json", base_url: "relative" } },
|
||||
])(
|
||||
"rejects malformed context before acquiring artifacts: %j",
|
||||
async (input) => {
|
||||
const service = new WebModuleTraceService(
|
||||
{
|
||||
load: () => {
|
||||
throw new Error("must not acquire");
|
||||
},
|
||||
},
|
||||
webModuleResolverFixture,
|
||||
);
|
||||
const response = await service.trace(input);
|
||||
if (response.ok) throw new Error("expected invalid input");
|
||||
expect(projectAnalysisError(response.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [expect.objectContaining({ reason: "invalid_format" })],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
it("handles computed-only sources without acquiring the optional engine", async () => {
|
||||
const artifacts = webModuleArtifactsFixture("import(name)");
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
{
|
||||
resolve: () => {
|
||||
throw new Error("engine must not start");
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await service.trace(args);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
webModuleTraceResultSchema.parse(result.value.normalized_result).engine,
|
||||
).toBeNull();
|
||||
});
|
||||
it("rejects incomplete resolution rather than fabricating unknown values", async () => {
|
||||
const artifacts = webModuleArtifactsFixture();
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
{
|
||||
resolve: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
engine: { id: "fixture", version: "1" },
|
||||
resolutions: [],
|
||||
diagnostics: [],
|
||||
rawResult: {},
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
const response = await service.trace(args);
|
||||
expect(response.ok).toBe(false);
|
||||
if (!response.ok) expect(response.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
it("preserves an explicit selected importer context", async () => {
|
||||
const artifacts = webModuleArtifactsFixture();
|
||||
const result = await new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(artifacts)) },
|
||||
webModuleResolverFixture,
|
||||
).trace({ ...args, importer_url: "https://selected.test/base/main.js" });
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
webModuleTraceResultSchema.parse(result.value.normalized_result)
|
||||
.imports[0]?.resolution,
|
||||
).toEqual({ state: "resolved", url: "https://selected.test/base/dep.js" });
|
||||
});
|
||||
it("honors cancellation before any artifacts are acquired", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const service = new WebModuleTraceService(
|
||||
{
|
||||
load: () => {
|
||||
throw new Error("must not acquire");
|
||||
},
|
||||
},
|
||||
webModuleResolverFixture,
|
||||
);
|
||||
const result = await service.trace(args, { signal: controller.signal });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
collectWebModuleImports,
|
||||
matchCapturedModules,
|
||||
} from "../domain/webModuleImports.js";
|
||||
import {
|
||||
webModuleTraceInputSchema,
|
||||
webModuleFileSchema,
|
||||
webModuleResolutionSchema,
|
||||
webModuleTraceResultSchema,
|
||||
type WebModuleResolution,
|
||||
type WebModuleTraceResult,
|
||||
} from "../domain/webModuleTrace.js";
|
||||
import { webScriptExportManifestSchema } from "../domain/webScriptExport.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type {
|
||||
WebModuleArtifactPort,
|
||||
WebModuleResolutionPort,
|
||||
WebModuleResolutionBatch,
|
||||
} from "./WebModulePorts.js";
|
||||
|
||||
const OPERATION = "trace_web_module_imports";
|
||||
const artifactsSchema = z.strictObject({
|
||||
manifest: webScriptExportManifestSchema,
|
||||
manifestFile: webModuleFileSchema,
|
||||
sourceFile: webModuleFileSchema,
|
||||
source: z.string(),
|
||||
importMap: z
|
||||
.strictObject({
|
||||
file: webModuleFileSchema,
|
||||
baseUrl: z.string().min(1),
|
||||
value: jsonObjectSchema,
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
const resolutionBatchSchema = z.strictObject({
|
||||
engine: z.strictObject({ id: z.string().min(1), version: z.string().min(1) }),
|
||||
resolutions: z.array(webModuleResolutionSchema),
|
||||
diagnostics: z.array(z.string()),
|
||||
rawResult: jsonObjectSchema,
|
||||
});
|
||||
const LIMITATIONS = [
|
||||
"Resolution is derived under the selected importer URL and optional import map; it does not establish that the map was installed in the captured page.",
|
||||
"Reported source URLs do not establish an inline script's document base. Select importer_url explicitly when that context differs or is unknown.",
|
||||
"Captured candidates retain reported capture metadata; their bytes are not reverified by this trace unless selected as the source.",
|
||||
"A resolved URL or captured source does not prove loading, execution, UI causality, or module namespace identity. Execution remains unknown.",
|
||||
"Only native ES module syntax is resolved. Bundler module IDs, computed imports, workers and non-HTTP(S) importer contexts are outside this trace.",
|
||||
"Source-map declarations do not establish retained map bytes or executed original-source coverage.",
|
||||
"Selected application code is parsed as data and never executed. The owned resolver executes only REA's trusted stub; its page requests are locally fulfilled or blocked.",
|
||||
"Manifest, source and import-map limits are 32 MiB, 16 MiB and 4 MiB. Native resolution has a 20-second deadline; cleanup may extend it. One renderer is requested; no aggregate host memory limit is claimed.",
|
||||
];
|
||||
|
||||
/** Compose verified captures, inert syntax and a replaceable URL resolution engine. */
|
||||
export class WebModuleTraceService {
|
||||
constructor(
|
||||
readonly artifacts: WebModuleArtifactPort,
|
||||
readonly resolver: WebModuleResolutionPort,
|
||||
) {}
|
||||
|
||||
async trace(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (isAborted(options?.signal))
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const parsed = webModuleTraceInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
{ cause: parsed.error },
|
||||
projectInputIssues(parsed.error.issues, rawInput),
|
||||
),
|
||||
);
|
||||
const input = parsed.data;
|
||||
for (const [path, value] of [
|
||||
["manifest_path", input.manifest_path],
|
||||
["import_map.path", input.import_map?.path],
|
||||
] as const)
|
||||
if (value !== undefined && !isAbsolute(value))
|
||||
return invalid(
|
||||
path.split("."),
|
||||
"Expected an absolute filesystem path on this host.",
|
||||
);
|
||||
for (const [path, value] of [
|
||||
["importer_url", input.importer_url],
|
||||
["import_map.base_url", input.import_map?.base_url],
|
||||
] as const)
|
||||
if (value !== undefined) {
|
||||
const admission = admitContext(value, path.split("."));
|
||||
if (!admission.ok) return admission;
|
||||
}
|
||||
const loaded = await this.artifacts.load(input, options);
|
||||
if (!loaded.ok) return loaded;
|
||||
const verified = artifactsSchema.safeParse(loaded.value);
|
||||
if (!verified.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Artifact port returned a malformed source/manifest report.",
|
||||
),
|
||||
);
|
||||
const data = verified.data;
|
||||
if (
|
||||
(input.import_map?.path ?? null) !==
|
||||
(data.importMap?.file.path ?? null) ||
|
||||
(input.import_map?.base_url ?? null) !== (data.importMap?.baseUrl ?? null)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Artifact port changed the selected import-map identity/context.",
|
||||
),
|
||||
);
|
||||
const selected = data.manifest.scripts[input.script_index];
|
||||
if (
|
||||
selected === undefined ||
|
||||
selected.content.state !== "exported" ||
|
||||
selected.content.sha256 !== data.sourceFile.sha256 ||
|
||||
selected.content.bytes !== data.sourceFile.bytes ||
|
||||
Buffer.byteLength(data.source) !== data.sourceFile.bytes ||
|
||||
createHash("sha256").update(data.source, "utf8").digest("hex") !==
|
||||
data.sourceFile.sha256 ||
|
||||
data.manifestFile.path !== input.manifest_path
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Artifact port did not bind the selected source and manifest identities.",
|
||||
),
|
||||
);
|
||||
const importerUrl = input.importer_url ?? selected.url;
|
||||
const importerAdmission = admitContext(importerUrl, ["importer_url"]);
|
||||
if (!importerAdmission.ok) return importerAdmission;
|
||||
const syntax = collectWebModuleImports(data.source);
|
||||
const literals = syntax.imports
|
||||
.filter((reference) => reference.specifier !== null)
|
||||
.map((reference) => reference.specifier ?? "");
|
||||
let resolution: WebModuleResolutionBatch | null = null;
|
||||
if (literals.length > 0) {
|
||||
const execution = await this.resolver.resolve(
|
||||
{
|
||||
importerUrl,
|
||||
importMap:
|
||||
data.importMap === null
|
||||
? null
|
||||
: {
|
||||
baseUrl: data.importMap.baseUrl,
|
||||
value: data.importMap.value,
|
||||
},
|
||||
specifiers: literals,
|
||||
},
|
||||
options,
|
||||
);
|
||||
if (!execution.ok) return execution;
|
||||
const report = resolutionBatchSchema.safeParse(execution.value);
|
||||
if (!report.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Resolver returned malformed or incomplete native evidence.",
|
||||
),
|
||||
);
|
||||
resolution = report.data;
|
||||
if (resolution.resolutions.length !== literals.length)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Resolver did not return every selected literal specifier.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isAborted(options?.signal))
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
let literalIndex = 0;
|
||||
const imports: WebModuleTraceResult["imports"] = [];
|
||||
for (const reference of syntax.imports) {
|
||||
let resolved:
|
||||
| WebModuleResolution
|
||||
| { state: "unknown"; reason: "computed-specifier" };
|
||||
if (reference.specifier === null)
|
||||
resolved = { state: "unknown", reason: "computed-specifier" };
|
||||
else {
|
||||
const reported = resolution?.resolutions[literalIndex++];
|
||||
if (reported === undefined)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Resolver omitted a selected literal specifier.",
|
||||
),
|
||||
);
|
||||
resolved = reported;
|
||||
}
|
||||
imports.push({
|
||||
...reference,
|
||||
resolution: resolved,
|
||||
captured_candidates:
|
||||
resolved.state === "resolved"
|
||||
? matchCapturedModules(resolved.url, data.manifest.scripts)
|
||||
: [],
|
||||
execution: "unknown",
|
||||
});
|
||||
}
|
||||
const normalized = webModuleTraceResultSchema.safeParse({
|
||||
manifest: {
|
||||
...data.manifestFile,
|
||||
reported_output_directory: data.manifest.output_directory,
|
||||
capture_sha256: data.manifest.capture_sha256,
|
||||
capture_path: data.manifest.capture_path,
|
||||
capture_kind: data.manifest.capture_kind,
|
||||
capture_completeness: data.manifest.capture_completeness,
|
||||
source_evidence_id: data.manifest.source_evidence_id,
|
||||
},
|
||||
source: {
|
||||
...data.sourceFile,
|
||||
script_index: input.script_index,
|
||||
script: selected,
|
||||
},
|
||||
importer: {
|
||||
url: importerUrl,
|
||||
basis:
|
||||
input.importer_url === undefined
|
||||
? "reported-source-url"
|
||||
: "caller-selected",
|
||||
},
|
||||
import_map:
|
||||
data.importMap === null
|
||||
? null
|
||||
: { ...data.importMap.file, base_url: data.importMap.baseUrl },
|
||||
parser: {
|
||||
state: syntax.state,
|
||||
diagnostics: syntax.diagnostics,
|
||||
location_units: "UTF-16 offsets and columns; one-based lines",
|
||||
},
|
||||
engine: resolution?.engine ?? null,
|
||||
imports,
|
||||
diagnostics: resolution?.diagnostics ?? [],
|
||||
limitations: LIMITATIONS,
|
||||
});
|
||||
if (!normalized.success)
|
||||
return err(new AnalysisOutputError(OPERATION, normalized.error.message));
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: data.sourceFile.path,
|
||||
sha256: data.sourceFile.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{
|
||||
id: "rea-web-module-trace",
|
||||
name: "REA captured module relationship trace",
|
||||
version: "1",
|
||||
},
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: normalized.data,
|
||||
rawResult: resolution?.rawResult ?? null,
|
||||
confidence: "derived",
|
||||
authority: "historical-reference",
|
||||
limitations: LIMITATIONS,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: data.sourceFile.path },
|
||||
{ kind: "artifact-path", path: input.manifest_path },
|
||||
],
|
||||
evidenceLinks:
|
||||
data.manifest.source_evidence_id === null
|
||||
? []
|
||||
: [data.manifest.source_evidence_id],
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const admitContext = (
|
||||
value: string,
|
||||
path: readonly string[],
|
||||
): Result<null, AnalysisError> => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return invalid(
|
||||
path,
|
||||
"Expected an absolute context URL. Select importer_url explicitly when the reported source URL is unknown.",
|
||||
);
|
||||
}
|
||||
if (url.username !== "" || url.password !== "")
|
||||
return invalid(
|
||||
path,
|
||||
"Module context URLs must not contain username/password userinfo.",
|
||||
);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"web-module-resolution",
|
||||
OPERATION,
|
||||
"unsupported_context_scheme",
|
||||
{
|
||||
userMessage: `${path.join(".")} ${value} uses unsupported ${url.protocol} context. This trace currently supports HTTP(S) importer/map contexts; select a supported context explicitly when appropriate.`,
|
||||
},
|
||||
),
|
||||
);
|
||||
return ok(null);
|
||||
};
|
||||
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
|
||||
const invalid = (path: readonly string[], message: string) =>
|
||||
err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{ path, reason: "invalid_format", message },
|
||||
]),
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open } from "node:fs/promises";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
/** Read one bounded regular file and reject replacement or in-place changes. */
|
||||
export const readStableArtifact = async (
|
||||
path: string,
|
||||
maximumBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ readonly bytes: Buffer; readonly sha256: string }> => {
|
||||
signal?.throwIfAborted();
|
||||
const before = await lstat(path, { bigint: true });
|
||||
if (!before.isFile() || before.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Expected a regular file without a symlink: ${path}`,
|
||||
);
|
||||
if (before.size > BigInt(maximumBytes))
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`Selected artifact exceeds ${String(maximumBytes)} bytes: ${path}`,
|
||||
);
|
||||
const file = await open(
|
||||
path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
try {
|
||||
const initial = await file.stat({ bigint: true });
|
||||
if (
|
||||
!initial.isFile() ||
|
||||
initial.dev !== before.dev ||
|
||||
initial.ino !== before.ino
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact changed before open: ${path}`,
|
||||
);
|
||||
const chunks: Buffer[] = [];
|
||||
let count = 0;
|
||||
for await (const chunk of file.createReadStream({
|
||||
autoClose: false,
|
||||
highWaterMark: 65536,
|
||||
})) {
|
||||
signal?.throwIfAborted();
|
||||
const value: unknown = chunk;
|
||||
if (!Buffer.isBuffer(value))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Artifact stream did not return bytes: ${path}`,
|
||||
);
|
||||
const bytes = value;
|
||||
count += bytes.length;
|
||||
if (count > maximumBytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`Selected artifact grew beyond its byte limit: ${path}`,
|
||||
);
|
||||
chunks.push(bytes);
|
||||
}
|
||||
const after = await file.stat({ bigint: true });
|
||||
const current = await lstat(path, { bigint: true });
|
||||
if (
|
||||
[after, current].some(
|
||||
(stat) =>
|
||||
!stat.isFile() ||
|
||||
stat.dev !== initial.dev ||
|
||||
stat.ino !== initial.ino ||
|
||||
stat.size !== initial.size ||
|
||||
stat.mtimeNs !== initial.mtimeNs ||
|
||||
stat.ctimeNs !== initial.ctimeNs,
|
||||
) ||
|
||||
BigInt(count) !== initial.size
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact changed during read: ${path}`,
|
||||
);
|
||||
const bytes = Buffer.concat(chunks, count);
|
||||
return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") };
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NativeModuleResolver } from "./NativeModuleResolver.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
|
||||
const input = {
|
||||
importerUrl: "https://app.test/main.js",
|
||||
importMap: null,
|
||||
specifiers: ["./dep.js"],
|
||||
};
|
||||
describe("optional native resolution acquisition", () => {
|
||||
it("awaits owned cleanup when cancellation arrives during acquisition", async () => {
|
||||
const controller = new AbortController();
|
||||
let closed = false;
|
||||
const provider = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/browser" },
|
||||
() => {
|
||||
controller.abort();
|
||||
return Promise.resolve({
|
||||
version: () => "fixture",
|
||||
newContext: () => {
|
||||
throw new Error("must not acquire context");
|
||||
},
|
||||
close: () => {
|
||||
closed = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
const result = await provider.resolve(input, { signal: controller.signal });
|
||||
expect(closed).toBe(true);
|
||||
if (result.ok) throw new Error("expected cancelled result");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
it("returns cleanup_incomplete when owned cleanup fails after an engine error", async () => {
|
||||
const provider = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/browser" },
|
||||
() =>
|
||||
Promise.resolve({
|
||||
version: () => "fixture",
|
||||
newContext: () =>
|
||||
Promise.reject(new Error("fixture context failure")),
|
||||
close: () =>
|
||||
Promise.reject(new Error("fixture owned cleanup refusal")),
|
||||
}),
|
||||
);
|
||||
const result = await provider.resolve(input);
|
||||
if (result.ok) throw new Error("expected cleanup failure");
|
||||
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
});
|
||||
it("does not load the browser engine when the selected operation lacks its configuration", async () => {
|
||||
const provider = new NativeModuleResolver({}, () => {
|
||||
throw new Error("must not launch");
|
||||
});
|
||||
const result = await provider.resolve(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
message: expect.stringContaining("REA_BROWSER_EXECUTABLE"),
|
||||
});
|
||||
});
|
||||
it("does not acquire a browser after caller cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const provider = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/browser" },
|
||||
() => {
|
||||
throw new Error("must not launch");
|
||||
},
|
||||
);
|
||||
const result = await provider.resolve(input, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
it("retains the selected executable and actual startup diagnostic", async () => {
|
||||
const provider = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/missing/browser" },
|
||||
() => Promise.reject(new Error("spawn EACCES fixture")),
|
||||
);
|
||||
const result = await provider.resolve(input);
|
||||
if (result.ok) throw new Error("expected startup failure");
|
||||
expect(projectAnalysisError(result.error).details).toMatchObject({
|
||||
diagnostics: {
|
||||
executable: "/missing/browser",
|
||||
error_message: "spawn EACCES fixture",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Browser, LaunchOptions } from "playwright-core";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import type {
|
||||
WebModuleResolutionPort,
|
||||
WebModuleResolutionBatch,
|
||||
} from "../../application/WebModulePorts.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../../domain/browserObservationError.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { webModuleResolutionSchema } from "../../domain/webModuleTrace.js";
|
||||
import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js";
|
||||
|
||||
const OPERATION = "trace_web_module_imports";
|
||||
const TIMEOUT_MS = 20000;
|
||||
const reportSchema = z.strictObject({
|
||||
importer_url: z.string(),
|
||||
entries: z.array(
|
||||
z.strictObject({
|
||||
specifier: z.string(),
|
||||
resolution: webModuleResolutionSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Production launch seam: only the adapter knows the browser API. */
|
||||
export type ModuleResolverLauncher = (
|
||||
options: LaunchOptions,
|
||||
) => Promise<Pick<Browser, "newContext" | "version" | "close">>;
|
||||
const launch: ModuleResolverLauncher = async (options) =>
|
||||
(await import("playwright-core")).chromium.launch(options);
|
||||
|
||||
/** Use native import.meta.resolve in an owned context with locally fulfilled requests. */
|
||||
export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
constructor(
|
||||
readonly environment: Readonly<Record<string, string | undefined>>,
|
||||
readonly launcher: ModuleResolverLauncher = launch,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
input: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
options?: ExecutionOptions,
|
||||
): ReturnType<WebModuleResolutionPort["resolve"]> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const executable = this.environment.REA_BROWSER_EXECUTABLE;
|
||||
if (executable === undefined || !isAbsolute(executable))
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"native-module-resolver",
|
||||
OPERATION,
|
||||
"missing_browser_executable",
|
||||
{
|
||||
userMessage:
|
||||
"Provide an absolute REA_BROWSER_EXECUTABLE for a caller-supplied Chromium browser supporting import.meta.resolve. REA installs no browser.",
|
||||
},
|
||||
),
|
||||
);
|
||||
const deadline = new ProviderStartupDeadline(TIMEOUT_MS, options?.signal);
|
||||
let browser: Pick<Browser, "newContext" | "version" | "close"> | undefined;
|
||||
let cleanup: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
if (cleanup === undefined && browser !== undefined)
|
||||
cleanup = browser.close();
|
||||
return cleanup ?? Promise.resolve();
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
if (browser !== undefined) {
|
||||
const pending = close();
|
||||
void pending.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
deadline.signal.addEventListener("abort", onAbort, { once: true });
|
||||
let result: Result<WebModuleResolutionBatch, AnalysisError>;
|
||||
try {
|
||||
browser = await this.launcher({
|
||||
executablePath: executable,
|
||||
headless: true,
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
args: [
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=128",
|
||||
...(this.environment.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
deadline.signal.throwIfAborted();
|
||||
const context = await browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
acceptDownloads: false,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const diagnostics: string[] = [];
|
||||
let diagnosticBytes = 0;
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
diagnosticBytes += Buffer.byteLength(text);
|
||||
if (diagnosticBytes <= 1024 * 1024)
|
||||
diagnostics.push(`${message.type()}: ${text}`);
|
||||
});
|
||||
const requests: {
|
||||
url: string;
|
||||
resource_type: string;
|
||||
effect: "fulfilled" | "blocked";
|
||||
}[] = [];
|
||||
const importerUrl = new URL(input.importerUrl).href;
|
||||
const documentUrl = new URL("/.rea-module-resolver/document", importerUrl)
|
||||
.href;
|
||||
const transportUrl = new URL(importerUrl);
|
||||
transportUrl.hash = "";
|
||||
let stubSent = false;
|
||||
await context.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.resourceType() === "document" &&
|
||||
request.url() === documentUrl.split("#")[0]
|
||||
) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "document",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: `<!doctype html>${input.importMap === null ? "" : `<base href="${htmlAttribute(new URL(input.importMap.baseUrl).href)}"><script type="importmap">${scriptJson(input.importMap.value)}</script>`}`,
|
||||
});
|
||||
} else if (
|
||||
!stubSent &&
|
||||
request.resourceType() === "script" &&
|
||||
request.url() === transportUrl.href
|
||||
) {
|
||||
stubSent = true;
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "script",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/javascript",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: `if (typeof import.meta.resolve !== "function") {globalThis.__reaModuleUnsupported = true;} else {globalThis.__reaModuleResult = {importer_url: import.meta.url, entries: ${scriptJson(input.specifiers)}.map(specifier => {try {return {specifier, resolution: {state: "resolved", url: import.meta.resolve(specifier)}}} catch (error) {return {specifier, resolution: {state: "rejected", message: String(error)}}}})};}`,
|
||||
});
|
||||
} else {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: request.resourceType(),
|
||||
effect: "blocked",
|
||||
});
|
||||
await route.abort();
|
||||
}
|
||||
});
|
||||
await page.goto(documentUrl, {
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
});
|
||||
await page.evaluate(
|
||||
`{const script = document.createElement("script"); script.type = "module"; script.src = ${scriptJson(importerUrl)}; script.onerror = () => {globalThis.__reaModuleFailure = "Trusted resolver stub could not be loaded in the selected context.";}; document.head.append(script);}`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Reflect.has(globalThis, "__reaModuleResult") ||
|
||||
Reflect.has(globalThis, "__reaModuleUnsupported") ||
|
||||
Reflect.has(globalThis, "__reaModuleFailure"),
|
||||
undefined,
|
||||
{ timeout: Math.max(1, deadline.remainingMs()) },
|
||||
);
|
||||
const unsupported: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown,
|
||||
);
|
||||
if (unsupported === true)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"native-module-resolver",
|
||||
OPERATION,
|
||||
"native_resolution_unsupported",
|
||||
{
|
||||
userMessage: `Configured Chromium ${browser.version()} does not supply import.meta.resolve; use a browser supporting native module resolution.`,
|
||||
},
|
||||
);
|
||||
const raw: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleResult") as unknown,
|
||||
);
|
||||
const failure: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleFailure") as unknown,
|
||||
);
|
||||
if (typeof failure === "string")
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`${failure} Native diagnostics: ${diagnostics.join("\n")}`,
|
||||
);
|
||||
const parsed = reportSchema.safeParse(raw);
|
||||
if (
|
||||
!parsed.success ||
|
||||
parsed.data.importer_url !== importerUrl ||
|
||||
parsed.data.entries.length !== input.specifiers.length ||
|
||||
parsed.data.entries.some(
|
||||
(entry, index) => entry.specifier !== input.specifiers[index],
|
||||
)
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Native resolver report has invalid shape or does not match the selected importer/specifiers.",
|
||||
);
|
||||
if (diagnosticBytes > 1024 * 1024)
|
||||
throw new BrowserObservationError(OPERATION, "payload_limit");
|
||||
deadline.signal.throwIfAborted();
|
||||
const engine = {
|
||||
id: "chromium-native-module-resolver",
|
||||
version: browser.version(),
|
||||
};
|
||||
result = ok({
|
||||
engine,
|
||||
resolutions: parsed.data.entries.map((entry) => entry.resolution),
|
||||
diagnostics,
|
||||
rawResult: jsonObjectSchema.parse({
|
||||
native_report: parsed.data,
|
||||
requests,
|
||||
diagnostics,
|
||||
engine,
|
||||
executable_path: executable,
|
||||
}),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
result = err(
|
||||
deadline.interruption === "cancelled"
|
||||
? new AnalysisCancelledError(OPERATION)
|
||||
: deadline.interruption === "timeout"
|
||||
? new AnalysisTimeoutError(OPERATION, TIMEOUT_MS)
|
||||
: cause instanceof AnalysisOutputError ||
|
||||
cause instanceof AnalysisCapabilityUnavailableError ||
|
||||
cause instanceof BrowserObservationError
|
||||
? cause
|
||||
: new ProviderAdapterError("native-module-resolver", OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
executable,
|
||||
error_message:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
deadline.signal.removeEventListener("abort", onAbort);
|
||||
deadline.dispose();
|
||||
try {
|
||||
await close();
|
||||
} catch (cause: unknown) {
|
||||
result = err(
|
||||
new BrowserObservationError(OPERATION, "cleanup_failed", { cause }),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result.ok && options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const scriptJson = (value: unknown): string =>
|
||||
JSON.stringify(value).replaceAll("<", "\\u003c");
|
||||
|
||||
const htmlAttribute = (value: string): string =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
@@ -0,0 +1,230 @@
|
||||
import { lstat, realpath } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
|
||||
import type {
|
||||
WebModuleArtifactPort,
|
||||
WebModuleArtifacts,
|
||||
} from "../../application/WebModulePorts.js";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { safeParseJson } from "../../domain/safeJson.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import type { WebModuleTraceInput } from "../../domain/webModuleTrace.js";
|
||||
import { webScriptExportManifestSchema } from "../../domain/webScriptExport.js";
|
||||
|
||||
/** Load verified exported source bytes relative to the current manifest location. */
|
||||
export class LocalWebModuleArtifacts implements WebModuleArtifactPort {
|
||||
/** Verify exactly the selected manifest, source and optional local map. */
|
||||
async load(input: WebModuleTraceInput, options?: ExecutionOptions) {
|
||||
let field: readonly (string | number)[] = ["manifest_path"];
|
||||
let targetPath = input.manifest_path;
|
||||
try {
|
||||
const manifestBytes = await readStableArtifact(
|
||||
input.manifest_path,
|
||||
32 * 1024 * 1024,
|
||||
options?.signal,
|
||||
);
|
||||
const manifest = webScriptExportManifestSchema.parse(
|
||||
parseJson(manifestBytes.bytes),
|
||||
);
|
||||
field = ["script_index"];
|
||||
const selected = manifest.scripts[input.script_index];
|
||||
if (selected === undefined)
|
||||
throw new ModuleArtifactFormatFailure(
|
||||
`No script at index ${String(input.script_index)}; manifest contains ${String(manifest.scripts.length)} scripts.`,
|
||||
);
|
||||
if (selected.content.state !== "exported")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"web-module-artifacts",
|
||||
"trace_web_module_imports",
|
||||
"selected_source_unavailable",
|
||||
{
|
||||
userMessage: `Selected script ${String(input.script_index)} has unavailable source bytes (${selected.content.reason}): ${selected.content.message} Capture source bytes before tracing this script.`,
|
||||
},
|
||||
),
|
||||
);
|
||||
const portable = normalizeArtifactPath(selected.content.relative_path);
|
||||
if (portable !== selected.content.relative_path)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Selected source path changes during normalization: ${selected.content.relative_path}`,
|
||||
);
|
||||
const root = dirname(input.manifest_path);
|
||||
const sourcePath = join(root, "files", portable);
|
||||
targetPath = sourcePath;
|
||||
await assertContained(root, sourcePath);
|
||||
const sourceBytes = await readStableArtifact(
|
||||
sourcePath,
|
||||
16 * 1024 * 1024,
|
||||
options?.signal,
|
||||
);
|
||||
await assertContained(root, sourcePath);
|
||||
if (
|
||||
sourceBytes.sha256 !== selected.content.sha256 ||
|
||||
sourceBytes.bytes.length !== selected.content.bytes
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Selected source identity mismatch: ${sourcePath}; expected sha256=${selected.content.sha256}, bytes=${String(selected.content.bytes)}; observed sha256=${sourceBytes.sha256}, bytes=${String(sourceBytes.bytes.length)}.`,
|
||||
undefined,
|
||||
{
|
||||
logicalPath: selected.content.relative_path,
|
||||
declaredSha256: selected.content.sha256,
|
||||
calculatedSha256: sourceBytes.sha256,
|
||||
unpacked: false,
|
||||
},
|
||||
);
|
||||
const source = decode(sourceBytes.bytes);
|
||||
let importMap: WebModuleArtifacts["importMap"] = null;
|
||||
if (input.import_map !== undefined) {
|
||||
field = ["import_map", "path"];
|
||||
targetPath = input.import_map.path;
|
||||
const bytes = await readStableArtifact(
|
||||
input.import_map.path,
|
||||
4 * 1024 * 1024,
|
||||
options?.signal,
|
||||
);
|
||||
importMap = {
|
||||
file: {
|
||||
path: input.import_map.path,
|
||||
sha256: bytes.sha256,
|
||||
bytes: bytes.bytes.length,
|
||||
},
|
||||
baseUrl: input.import_map.base_url,
|
||||
value: jsonObjectSchema.parse(parseJson(bytes.bytes)),
|
||||
};
|
||||
}
|
||||
return ok({
|
||||
manifest,
|
||||
manifestFile: {
|
||||
path: input.manifest_path,
|
||||
sha256: manifestBytes.sha256,
|
||||
bytes: manifestBytes.bytes.length,
|
||||
},
|
||||
sourceFile: {
|
||||
path: sourcePath,
|
||||
sha256: sourceBytes.sha256,
|
||||
bytes: sourceBytes.bytes.length,
|
||||
},
|
||||
source,
|
||||
importMap,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("trace_web_module_imports"));
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return err(
|
||||
new ArtifactOperationError(
|
||||
"trace_web_module_imports",
|
||||
cause.reason,
|
||||
cause.details,
|
||||
cause.message,
|
||||
),
|
||||
);
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
typeof cause.code === "string" &&
|
||||
"syscall" in cause &&
|
||||
typeof cause.syscall === "string"
|
||||
)
|
||||
return err(
|
||||
new ArtifactOperationError(
|
||||
"trace_web_module_imports",
|
||||
"io",
|
||||
undefined,
|
||||
`${field.join(".")}: ${cause.code}: ${cause.message}`,
|
||||
),
|
||||
);
|
||||
if (
|
||||
cause instanceof ModuleArtifactFormatFailure ||
|
||||
cause instanceof z.ZodError
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError("trace_web_module_imports", { cause }, [
|
||||
{
|
||||
path: field,
|
||||
reason: "invalid_format",
|
||||
message: `${targetPath}: ${cause.message}`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
"web-module-artifacts",
|
||||
"trace_web_module_imports",
|
||||
{
|
||||
cause,
|
||||
diagnostics: {
|
||||
target_path: targetPath,
|
||||
field: field.join("."),
|
||||
error_message:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ModuleArtifactFormatFailure extends Error {}
|
||||
|
||||
const decode = (bytes: Buffer): string => {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
throw new ModuleArtifactFormatFailure(
|
||||
"Selected artifact is not valid UTF-8.",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
};
|
||||
const parseJson = (bytes: Buffer): unknown => {
|
||||
const parsed = safeParseJson(decode(bytes));
|
||||
if (!parsed.ok) throw new ModuleArtifactFormatFailure(parsed.error);
|
||||
return parsed.value;
|
||||
};
|
||||
const assertContained = async (root: string, path: string): Promise<void> => {
|
||||
const canonicalRoot = await realpath(root);
|
||||
const canonicalFile = await realpath(path);
|
||||
const fromRoot = relative(canonicalRoot, canonicalFile);
|
||||
if (
|
||||
fromRoot === ".." ||
|
||||
fromRoot.startsWith(`..${sep}`) ||
|
||||
isAbsolute(fromRoot)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Selected source escapes its manifest directory: ${path}`,
|
||||
);
|
||||
let directory = dirname(path);
|
||||
while (directory !== root) {
|
||||
const stat = await lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Selected source directory is a symlink or not a directory: ${directory}`,
|
||||
);
|
||||
const parent = dirname(directory);
|
||||
if (parent === directory)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"Selected source has no containing manifest directory.",
|
||||
);
|
||||
directory = parent;
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
import { registerProcessCommands } from "./cli/processCommands.js";
|
||||
import { registerBrowserCommands } from "./cli/browserCommands.js";
|
||||
import { registerWebScriptCommands } from "./cli/webScriptCommands.js";
|
||||
import { registerWebModuleCommands } from "./cli/webModuleCommands.js";
|
||||
import { registerJavaScriptRecoveryCommands } from "./cli/javascriptRecoveryCommands.js";
|
||||
import { registerAdvancedBrowserCommands } from "./cli/browserAdvancedCommands.js";
|
||||
import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js";
|
||||
@@ -57,6 +58,7 @@ export const createCli = (
|
||||
registerProcessCommands(cli, logger, environment);
|
||||
registerBrowserCommands(cli, logger);
|
||||
registerWebScriptCommands(cli, logger);
|
||||
registerWebModuleCommands(cli, logger, environment);
|
||||
registerJavaScriptRecoveryCommands(cli, logger, environment);
|
||||
registerAdvancedBrowserCommands(cli, logger);
|
||||
registerBrowserScenarioCommands(cli, logger);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from "incur";
|
||||
import { createWebModuleTraceService } from "../composition/webModules.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
/** Expose the same selected-context module trace through the CLI. */
|
||||
export const registerWebModuleCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): void => {
|
||||
const service = createWebModuleTraceService(environment);
|
||||
cli.command(CLI_COMMANDS.traceWebModuleImports, {
|
||||
description:
|
||||
"Trace one captured script's native imports to URLs and captured source candidates",
|
||||
args: z.object({
|
||||
manifestPath: z
|
||||
.string()
|
||||
.describe("Absolute export_web_scripts manifest path"),
|
||||
scriptIndex: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Zero-based manifest script index"),
|
||||
}),
|
||||
options: z.object({
|
||||
importerUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Explicit HTTP(S) importer URL"),
|
||||
importMapPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Absolute local import-map JSON"),
|
||||
importMapBaseUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("HTTP(S) import-map base URL"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.traceWebModuleImports, async () => {
|
||||
const result = await service.trace(
|
||||
{
|
||||
manifest_path: args.manifestPath,
|
||||
script_index: args.scriptIndex,
|
||||
...(options.importerUrl === undefined
|
||||
? {}
|
||||
: { importer_url: options.importerUrl }),
|
||||
...(options.importMapPath === undefined &&
|
||||
options.importMapBaseUrl === undefined
|
||||
? {}
|
||||
: {
|
||||
import_map: {
|
||||
path: options.importMapPath,
|
||||
base_url: options.importMapBaseUrl,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Canonical names used by every one-shot CLI command registration. */
|
||||
export const CLI_COMMANDS = Object.freeze({
|
||||
traceWebModuleImports: "trace-web-module-imports",
|
||||
analyze: "analyze",
|
||||
inspect: "inspect",
|
||||
decompile: "decompile",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { WebModuleTraceService } from "../application/WebModuleTraceService.js";
|
||||
import { LocalWebModuleArtifacts } from "../browser/modules/WebModuleArtifacts.js";
|
||||
import { NativeModuleResolver } from "../browser/modules/NativeModuleResolver.js";
|
||||
|
||||
/** Assemble optional module tracing without acquiring a browser or reading files. */
|
||||
export const createWebModuleTraceService = (
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): WebModuleTraceService =>
|
||||
new WebModuleTraceService(
|
||||
new LocalWebModuleArtifacts(),
|
||||
new NativeModuleResolver(environment),
|
||||
);
|
||||
@@ -83,6 +83,7 @@ const PUBLISHED_TOOL_NAME_FLOOR = [
|
||||
"export_evidence_bundle",
|
||||
"extract_artifact",
|
||||
"export_web_scripts",
|
||||
"trace_web_module_imports",
|
||||
"find_changed_behavior",
|
||||
"find_xrefs_to_name",
|
||||
"get_call_graph",
|
||||
|
||||
@@ -41,6 +41,12 @@ const sessionEvidence = effects({ mutatesSession: true });
|
||||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
trace_web_module_imports: effects({
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
inspect_firmware_regions: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
|
||||
@@ -3,11 +3,37 @@ import {
|
||||
webScriptExportResultSchema,
|
||||
} from "../domain/webScriptExport.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import {
|
||||
webModuleTraceInputSchema,
|
||||
webModuleTraceResultSchema,
|
||||
} from "../domain/webModuleTrace.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: "trace_web_module_imports",
|
||||
...toolContractMetadata("trace_web_module_imports"),
|
||||
description:
|
||||
"Trace one exported website script's outgoing native ES imports/re-exports, using exact URL and optional selected import-map context. Reads a local export_web_scripts manifest and verifies the selected source's SHA-256/size; returns source positions, native resolved URLs or exact errors, every matching captured candidate and explicit unknown execution. Query/fragment identities are preserved. Computed imports and bundler IDs remain unknown. Select importer_url for unknown inline/document-base context. Caller-supplied Chromium executes only a trusted REA resolver stub in an owned context with page requests locally fulfilled/blocked; no captured application execution or asset refetch. Requires absolute REA_BROWSER_EXECUTABLE for literal imports. 32 MiB manifest, 16 MiB source, 4 MiB map and 20-second native deadline; cleanup may extend the deadline.",
|
||||
kind: "application",
|
||||
inputSchema: webModuleTraceInputSchema,
|
||||
outputSchema: evidenceResultOf(webModuleTraceResultSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Trace an exported module under a selected import map",
|
||||
input: {
|
||||
manifest_path: "/analysis/exported-scripts/manifest.json",
|
||||
script_index: 0,
|
||||
import_map: {
|
||||
path: "/analysis/import-map.json",
|
||||
base_url: "https://example.test/app/",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "export_web_scripts",
|
||||
...toolContractMetadata("export_web_scripts"),
|
||||
|
||||
@@ -13,6 +13,7 @@ export class ArtifactOperationError extends AnalysisError {
|
||||
| "inspect_asset_catalog"
|
||||
| "inspect_keyed_archive"
|
||||
| "export_web_scripts"
|
||||
| "trace_web_module_imports"
|
||||
| "analyze_javascript_application",
|
||||
readonly reason:
|
||||
| "cancelled"
|
||||
|
||||
@@ -3,6 +3,7 @@ export type BrowserObservationOperation =
|
||||
| "list_browser_targets"
|
||||
| "inspect_web_page"
|
||||
| "analyze_web_bundle"
|
||||
| "trace_web_module_imports"
|
||||
| "observe_web_session"
|
||||
| "discover_webmcp_tools"
|
||||
| "compare_web_captures"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectWebModuleImports,
|
||||
matchCapturedModules,
|
||||
} from "./webModuleImports.js";
|
||||
import { exportedWebScriptSchema } from "./webScriptExport.js";
|
||||
|
||||
describe("native module syntax observations", () => {
|
||||
it("collects native imports and re-exports, preserving empty strings and computed unknowns", () => {
|
||||
const source =
|
||||
'import ""; import x from "./x.js"; export * from "./all.js"; export {x} from "pkg"; import(`./literal.js`); import(`./${name}.js`); import(expr);';
|
||||
const result = collectWebModuleImports(source);
|
||||
expect(result.state).toBe("parsed");
|
||||
expect(result.imports.map((item) => [item.kind, item.specifier])).toEqual([
|
||||
["static-import", ""],
|
||||
["static-import", "./x.js"],
|
||||
["re-export", "./all.js"],
|
||||
["re-export", "pkg"],
|
||||
["dynamic-import", "./literal.js"],
|
||||
["dynamic-import", null],
|
||||
["dynamic-import", null],
|
||||
]);
|
||||
for (const item of result.imports)
|
||||
expect(source.slice(item.start.offset, item.end.offset)).toBe(
|
||||
item.expression,
|
||||
);
|
||||
});
|
||||
it("excludes erased TypeScript imports/exports and bundler inference", () => {
|
||||
const result = collectWebModuleImports(
|
||||
'import type {T} from "types"; import {type X} from "also-types"; export type {T} from "export-types"; export {type Y} from "only-types"; import {type T, value} from "mixed"; __webpack_require__.e(4); __vitePreload(() => 1, ["chunk.js"]);',
|
||||
);
|
||||
expect(result.imports.map((item) => item.specifier)).toEqual(["mixed"]);
|
||||
});
|
||||
it("uses UTF-16 source locations after astral characters", () => {
|
||||
const source = 'const emoji = "😀";\nimport "./x.js";';
|
||||
const item = collectWebModuleImports(source).imports[0];
|
||||
expect(item?.start).toEqual({
|
||||
offset: source.indexOf('"./x.js"'),
|
||||
line: 2,
|
||||
column: 7,
|
||||
});
|
||||
});
|
||||
it("reports unparseable input without asserting an empty complete graph", () => {
|
||||
const result = collectWebModuleImports('import "./ok.js"; function {');
|
||||
expect(result).toMatchObject({ state: "unparseable", imports: [] });
|
||||
expect(result.diagnostics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
const script = (
|
||||
url: string,
|
||||
kind: "page-script" | "scenario-response",
|
||||
sha256 = "a".repeat(64),
|
||||
) =>
|
||||
exportedWebScriptSchema.parse({
|
||||
url,
|
||||
source:
|
||||
kind === "page-script"
|
||||
? {
|
||||
kind,
|
||||
script_key: "script1",
|
||||
frame_id: null,
|
||||
is_module: true,
|
||||
language: "JavaScript",
|
||||
source_map_url: null,
|
||||
}
|
||||
: {
|
||||
kind,
|
||||
transaction_id: "txn",
|
||||
request_sequence: 1,
|
||||
response_sequence: 2,
|
||||
status: 200,
|
||||
},
|
||||
content: {
|
||||
state: "exported",
|
||||
relative_path: "modules/a.js",
|
||||
layout: "isolated",
|
||||
layout_reason: "fixture",
|
||||
sha256,
|
||||
bytes: 1,
|
||||
media_type: "text/javascript",
|
||||
redacted: false,
|
||||
representation:
|
||||
kind === "page-script"
|
||||
? "debugger-source-utf8"
|
||||
: "browser-decoded-response-bytes",
|
||||
},
|
||||
});
|
||||
describe("captured module candidates", () => {
|
||||
it("keeps query versions separate and response fragment matching weaker", () => {
|
||||
const scripts = [
|
||||
script("https://app.test/a.js?v=1#one", "page-script"),
|
||||
script("https://app.test/a.js?v=1#two", "page-script"),
|
||||
script("https://app.test/a.js?v=1", "scenario-response"),
|
||||
script("https://app.test/a.js?v=2", "scenario-response"),
|
||||
];
|
||||
expect(
|
||||
matchCapturedModules("https://app.test/a.js?v=1#one", scripts).map(
|
||||
(item) => [item.script_index, item.match],
|
||||
),
|
||||
).toEqual([
|
||||
[0, "exact-reported-url"],
|
||||
[2, "response-url-without-fragment"],
|
||||
]);
|
||||
});
|
||||
it("preserves every captured version at the same URL and never picks one", () => {
|
||||
const url = "https://app.test/a.js";
|
||||
const candidates = matchCapturedModules(url, [
|
||||
script(url, "scenario-response"),
|
||||
script(url, "scenario-response", "b".repeat(64)),
|
||||
]);
|
||||
expect(candidates).toHaveLength(2);
|
||||
expect(
|
||||
matchCapturedModules(
|
||||
"https://app.test/lazy.js",
|
||||
candidates.map((item) => item.script),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { traverseFast, isStringLiteral, isTemplateLiteral } from "@babel/types";
|
||||
import type { Node } from "@babel/types";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import type { WebModuleImport } from "./webModuleTrace.js";
|
||||
import type { ExportedWebScript } from "./webScriptExport.js";
|
||||
|
||||
/** Collect native import syntax, excluding erased type-only references. */
|
||||
export const collectWebModuleImports = (
|
||||
source: string,
|
||||
): {
|
||||
readonly imports: readonly WebModuleImport[];
|
||||
readonly state: "parsed" | "unparseable";
|
||||
readonly diagnostics: readonly string[];
|
||||
} => {
|
||||
const ast = parseJavaScriptSource(source);
|
||||
if (ast === null)
|
||||
return {
|
||||
imports: [],
|
||||
state: "unparseable",
|
||||
diagnostics: ["Source could not be parsed; imports are unknown."],
|
||||
};
|
||||
const imports: WebModuleImport[] = [];
|
||||
const add = (kind: WebModuleImport["kind"], expression: Node): void => {
|
||||
if (
|
||||
expression.start === null ||
|
||||
expression.start === undefined ||
|
||||
expression.end === null ||
|
||||
expression.end === undefined ||
|
||||
expression.loc === null ||
|
||||
expression.loc === undefined
|
||||
)
|
||||
return;
|
||||
const specifier = isStringLiteral(expression)
|
||||
? expression.value
|
||||
: isTemplateLiteral(expression) && expression.expressions.length === 0
|
||||
? (expression.quasis[0]?.value.cooked ?? null)
|
||||
: null;
|
||||
imports.push({
|
||||
kind,
|
||||
specifier,
|
||||
expression: source.slice(expression.start, expression.end),
|
||||
start: {
|
||||
offset: expression.start,
|
||||
line: expression.loc.start.line,
|
||||
column: expression.loc.start.column,
|
||||
},
|
||||
end: {
|
||||
offset: expression.end,
|
||||
line: expression.loc.end.line,
|
||||
column: expression.loc.end.column,
|
||||
},
|
||||
});
|
||||
};
|
||||
traverseFast(ast, (node) => {
|
||||
if (node.type === "ImportDeclaration") {
|
||||
if (node.importKind === "type" || node.importKind === "typeof") return;
|
||||
if (
|
||||
node.specifiers.length > 0 &&
|
||||
node.specifiers.every(
|
||||
(s) =>
|
||||
s.type === "ImportSpecifier" &&
|
||||
(s.importKind === "type" || s.importKind === "typeof"),
|
||||
)
|
||||
)
|
||||
return;
|
||||
add("static-import", node.source);
|
||||
} else if (
|
||||
node.type === "ExportAllDeclaration" ||
|
||||
node.type === "ExportNamedDeclaration"
|
||||
) {
|
||||
if (
|
||||
node.exportKind === "type" ||
|
||||
node.source === null ||
|
||||
node.source === undefined
|
||||
)
|
||||
return;
|
||||
if (
|
||||
node.type === "ExportNamedDeclaration" &&
|
||||
node.specifiers.length > 0 &&
|
||||
node.specifiers.every(
|
||||
(s) => s.type === "ExportSpecifier" && s.exportKind === "type",
|
||||
)
|
||||
)
|
||||
return;
|
||||
add("re-export", node.source);
|
||||
} else if (node.type === "ImportExpression")
|
||||
add("dynamic-import", node.source);
|
||||
else if (
|
||||
node.type === "CallExpression" &&
|
||||
node.callee.type === "Import" &&
|
||||
node.arguments[0] !== undefined
|
||||
)
|
||||
add("dynamic-import", node.arguments[0]);
|
||||
});
|
||||
imports.sort((a, b) => a.start.offset - b.start.offset);
|
||||
return {
|
||||
imports,
|
||||
state: "parsed",
|
||||
diagnostics: ast.errors?.map((error) => error.message) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
/** Match reported URLs without collapsing query or module fragment identity. */
|
||||
export const matchCapturedModules = (
|
||||
url: string,
|
||||
scripts: readonly ExportedWebScript[],
|
||||
) => {
|
||||
const candidates: {
|
||||
script_index: number;
|
||||
match: "exact-reported-url" | "response-url-without-fragment";
|
||||
script: ExportedWebScript;
|
||||
}[] = [];
|
||||
scripts.forEach((script, script_index) => {
|
||||
if (script.url === url)
|
||||
candidates.push({ script_index, match: "exact-reported-url", script });
|
||||
else if (script.source.kind === "scenario-response") {
|
||||
try {
|
||||
const transport = new URL(url);
|
||||
transport.hash = "";
|
||||
if (transport.href === script.url)
|
||||
candidates.push({
|
||||
script_index,
|
||||
match: "response-url-without-fragment",
|
||||
script,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
}
|
||||
}
|
||||
});
|
||||
return candidates;
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { digestSchema } from "./digests.js";
|
||||
import {
|
||||
exportedWebScriptSchema,
|
||||
webScriptExportManifestSchema,
|
||||
} from "./webScriptExport.js";
|
||||
|
||||
/** Inspect one captured script's native ES module relationships. */
|
||||
export const webModuleTraceInputSchema = z.strictObject({
|
||||
manifest_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute path to an export_web_scripts manifest"),
|
||||
script_index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Zero-based index in manifest.scripts"),
|
||||
importer_url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Explicit HTTP(S) module URL; useful when captured inline/base context is unknown",
|
||||
),
|
||||
import_map: z
|
||||
.strictObject({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute local import-map JSON path; no fetch"),
|
||||
base_url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Explicit HTTP(S) base for the selected map"),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type WebModuleTraceInput = z.output<typeof webModuleTraceInputSchema>;
|
||||
|
||||
/** Byte identity of one selected local artifact. */
|
||||
export const webModuleFileSchema = z.strictObject({
|
||||
path: z.string().min(1),
|
||||
sha256: digestSchema,
|
||||
bytes: z.number().int().min(0),
|
||||
});
|
||||
export type WebModuleFile = z.output<typeof webModuleFileSchema>;
|
||||
|
||||
const positionSchema = z.strictObject({
|
||||
offset: z.number().int().min(0),
|
||||
line: z.number().int().min(1),
|
||||
column: z.number().int().min(0),
|
||||
});
|
||||
|
||||
/** Parser observations do not assert that a dependency was loaded. */
|
||||
export const webModuleImportSchema = z.strictObject({
|
||||
kind: z.enum(["static-import", "re-export", "dynamic-import"]),
|
||||
specifier: z.string().nullable(),
|
||||
expression: z.string(),
|
||||
start: positionSchema,
|
||||
end: positionSchema,
|
||||
});
|
||||
export type WebModuleImport = z.output<typeof webModuleImportSchema>;
|
||||
|
||||
/** Native resolver output, including the engine's exact failure text. */
|
||||
export const webModuleResolutionSchema = z.discriminatedUnion("state", [
|
||||
z.strictObject({ state: z.literal("resolved"), url: z.string().min(1) }),
|
||||
z.strictObject({ state: z.literal("rejected"), message: z.string() }),
|
||||
]);
|
||||
export type WebModuleResolution = z.output<typeof webModuleResolutionSchema>;
|
||||
|
||||
const candidateSchema = z.strictObject({
|
||||
script_index: z.number().int().min(0),
|
||||
match: z.enum(["exact-reported-url", "response-url-without-fragment"]),
|
||||
script: exportedWebScriptSchema,
|
||||
});
|
||||
|
||||
/** Complete outgoing imports with captured candidates and explicit unknowns. */
|
||||
export const webModuleTraceResultSchema = z.strictObject({
|
||||
manifest: webModuleFileSchema.extend({
|
||||
reported_output_directory: z.string(),
|
||||
...webScriptExportManifestSchema.pick({
|
||||
capture_path: true,
|
||||
capture_sha256: true,
|
||||
capture_kind: true,
|
||||
capture_completeness: true,
|
||||
source_evidence_id: true,
|
||||
}).shape,
|
||||
}),
|
||||
source: webModuleFileSchema.extend({
|
||||
script_index: z.number().int().min(0),
|
||||
script: exportedWebScriptSchema,
|
||||
}),
|
||||
importer: z.strictObject({
|
||||
url: z.string().min(1),
|
||||
basis: z.enum(["caller-selected", "reported-source-url"]),
|
||||
}),
|
||||
import_map: webModuleFileSchema
|
||||
.extend({ base_url: z.string().min(1) })
|
||||
.nullable(),
|
||||
parser: z.strictObject({
|
||||
state: z.enum(["parsed", "unparseable"]),
|
||||
diagnostics: z.array(z.string()),
|
||||
location_units: z.literal("UTF-16 offsets and columns; one-based lines"),
|
||||
}),
|
||||
engine: z
|
||||
.strictObject({ id: z.string().min(1), version: z.string().min(1) })
|
||||
.nullable(),
|
||||
imports: z.array(
|
||||
webModuleImportSchema.extend({
|
||||
resolution: z.union([
|
||||
webModuleResolutionSchema,
|
||||
z.strictObject({
|
||||
state: z.literal("unknown"),
|
||||
reason: z.literal("computed-specifier"),
|
||||
}),
|
||||
]),
|
||||
captured_candidates: z.array(candidateSchema),
|
||||
execution: z.literal("unknown"),
|
||||
}),
|
||||
),
|
||||
diagnostics: z.array(z.string()),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type WebModuleTraceResult = z.output<typeof webModuleTraceResultSchema>;
|
||||
File diff suppressed because one or more lines are too long
@@ -16,6 +16,9 @@ import { registerArtifactTools } from "./registerArtifactTools.js";
|
||||
import { registerBrowserScenarioTool } from "./registerBrowserScenarioTool.js";
|
||||
import { registerBrowserTools } from "./registerBrowserTools.js";
|
||||
import { registerWebScriptTool } from "./registerWebScriptTool.js";
|
||||
import { registerWebModuleTool } from "./registerWebModuleTool.js";
|
||||
import type { WebModuleTraceService } from "../application/WebModuleTraceService.js";
|
||||
import { createWebModuleTraceService } from "../composition/webModules.js";
|
||||
import { registerJavaScriptRecoveryTool } from "./registerJavaScriptRecoveryTool.js";
|
||||
import { JavaScriptRecoveryService } from "../application/JavaScriptRecoveryService.js";
|
||||
import type { JavaScriptRecoveryPort } from "../application/JavaScriptRecoveryPort.js";
|
||||
@@ -50,6 +53,7 @@ export interface CreateServerOptions {
|
||||
readonly logger?: Logger;
|
||||
readonly firmwareAnalysis?: FirmwareAnalysisPort;
|
||||
readonly javascriptRecovery?: JavaScriptRecoveryPort;
|
||||
readonly webModuleTrace?: WebModuleTraceService;
|
||||
readonly androidAnalysis?: AndroidAnalysisPort;
|
||||
readonly browserObservation?: BrowserObservationPort;
|
||||
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
||||
@@ -69,6 +73,9 @@ const installSessionToolAvailability = (
|
||||
const policy = sessionAvailabilityPolicy(options.availabilityPolicy, {
|
||||
optionalProviderLoadFailures: options.optionalProviderLoadFailures,
|
||||
optionalFeatures: {
|
||||
webModuleResolutionEnabled:
|
||||
options.webModuleTrace !== undefined ||
|
||||
isAbsolute(process.env.REA_BROWSER_EXECUTABLE ?? ""),
|
||||
firmwareInspectionEnabled:
|
||||
options.firmwareAnalysis !== undefined ||
|
||||
(process.platform === "linux" &&
|
||||
@@ -165,6 +172,12 @@ export const createServer = (
|
||||
recordEvidence,
|
||||
);
|
||||
registerObservationTools(toolContext);
|
||||
registerWebModuleTool(
|
||||
server,
|
||||
options.webModuleTrace ?? createWebModuleTraceService(),
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
);
|
||||
registerJavaScriptRecoveryTool(
|
||||
server,
|
||||
new JavaScriptRecoveryService(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import type { WebModuleTraceService } from "../application/WebModuleTraceService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.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";
|
||||
|
||||
/** Bind the module relationship workflow to its exact canonical contract. */
|
||||
export const registerWebModuleTool = (
|
||||
server: McpServer,
|
||||
service: WebModuleTraceService,
|
||||
logger: Logger,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
): void => {
|
||||
const contract = toolContract("trace_web_module_imports");
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input, context) => {
|
||||
const result = await logToolExecution(logger, contract.name, () =>
|
||||
service.trace(input, { signal: context.mcpReq.signal }),
|
||||
);
|
||||
if (!result.ok) return toCallToolResult(result, contract);
|
||||
const recorded = recordEvidence?.(result.value);
|
||||
return recorded !== undefined && !recorded.ok
|
||||
? toCallToolResult(recorded, contract)
|
||||
: toCallToolResult(result, contract);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export interface SessionAvailabilityDefaults {
|
||||
| "v8InspectorObservationEnabled"
|
||||
| "androidAnalysisEnabled"
|
||||
| "javascriptRecoveryEnabled"
|
||||
| "webModuleResolutionEnabled"
|
||||
| "firmwareInspectionEnabled"
|
||||
| "firmwareExtractionEnabled"
|
||||
>;
|
||||
@@ -39,6 +40,8 @@ export const sessionAvailabilityPolicy = (
|
||||
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
|
||||
javascriptRecoveryEnabled:
|
||||
defaults.optionalFeatures?.javascriptRecoveryEnabled ?? false,
|
||||
webModuleResolutionEnabled:
|
||||
defaults.optionalFeatures?.webModuleResolutionEnabled ?? false,
|
||||
browserObservationEnabled:
|
||||
defaults.optionalFeatures?.browserObservationEnabled ?? false,
|
||||
browserScenarioEnabled:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
import { createWebModuleTraceService } from "../../src/composition/webModules.js";
|
||||
import { createServer } from "../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { webModuleArtifactsFixture } from "../fixtures/webModuleTrace.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"accepts zero-based numeric source indices and returns the same engine-free CLI/MCP trace",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-module-cli-");
|
||||
const artifacts = webModuleArtifactsFixture("import(name)");
|
||||
const manifest = {
|
||||
...artifacts.manifest,
|
||||
output_directory: root,
|
||||
analysis_input: { input_path: join(root, "files"), format: "directory" },
|
||||
};
|
||||
await mkdir(join(root, "files", "modules"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "files", "modules", "main.js"),
|
||||
artifacts.source,
|
||||
);
|
||||
const manifestPath = join(root, "manifest.json");
|
||||
await writeFile(manifestPath, JSON.stringify(manifest));
|
||||
const environment = {
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_BROWSER_EXECUTABLE: "relative-missing-browser",
|
||||
};
|
||||
const cliResponse = await cli.run({
|
||||
arguments: ["trace-web-module-imports", manifestPath, "0", "--json"],
|
||||
environment,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
expect(cliResponse.exitCode).toBe(0);
|
||||
expect(cliResponse.json).toMatchObject({
|
||||
normalized_result: {
|
||||
engine: null,
|
||||
source: { script_index: 0 },
|
||||
imports: [
|
||||
{ resolution: { state: "unknown", reason: "computed-specifier" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("no binary acquisition");
|
||||
});
|
||||
const server = createServer(session, session, {
|
||||
webModuleTrace: createWebModuleTraceService(environment),
|
||||
});
|
||||
const client = new Client({ name: "module-cli-parity", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const response = await client.callTool({
|
||||
name: "trace_web_module_imports",
|
||||
arguments: { manifest_path: manifestPath, script_index: 0 },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const expected = cliResponse.json;
|
||||
expect(response.structuredContent).toMatchObject({ evidence: expected });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { rename, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("reads exact binary and empty bytes and rejects symlink/size/cancellation boundaries", async () => {
|
||||
const root = await createTestTempDirectory("rea-stable-artifact-");
|
||||
const path = join(root, "source.bin");
|
||||
await writeFile(path, Buffer.from([0, 255, 254]));
|
||||
expect((await readStableArtifact(path, 3)).bytes).toEqual(
|
||||
Buffer.from([0, 255, 254]),
|
||||
);
|
||||
await expect(readStableArtifact(path, 2)).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
});
|
||||
await writeFile(path, "");
|
||||
expect((await readStableArtifact(path, 0)).bytes.length).toBe(0);
|
||||
const real = join(root, "original.bin");
|
||||
await rename(path, real);
|
||||
await symlink(real, path);
|
||||
await expect(readStableArtifact(path, 3)).rejects.toMatchObject({
|
||||
reason: "path",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(readStableArtifact(real, 3, controller.signal)).rejects.toBe(
|
||||
controller.signal.reason,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
|
||||
import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { scriptScenarioFixture } from "../../fixtures/webScriptCapture.js";
|
||||
|
||||
const fixture = async (bytes = Buffer.from('import "./dep.js";')) => {
|
||||
const root = await createTestTempDirectory("rea-module-artifacts-");
|
||||
const capturePath = join(root, "capture.json");
|
||||
await writeFile(
|
||||
capturePath,
|
||||
JSON.stringify(
|
||||
scriptScenarioFixture([{ url: "https://app.test/main.js", bytes }]),
|
||||
),
|
||||
);
|
||||
const response = await exportWebScripts({
|
||||
capture_path: capturePath,
|
||||
output_directory: join(root, "export"),
|
||||
});
|
||||
if (!response.ok) throw response.error;
|
||||
const result = webScriptExportResultSchema.parse(
|
||||
response.value.normalized_result,
|
||||
);
|
||||
const script = result.scripts[0];
|
||||
if (script?.content.state !== "exported")
|
||||
throw new Error("fixture needs source");
|
||||
return {
|
||||
root,
|
||||
result,
|
||||
manifestPath: result.manifest.path,
|
||||
sourcePath: join(
|
||||
result.output_directory,
|
||||
"files",
|
||||
script.content.relative_path,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
describe("selected captured module artifact boundary", () => {
|
||||
it("reports selected unavailable source bytes as a capability limitation", async () => {
|
||||
const f = await fixture();
|
||||
const parsed: unknown = JSON.parse(await readFile(f.manifestPath, "utf8"));
|
||||
const manifest = webScriptExportResultSchema
|
||||
.omit({ manifest: true })
|
||||
.parse(parsed);
|
||||
const script = manifest.scripts[0];
|
||||
if (script === undefined) throw new Error("fixture script missing");
|
||||
script.content = {
|
||||
state: "unavailable",
|
||||
reason: "not-captured",
|
||||
message: "Source bytes were not selected in this capture.",
|
||||
};
|
||||
await writeFile(f.manifestPath, JSON.stringify(manifest));
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: f.manifestPath,
|
||||
script_index: 0,
|
||||
});
|
||||
if (response.ok) throw new Error("expected unavailable source");
|
||||
expect(projectAnalysisError(response.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
message: expect.stringContaining("not-captured"),
|
||||
});
|
||||
});
|
||||
it("verifies original bytes and tolerates relocation without inventing URL paths", async () => {
|
||||
const f = await fixture();
|
||||
const relocated = join(f.root, "relocated");
|
||||
await rename(f.result.output_directory, relocated);
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: join(relocated, "manifest.json"),
|
||||
script_index: 0,
|
||||
});
|
||||
if (!response.ok) throw response.error;
|
||||
expect(response.value.source).toBe('import "./dep.js";');
|
||||
expect(response.value.sourceFile.path.startsWith(relocated)).toBe(true);
|
||||
expect(response.value.manifest.output_directory).toBe(
|
||||
f.result.output_directory,
|
||||
);
|
||||
});
|
||||
it("reports actual and expected identities after source tampering", async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(f.sourcePath, "changed");
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: f.manifestPath,
|
||||
script_index: 0,
|
||||
});
|
||||
if (response.ok) throw new Error("expected mismatch");
|
||||
expect(projectAnalysisError(response.error).code).toBe(
|
||||
"artifact_integrity_mismatch",
|
||||
);
|
||||
expect(response.error.message).toContain("declared_sha256=");
|
||||
});
|
||||
it("rejects unavailable indices and invalid UTF-8 source bytes", async () => {
|
||||
const f = await fixture(Buffer.from([255, 254]));
|
||||
for (const script_index of [0, 10]) {
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: f.manifestPath,
|
||||
script_index,
|
||||
});
|
||||
expect(response.ok).toBe(false);
|
||||
if (!response.ok)
|
||||
expect(projectAnalysisError(response.error).code).toBe(
|
||||
"invalid_request",
|
||||
);
|
||||
}
|
||||
});
|
||||
it("rejects source symlinks even when bytes have the expected digest", async () => {
|
||||
const f = await fixture();
|
||||
const real = join(f.root, "original.js");
|
||||
await rename(f.sourcePath, real);
|
||||
await symlink(real, f.sourcePath);
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: f.manifestPath,
|
||||
script_index: 0,
|
||||
});
|
||||
expect(response.ok).toBe(false);
|
||||
});
|
||||
it("rejects directory symlinks and traversal in selected manifest paths", async () => {
|
||||
const f = await fixture();
|
||||
const original = dirname(f.sourcePath);
|
||||
const moved = join(f.root, "moved");
|
||||
await rename(original, moved);
|
||||
await symlink(moved, original, "dir");
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: f.manifestPath,
|
||||
script_index: 0,
|
||||
});
|
||||
expect(response.ok).toBe(false);
|
||||
const manifest: unknown = JSON.parse(
|
||||
await readFile(f.manifestPath, "utf8"),
|
||||
);
|
||||
const result = webScriptExportResultSchema
|
||||
.omit({ manifest: true })
|
||||
.parse(manifest);
|
||||
const selected = result.scripts[0];
|
||||
if (selected?.content.state !== "exported")
|
||||
throw new Error("fixture source missing");
|
||||
selected.content.relative_path = "../capture.json";
|
||||
await writeFile(f.manifestPath, JSON.stringify(result));
|
||||
const escaped = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: f.manifestPath,
|
||||
script_index: 0,
|
||||
});
|
||||
expect(escaped.ok).toBe(false);
|
||||
});
|
||||
it("reads only an explicit local map and rejects non-object JSON", async () => {
|
||||
const f = await fixture();
|
||||
const map = join(f.root, "map.json");
|
||||
await writeFile(map, '{"imports":{"blocked":null}}');
|
||||
const input = {
|
||||
manifest_path: f.manifestPath,
|
||||
script_index: 0,
|
||||
import_map: { path: map, base_url: "https://map.test/base/" },
|
||||
};
|
||||
const response = await new LocalWebModuleArtifacts().load(input);
|
||||
if (!response.ok) throw response.error;
|
||||
expect(response.value.importMap?.value).toEqual({
|
||||
imports: { blocked: null },
|
||||
});
|
||||
await writeFile(map, "[]");
|
||||
expect((await new LocalWebModuleArtifacts().load(input)).ok).toBe(false);
|
||||
});
|
||||
it("preserves missing filesystem diagnostics separately from malformed input", async () => {
|
||||
const root = await createTestTempDirectory("rea-module-missing-");
|
||||
await mkdir(join(root, "directory"));
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: join(root, "missing.json"),
|
||||
script_index: 0,
|
||||
});
|
||||
if (response.ok) throw new Error("expected missing file");
|
||||
expect(response.error).toMatchObject({
|
||||
_tag: "ArtifactOperationError",
|
||||
reason: "io",
|
||||
});
|
||||
expect(projectAnalysisError(response.error).details?.detail).toContain(
|
||||
"ENOENT",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { WebModuleTraceService } from "../../../src/application/WebModuleTraceService.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import {
|
||||
webModuleArtifactsFixture,
|
||||
webModuleResolverFixture,
|
||||
} from "../../fixtures/webModuleTrace.js";
|
||||
|
||||
it("publishes valid SDK schemas and records the named module trace without a binary target", async () => {
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: () => Promise.resolve(ok(webModuleArtifactsFixture())) },
|
||||
webModuleResolverFixture,
|
||||
);
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("binary provider must not start");
|
||||
});
|
||||
const server = createServer(session, session, { webModuleTrace: service });
|
||||
const client = new Client({ name: "module-contract", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "trace_web_module_imports",
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error("module trace must advertise both schemas");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
const args = { manifest_path: "/analysis/manifest.json", script_index: 0 };
|
||||
expect(ajv.validate(inputSchema, args)).toBe(true);
|
||||
const response = await client.callTool({
|
||||
name: "trace_web_module_imports",
|
||||
arguments: args,
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract("trace_web_module_imports").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
const evidence = parseEvidence(parsed.evidence);
|
||||
expect(parsed.result).toEqual(evidence.normalized_result);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
const invalid = await client.callTool({
|
||||
name: "trace_web_module_imports",
|
||||
arguments: { ...args, manifest_path: "relative.json" },
|
||||
});
|
||||
expect(invalid.isError).toBe(true);
|
||||
});
|
||||
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
WebModuleArtifacts,
|
||||
WebModuleResolutionPort,
|
||||
} from "../../src/application/WebModulePorts.js";
|
||||
import { webScriptExportManifestSchema } from "../../src/domain/webScriptExport.js";
|
||||
import { ok } from "../../src/domain/result.js";
|
||||
|
||||
/** Pure source-owned artifact fixture; no runtime loading is asserted. */
|
||||
export const webModuleArtifactsFixture = (
|
||||
source = 'import "./dep.js"; import(name);',
|
||||
): WebModuleArtifacts => {
|
||||
const path = "/analysis/modules/main.js";
|
||||
const sha256 = createHash("sha256").update(source).digest("hex");
|
||||
const bytes = Buffer.byteLength(source);
|
||||
const manifest = webScriptExportManifestSchema.parse({
|
||||
capture_path: "/capture.json",
|
||||
capture_sha256: "a".repeat(64),
|
||||
capture_kind: "browser-scenario",
|
||||
source_evidence_id: null,
|
||||
capture_completeness: {
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
missing_sections: [],
|
||||
truncated_sections: [],
|
||||
},
|
||||
output_directory: "/analysis",
|
||||
analysis_input: { input_path: "/analysis", format: "directory" },
|
||||
limitations: [],
|
||||
scripts: [
|
||||
{
|
||||
url: "https://app.test/main.js?build=1#entry",
|
||||
source: {
|
||||
kind: "scenario-response",
|
||||
transaction_id: "txn",
|
||||
request_sequence: 1,
|
||||
response_sequence: 2,
|
||||
status: 200,
|
||||
},
|
||||
content: {
|
||||
state: "exported",
|
||||
relative_path: "modules/main.js",
|
||||
layout: "isolated",
|
||||
layout_reason: "fixture",
|
||||
sha256,
|
||||
bytes,
|
||||
media_type: "text/javascript",
|
||||
redacted: false,
|
||||
representation: "browser-decoded-response-bytes",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
source,
|
||||
manifest,
|
||||
manifestFile: {
|
||||
path: "/analysis/manifest.json",
|
||||
sha256: "b".repeat(64),
|
||||
bytes: 100,
|
||||
},
|
||||
sourceFile: { path, sha256, bytes },
|
||||
importMap: null,
|
||||
};
|
||||
};
|
||||
|
||||
/** Deterministic port seam for orchestration/schema tests, not native engine proof. */
|
||||
export const webModuleResolverFixture: WebModuleResolutionPort = {
|
||||
resolve: (input) =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
engine: { id: "fixture", version: "1" },
|
||||
resolutions: input.specifiers.map((specifier) => ({
|
||||
state: "resolved",
|
||||
url: new URL(specifier, input.importerUrl).href,
|
||||
})),
|
||||
diagnostics: [],
|
||||
rawResult: { fixture: true },
|
||||
}),
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user