Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33236ec08 | ||
|
|
fc58fa1eb0 | ||
|
|
49887f0239 | ||
|
|
4a6073810a | ||
|
|
fc4a9a0592 | ||
|
|
abd7a5ed09 | ||
|
|
58381eefa2 | ||
|
|
ebb065e2dc | ||
|
|
2cedde9f1c | ||
|
|
248972f327 | ||
|
|
5047373006 | ||
|
|
7dc40d3d9e | ||
|
|
93aa9eb5c2 | ||
|
|
d76f7d1508 | ||
|
|
9f496ef2d4 | ||
|
|
5065fcd520 | ||
|
|
0a4caf4a0a | ||
|
|
98b2b027af | ||
|
|
8aaf9a1978 | ||
|
|
d5c43d65f0 | ||
|
|
a45fe86a39 | ||
|
|
71ac83f3dd | ||
|
|
fb3749aff6 | ||
|
|
f26c8b5b8c | ||
|
|
ea4db4c9f7 | ||
|
|
f8d84da127 | ||
|
|
33d4761cc7 | ||
|
|
b5afa5f91e | ||
|
|
ef1a0cc65a | ||
|
|
a6ba772c08 |
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, "release/*"]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -311,7 +311,9 @@ jobs:
|
||||
- name: Real binary metadata and Interface Builder integration
|
||||
run: npm run verify:apple-dispatch && npm run verify:interface-builder
|
||||
- name: Native plist CLI selection regressions
|
||||
run: npm run test:focused -- tests/boundary/cli/nativePlistCli.test.ts
|
||||
run: npm run test:focused -- tests/boundary/cli/nativePlistCli.test.ts tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts tests/boundary/cli/nativePlistSelectionFailureCli.test.ts
|
||||
- name: Native Swift demangling CLI and MCP regressions
|
||||
run: npm run test:focused -- tests/boundary/cli/nativeDemangleCli.test.ts tests/boundary/mcp/nativeDemangleMcp.test.ts
|
||||
|
||||
reconstruction-readiness:
|
||||
name: Reconstruction readiness
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Real website runtime attribution
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-web-runtime.yml"
|
||||
- "scripts/verify-browser-runtime.mjs"
|
||||
- "scripts/lib/browser-runtime-*.mjs"
|
||||
- "src/process/ProviderProcess.ts"
|
||||
- "src/process/ProcessOwnership*.ts"
|
||||
- "scripts/fixtures/browser-runtime-site.mjs"
|
||||
- "src/browser/execution/**"
|
||||
- "src/browser/CdpConnection.ts"
|
||||
- "src/browser/CdpEndpoint.ts"
|
||||
- "src/browser/CdpTarget*.ts"
|
||||
- "src/browser/CdpCaptureValues.ts"
|
||||
- "src/browser/CdpAuthorized*.ts"
|
||||
- "src/domain/webRuntime.ts"
|
||||
- "src/domain/webExecution.ts"
|
||||
- "src/domain/webEventListeners.ts"
|
||||
- "src/domain/browserObservationError*.ts"
|
||||
- "src/application/WebRuntime*.ts"
|
||||
- "src/composition/webRuntime.ts"
|
||||
- "src/contracts/webRuntimeToolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/server/registerWebRuntimeTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli/webRuntimeCommands.ts"
|
||||
- "src/cli/commandCancellation.ts"
|
||||
- "src/cli.ts"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: real-web-runtime-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
HUSKY: "0"
|
||||
jobs:
|
||||
real-web-runtime:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run verify:browser:runtime
|
||||
env:
|
||||
REA_BROWSER_EXECUTABLE: /usr/bin/google-chrome
|
||||
REA_BROWSER_NO_SANDBOX: "true"
|
||||
@@ -2,7 +2,8 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ["release/*"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
@@ -21,13 +22,47 @@ jobs:
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
steps:
|
||||
- name: Validate release selection
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" != "refs/heads/${RELEASE_BRANCH}" || "${RELEASE_BRANCH}" != release/* ]]; then
|
||||
echo "Select a release/ branch; main and tags are not release checkpoints." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git check-ref-format "refs/heads/${RELEASE_BRANCH}"; then
|
||||
echo "Invalid release branch: ${RELEASE_BRANCH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
selected_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BRANCH}" --jq '.object.sha')"
|
||||
if [[ "${selected_sha}" != "${GITHUB_SHA}" ]]; then
|
||||
echo "The release branch moved after this run was triggered; review its new checkpoint before publication." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Selected release checkpoint: %s\n' "${selected_sha}"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
# A dedicated credential lets release-PR updates trigger CI. GitHub
|
||||
# requires manual approval for pull_request events from GITHUB_TOKEN.
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
target-branch: ${{ github.ref_name }}
|
||||
|
||||
- name: Bind publication to the triggering checkpoint
|
||||
if: steps.release.outputs.release_created == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_SHA: ${{ steps.release.outputs.sha }}
|
||||
run: |
|
||||
if [[ "${RELEASE_SHA}" != "${GITHUB_SHA}" ]]; then
|
||||
echo "The tagged release SHA differs from the triggering checkpoint; refusing to publish mismatched source provenance." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check out release pull request
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
@@ -99,6 +134,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -164,7 +202,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
ref: ${{ needs.release-please.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify package and registry metadata versions
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- "website/**"
|
||||
- "scripts/verify-website.py"
|
||||
- "scripts/prepare-website.py"
|
||||
- ".github/workflows/**"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,5 +25,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Prepare the example download
|
||||
run: python3 scripts/prepare-website.py
|
||||
- name: Check website links and publishing configuration
|
||||
run: python3 scripts/verify-website.py
|
||||
|
||||
@@ -21,6 +21,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Prepare the example download
|
||||
run: python3 scripts/prepare-website.py
|
||||
- name: Check website links and publishing configuration
|
||||
run: python3 scripts/verify-website.py
|
||||
- name: Configure Pages
|
||||
|
||||
@@ -45,3 +45,6 @@ Thumbs.db
|
||||
|
||||
# Local reference sources and analysis artifacts
|
||||
/_reference/
|
||||
|
||||
# Generated website download
|
||||
/website/public/examples/notes-example.zip
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "4.1.0"
|
||||
".": "5.0.0"
|
||||
}
|
||||
|
||||
+259
@@ -1,5 +1,264 @@
|
||||
# Changelog
|
||||
|
||||
## [5.0.0](https://github.com/morluto/rea/compare/rea-agents-4.1.0...rea-agents-5.0.0) (2026-10-07)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **native:** preserve incomplete native UI captures ([#991](https://github.com/morluto/rea/issues/991))
|
||||
* **android:** Android analysis now requires a full JDK 17 or newer with its compiler module for the packaged Java source bridge.
|
||||
|
||||
### Features
|
||||
|
||||
* **apple:** project macOS bundle anatomy in application graphs ([88c940b](https://github.com/morluto/rea/commit/88c940b165d788a491e212ea93e00462c4dde6a2))
|
||||
* **apple:** project macOS bundle anatomy in application graphs ([dd3e7ae](https://github.com/morluto/rea/commit/dd3e7aef0d52c478f6cdf475501c42ebe7f5c97e))
|
||||
* **application:** wire Android and Apple inventory graph tools ([#852](https://github.com/morluto/rea/issues/852)) ([e0fd820](https://github.com/morluto/rea/commit/e0fd8207a1fb738bcc34f204b55c4fa154f5f02d))
|
||||
* **browser:** attribute native execution and listener sources ([#917](https://github.com/morluto/rea/issues/917)) ([b5afa5f](https://github.com/morluto/rea/commit/b5afa5f91eed9ae31d670aeab9bbf5112d790ec2))
|
||||
* **browser:** capture correlated network content and lifecycle evidence ([#797](https://github.com/morluto/rea/issues/797)) ([f9eaf05](https://github.com/morluto/rea/commit/f9eaf056ef58215094513176a70fabae0740081f))
|
||||
* **browser:** trace captured website module imports ([#879](https://github.com/morluto/rea/issues/879)) ([01be082](https://github.com/morluto/rea/commit/01be082e9e11c78b91828c06a9c543c754783c07))
|
||||
* **browser:** trace captured website source-map locations ([#897](https://github.com/morluto/rea/issues/897)) ([f430c41](https://github.com/morluto/rea/commit/f430c416c72d444fbe521e0263f8935da4cdb040))
|
||||
* **javascript:** recover readable modules with Wakaru ([#848](https://github.com/morluto/rea/issues/848)) ([166dcd2](https://github.com/morluto/rea/commit/166dcd21d06f687a9c480a6a6d36beb3b4954dd0))
|
||||
* **native:** decode chained fixups, binds, ObjC categories and properties ([a657557](https://github.com/morluto/rea/commit/a65755728f7a10d984514c75fa863b84ddcdaee9))
|
||||
* **native:** decode chained fixups, binds, ObjC categories and properties ([3e24a72](https://github.com/morluto/rea/commit/3e24a72465fe6c628db1f836e93d74d7f4a92b80))
|
||||
* **native:** open iOS-style and iOS-on-Mac wrapper app bundles ([2ed89e6](https://github.com/morluto/rea/commit/2ed89e6529924248d2e21084f8730bedbb16bc3f))
|
||||
* **native:** open iOS-style and iOS-on-Mac wrapper app bundles ([aeebba3](https://github.com/morluto/rea/commit/aeebba391ee5c288b75cd828e7d18d32b40aa698))
|
||||
* **web:** export captured scripts for JavaScript application analysis ([#801](https://github.com/morluto/rea/issues/801)) ([472b72a](https://github.com/morluto/rea/commit/472b72a53068a8e2ec78fe239d687023482daaa7))
|
||||
* **website:** add a shared back-to-top link ([5047373](https://github.com/morluto/rea/commit/504737300632d8644fbd6c658d2c97e38bb54c3b))
|
||||
* **website:** introduce REA and the DX-Ball reconstruction ([#906](https://github.com/morluto/rea/issues/906)) ([4e3953c](https://github.com/morluto/rea/commit/4e3953cc45ce9c21786a7a6fc495d2350aa85d24))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review nits from batch integration follow-ups ([#818](https://github.com/morluto/rea/issues/818)) ([5a0e0ba](https://github.com/morluto/rea/commit/5a0e0ba3b81ea0ec336addb07da98ec7bc0a35d3))
|
||||
* **android:** align runtime suffix inference ([#977](https://github.com/morluto/rea/issues/977)) ([fb3749a](https://github.com/morluto/rea/commit/fb3749aff692a7a8225dff00a4516b48a76866b6))
|
||||
* **android:** bound fragmented JADX MCP frame memory ([#937](https://github.com/morluto/rea/issues/937)) ([9e39795](https://github.com/morluto/rea/commit/9e397958951edb543134e3ad57e7ab253d6df21a))
|
||||
* **android:** preserve JVM settings and reuse metadata sessions ([#908](https://github.com/morluto/rea/issues/908)) ([eab34a9](https://github.com/morluto/rea/commit/eab34a9d8c5eebad6b04b85750be0276d679c3f9))
|
||||
* **apple:** attribute only application-root components in macOS inventories ([5524970](https://github.com/morluto/rea/commit/55249704c5c83cbf8d20ac7869863cc196da00a6))
|
||||
* **apple:** count only real framework version directories ([2e81e86](https://github.com/morluto/rea/commit/2e81e867b24da8314f666f62d154de5803040cdf))
|
||||
* **apple:** pair bridge hypotheses within one application root ([7acf4e7](https://github.com/morluto/rea/commit/7acf4e741a6a314ea7aabb18fc757e196158ff76))
|
||||
* **apple:** pair bridge hypotheses within one application root ([9226ade](https://github.com/morluto/rea/commit/9226ade5114f287eb8e1b00b08dd91da1cc66003))
|
||||
* **apple:** traverse keyed archive hierarchy collections ([#976](https://github.com/morluto/rea/issues/976)) ([71ac83f](https://github.com/morluto/rea/commit/71ac83f3dd376135098f460c86e7897183456ec5))
|
||||
* **artifacts:** accept APFS image disks ejected with their container ([d93ebec](https://github.com/morluto/rea/commit/d93ebec9df951d40e66ced667f07fadd2dd5d25a))
|
||||
* **artifacts:** accept APFS image disks ejected with their container ([b72c079](https://github.com/morluto/rea/commit/b72c079a9a90c374bd6dd30ff8c4387306b8f59b))
|
||||
* **artifacts:** accept the bundle subject when extracting an app bundle ([#891](https://github.com/morluto/rea/issues/891)) ([37d2eba](https://github.com/morluto/rea/commit/37d2ebac4c2a524eec6e32c4c57cbbb632491ad8))
|
||||
* **artifacts:** decode flat keyed-archive NIBs with data and shared references ([b3f013f](https://github.com/morluto/rea/commit/b3f013fa183e8351319a053575aeee5df337c276))
|
||||
* **artifacts:** decode flat keyed-archive NIBs with data and shared references ([2a4667b](https://github.com/morluto/rea/commit/2a4667bb79117b202abb009c0bb9ebfdfde57158))
|
||||
* **artifacts:** harden reader lifecycle boundaries ([#951](https://github.com/morluto/rea/issues/951)) ([2366bda](https://github.com/morluto/rea/commit/2366bda37b1084016d42a0d06920dc57e7c7723f))
|
||||
* **artifacts:** keep the post-snapshot detach check out of provenance ([62634e4](https://github.com/morluto/rea/commit/62634e4c0177bde8a08decc356da98fbe25d2000))
|
||||
* **artifacts:** preserve reader failure diagnostics ([#987](https://github.com/morluto/rea/issues/987)) ([fc4a9a0](https://github.com/morluto/rea/commit/fc4a9a05922ce8474fd0717bc800343b0a95bd41))
|
||||
* **artifacts:** project compiled AppKit actions from the control to its target ([b08228d](https://github.com/morluto/rea/commit/b08228d19f8a0f6dbc940e8d322ef2bb42d2ce1a))
|
||||
* **artifacts:** project compiled AppKit actions from the control to its target ([#905](https://github.com/morluto/rea/issues/905)) ([900d7c8](https://github.com/morluto/rea/commit/900d7c8606cbc1ccbca818827a55bf2eab28bca4))
|
||||
* **artifacts:** project UIKit runtime outlet and event connections ([aa8082e](https://github.com/morluto/rea/commit/aa8082ea6c0898feb4d842135aea6b9539f769f8))
|
||||
* **artifacts:** project UIKit runtime outlet and event connections ([8b24387](https://github.com/morluto/rea/commit/8b243877c22a5550d005e7b8abf93791ce8566b9))
|
||||
* **artifacts:** read AppKit connectors from flat keyed-archive NIBs ([#900](https://github.com/morluto/rea/issues/900)) ([27c272b](https://github.com/morluto/rea/commit/27c272bd9a42e7235c7991881196c60dbc126714))
|
||||
* **artifacts:** read keyed archive dictionaries keyed UID as dictionaries ([cfd2b0c](https://github.com/morluto/rea/commit/cfd2b0c75dfb9d51b936788a46d0338145803ece))
|
||||
* **artifacts:** read keyed archive dictionaries keyed UID as dictionaries ([35c82b9](https://github.com/morluto/rea/commit/35c82b93eb2579687bb446c3a8a2a9f1a7563d28))
|
||||
* **artifacts:** report which path constraint failed ([#847](https://github.com/morluto/rea/issues/847)) ([3f1a77f](https://github.com/morluto/rea/commit/3f1a77f68ec4355d8b792ccb2970bc03485889c9))
|
||||
* **browser:** distinguish computed call keys from identifiers ([#808](https://github.com/morluto/rea/issues/808)) ([02beb74](https://github.com/morluto/rea/commit/02beb745358f02bdaabed4bb523c5e3527a9841e))
|
||||
* **browser:** distinguish WebMCP registration frame owners ([#813](https://github.com/morluto/rea/issues/813)) ([b3b44d8](https://github.com/morluto/rea/commit/b3b44d8a2d8b2aa70a8ed8b9cddf365865fcc53f))
|
||||
* **browser:** fingerprint cookies at the selected page URL ([#763](https://github.com/morluto/rea/issues/763)) ([78014e3](https://github.com/morluto/rea/commit/78014e3d149f762c848581a0aac7e44f75d51225))
|
||||
* **browser:** identify NodeNext TypeScript original source artifacts ([#811](https://github.com/morluto/rea/issues/811)) ([e84ad86](https://github.com/morluto/rea/commit/e84ad86a7578fb46568663aca6fe6a6f3986cc8d))
|
||||
* **browser:** preserve evidence through deep AST analysis ([#924](https://github.com/morluto/rea/issues/924)) ([f826aa8](https://github.com/morluto/rea/commit/f826aa893fa7d55e87d907f0c94c39421a166dd8))
|
||||
* **browser:** preserve form destinations and satisfies mutation uncertainty ([#825](https://github.com/morluto/rea/issues/825)) ([6dd719e](https://github.com/morluto/rea/commit/6dd719eaf605b37cd5dad6d01d33527ca9d59f06)), closes [#820](https://github.com/morluto/rea/issues/820)
|
||||
* **browser:** preserve Link header value delimiters ([#805](https://github.com/morluto/rea/issues/805)) ([dd36405](https://github.com/morluto/rea/commit/dd3640548fe3d1e8b92784a63fac55d757dcc1f0))
|
||||
* **build:** validate installed dependencies before cache hits ([#982](https://github.com/morluto/rea/issues/982)) ([a45fe86](https://github.com/morluto/rea/commit/a45fe86a39b9e7a7c21e3665b6de4763cb72d782))
|
||||
* capture IndexedDB object-store record fingerprints ([#809](https://github.com/morluto/rea/issues/809)) ([21a8bd0](https://github.com/morluto/rea/commit/21a8bd0ccde2326c8bc022714a88a438d8b89dde))
|
||||
* **cli:** align structured output guard with native flags ([#777](https://github.com/morluto/rea/issues/777)) ([e9fac56](https://github.com/morluto/rea/commit/e9fac56232e74c8287863ca5c4cc7d8e39fb4a40))
|
||||
* **cli:** emit empty structured filter projections ([#914](https://github.com/morluto/rea/issues/914)) ([2db668f](https://github.com/morluto/rea/commit/2db668f6ca91d2514a354fd459239b84031812e7))
|
||||
* **cli:** honor the selected native plist path ([#773](https://github.com/morluto/rea/issues/773)) ([0374865](https://github.com/morluto/rea/commit/0374865836aa4960657fb92f588c71e35dd3bed8))
|
||||
* **cli:** preserve output and comparison input validation ([#981](https://github.com/morluto/rea/issues/981)) ([d5c43d6](https://github.com/morluto/rea/commit/d5c43d65f0f6a1de8fb2eb5f2c1940a6ea2af20c))
|
||||
* **cli:** preserve unsupported historical-source imports ([#795](https://github.com/morluto/rea/issues/795)) ([911d580](https://github.com/morluto/rea/commit/911d58063477912042da5e9e573a9e8008956622))
|
||||
* **contracts:** align CLI/MCP boundary contracts ([#946](https://github.com/morluto/rea/issues/946)) ([4a60738](https://github.com/morluto/rea/commit/4a6073810a955d058c10f209157cbd68832a92c5))
|
||||
* **deps:** remediate smol-toml advisories ([93091ab](https://github.com/morluto/rea/commit/93091abb4dbd3cceee217d179db816895df24d70))
|
||||
* **deps:** remediate smol-toml advisories ([c55ae3b](https://github.com/morluto/rea/commit/c55ae3b9eccc17e6e8f4369e13044995d0650704))
|
||||
* **dotnet:** compare members with undecoded signatures by exact identity ([28fd49f](https://github.com/morluto/rea/commit/28fd49f0f6cf54e373411f5999e6b5722dd826f5))
|
||||
* **dotnet:** compare members with undecoded signatures by exact identity ([d52af3b](https://github.com/morluto/rea/commit/d52af3b321a321964598fb302edeb26552c9dffd))
|
||||
* **dotnet:** inspect native boundaries of non-managed and malformed PEs ([d5f28fb](https://github.com/morluto/rea/commit/d5f28fb29c5c3e8acef940fcae042e660e3a4d72))
|
||||
* **dotnet:** inspect native boundaries of non-managed and malformed PEs ([d98dcfa](https://github.com/morluto/rea/commit/d98dcfa8c2e46a7cc918aeb01058c26c4a84060c))
|
||||
* **dotnet:** keep a decoded member unknown beside an undecoded counterpart ([76b7020](https://github.com/morluto/rea/commit/76b70203d941ab85c1772f330407fd832d438c9c))
|
||||
* **dotnet:** keep admitted CLI header facts when metadata is unreadable ([e5ba693](https://github.com/morluto/rea/commit/e5ba6934e4ff819c09077ca70bd2417d37f85bc0))
|
||||
* **dotnet:** name the shared key in exact-signature ambiguities ([7714e76](https://github.com/morluto/rea/commit/7714e763d9ac2e87994068b3dee37ca6c5ffc6f4))
|
||||
* **dotnet:** treat ambiguous undecoded members as possible counterparts ([73370e1](https://github.com/morluto/rea/commit/73370e1c064899753988581c9a821e0e7f579561))
|
||||
* **errors:** report union input issues at the union's path ([bddeb63](https://github.com/morluto/rea/commit/bddeb634b583cf4f7d5fa6cf3a1ee821e05d2923))
|
||||
* **evaluation:** canonicalize Unicode argument keys ([#980](https://github.com/morluto/rea/issues/980)) ([0a4caf4](https://github.com/morluto/rea/commit/0a4caf4a0a1355b6a121fc88886b22e55843ac76))
|
||||
* **firmware:** accept the verified Binwalk and Unblob release lines ([#844](https://github.com/morluto/rea/issues/844)) ([bef4321](https://github.com/morluto/rea/commit/bef4321afb3d0d480e8a04ad7e256663542caa3d))
|
||||
* **firmware:** retain multi-file extraction failures ([#978](https://github.com/morluto/rea/issues/978)) ([8aaf9a1](https://github.com/morluto/rea/commit/8aaf9a19788beb6612332505850ad3407cb00027))
|
||||
* **ghidra:** admit the 12.1 line and the install's JDK range ([#840](https://github.com/morluto/rea/issues/840)) ([5cbf06c](https://github.com/morluto/rea/commit/5cbf06c7ad0ace599721bffd7da566b8a790b622))
|
||||
* **ghidra:** select exact packaged bridge scripts ([#800](https://github.com/morluto/rea/issues/800)) ([894c30e](https://github.com/morluto/rea/commit/894c30ef02d946f0c8de3ae6c9168d9525d95033))
|
||||
* **ida:** share concurrent session cleanup ([#950](https://github.com/morluto/rea/issues/950)) ([f14b6a8](https://github.com/morluto/rea/commit/f14b6a84306116716e78c5ffc84353ac07939593))
|
||||
* **installer:** validate exact semantic versions ([#954](https://github.com/morluto/rea/issues/954)) ([248972f](https://github.com/morluto/rea/commit/248972f327776687037dc07099117584c0674e77))
|
||||
* integrate 24 reviewed browser/native/semantics corrections ([#815](https://github.com/morluto/rea/issues/815)) ([3af3811](https://github.com/morluto/rea/commit/3af3811ab0a212b6aab6185628d47ecad37958fc))
|
||||
* **javascript:** advertise the exact empty seed rule and match empty API keys ([35e5508](https://github.com/morluto/rea/commit/35e5508fd095a062d7477bd6bb7d2d8e28201cd8))
|
||||
* **javascript:** analyze source maps with empty source names ([58ef8ac](https://github.com/morluto/rea/commit/58ef8ac3aa803d7b64dd12a5b2a2e8ec24261060))
|
||||
* **javascript:** analyze sources containing empty string literals ([e7cbfe0](https://github.com/morluto/rea/commit/e7cbfe04fdfd7affe50dba9e2c7a9a0a72291041))
|
||||
* **javascript:** analyze sources containing empty string literals ([11fc3b9](https://github.com/morluto/rea/commit/11fc3b981509e8107b10b2e75363f5bb489879db))
|
||||
* **javascript:** filter keyed-collection receivers only for get/delete ([00f707b](https://github.com/morluto/rea/commit/00f707b250451246d69a1205c39c6843fab6bb5b))
|
||||
* **javascript:** handle deep AST traversal and addition without stack overflow ([#915](https://github.com/morluto/rea/issues/915)) ([62b3711](https://github.com/morluto/rea/commit/62b37111e7f09887eb712c6e8e721e0334cf10b9))
|
||||
* **javascript:** handle deep static member paths ([#939](https://github.com/morluto/rea/issues/939)) ([348c65c](https://github.com/morluto/rea/commit/348c65c7ece1aee280f9431d995996b444e7549d))
|
||||
* **javascript:** keep bare relative URLs for computed-method XHR ([e32028d](https://github.com/morluto/rea/commit/e32028d1b566872ba8cd08fc6800c198d6aef30b))
|
||||
* **javascript:** keep computed-method XHR request URLs ([602d002](https://github.com/morluto/rea/commit/602d0021d45a6cdab9f806554f3ee12ea670ec97))
|
||||
* **javascript:** keep exact empty env keys and method names ([522c2d0](https://github.com/morluto/rea/commit/522c2d0ecb1eb7be262eaef2926cceac9a68badc))
|
||||
* **javascript:** keep exact empty env keys and method names ([f7954a8](https://github.com/morluto/rea/commit/f7954a8fd692f262ef3ce1507b957681150ce9c4)), closes [#783](https://github.com/morluto/rea/issues/783)
|
||||
* **javascript:** keep exact empty require paths and request fields ([ad1f6b5](https://github.com/morluto/rea/commit/ad1f6b508fbdd44d8bf1884ed96ae69082808e7d)), closes [#783](https://github.com/morluto/rea/issues/783)
|
||||
* **javascript:** keep exact empty values in graph identities ([f608127](https://github.com/morluto/rea/commit/f608127635ed8e0d105ad95252e5ea0dff899f5b))
|
||||
* **javascript:** keep exact empty values in graph identities ([63012ee](https://github.com/morluto/rea/commit/63012eeb601fc74ca82f3a027d852a5da1b099d2))
|
||||
* **javascript:** keep keyed-collection reads out of endpoints ([9075ce7](https://github.com/morluto/rea/commit/9075ce71ada4334407aa2d194dc82e3031e30260))
|
||||
* **javascript:** keep keyed-collection reads out of endpoints ([eb68761](https://github.com/morluto/rea/commit/eb68761eb0a35b876e33dd6115d0d07f97e99dd5))
|
||||
* **javascript:** keep literal HTTP methods on storage-named receivers ([9f48a76](https://github.com/morluto/rea/commit/9f48a76e1186cc43c6928eff1c82e93280caee1a))
|
||||
* **javascript:** keep literal-method and URL-literal requests on aliased receivers ([e3e7664](https://github.com/morluto/rea/commit/e3e76646657e950e24241ba715eb97ea385933e9))
|
||||
* **javascript:** keep mutated object initializer values unknown ([#752](https://github.com/morluto/rea/issues/752)) ([6caa38a](https://github.com/morluto/rea/commit/6caa38a0cad369ddff52b08f8d7d44c1b92ecac0))
|
||||
* **javascript:** keep storage open versions out of XHR endpoints ([4f51103](https://github.com/morluto/rea/commit/4f5110306d9edab94bf63bf81cf259324d96af57))
|
||||
* **javascript:** keep storage open versions out of XHR endpoints ([898b0f6](https://github.com/morluto/rea/commit/898b0f62d76ea1cf793140cb255cad16ab85b44d))
|
||||
* **javascript:** keep XHR and client endpoints that receiver spellings hid ([05f43ed](https://github.com/morluto/rea/commit/05f43ed388ae4e9316d320b592b6eba5b119ce89))
|
||||
* **javascript:** leave empty application values unlabeled ([527b76e](https://github.com/morluto/rea/commit/527b76ef78e84d29bb41bd45f1f83fae05ca66ca))
|
||||
* **javascript:** match exact empty literal channels and seeds ([e20da1f](https://github.com/morluto/rea/commit/e20da1f205b105b1b6a5decd3c2d0fe36fd12410))
|
||||
* **javascript:** never read window or document open as XHR ([ae849a7](https://github.com/morluto/rea/commit/ae849a72811857ba9755962ec48a1d76c9d1181a))
|
||||
* **javascript:** preserve computed Electron member uncertainty ([3a4a63f](https://github.com/morluto/rea/commit/3a4a63f70a2e42fb1fa48881e08b08d612289f2e))
|
||||
* **javascript:** preserve computed Electron member uncertainty ([1260cac](https://github.com/morluto/rea/commit/1260cacc67b8e0c93fb9434452053feca956e7e7))
|
||||
* **javascript:** project decoded file URL paths ([#810](https://github.com/morluto/rea/issues/810)) ([2572e1e](https://github.com/morluto/rea/commit/2572e1eb2f853a4cebfc8303d9e293e1d106d608))
|
||||
* **javascript:** read open() endpoints only from XHR method/URL pairs ([63e0822](https://github.com/morluto/rea/commit/63e0822cc794bd6ec1688340b3230ffb6b5c4a8c))
|
||||
* **javascript:** read open() endpoints only from XHR method/URL pairs ([1be3705](https://github.com/morluto/rea/commit/1be370543f7ea39016f86ebdec80a8ceaf7f8a28))
|
||||
* **javascript:** read substitution-free templates as exact strings ([c371d6e](https://github.com/morluto/rea/commit/c371d6e1e9cceb28e6cfc874e14c4e740bb87751))
|
||||
* **javascript:** read substitution-free templates as exact strings ([77cf757](https://github.com/morluto/rea/commit/77cf75710369acc6c304ec352ab25aa7274e04b1))
|
||||
* **javascript:** resolve bare source-map and worker URLs next to the script ([#890](https://github.com/morluto/rea/issues/890)) ([45fcd28](https://github.com/morluto/rea/commit/45fcd28b4018723bdb508a9229b74ab9eb749f75))
|
||||
* **javascript:** retain recovered call argument positions ([#929](https://github.com/morluto/rea/issues/929)) ([e6ba9c3](https://github.com/morluto/rea/commit/e6ba9c3af3053485f154825eea6a156127328be9))
|
||||
* **linux:** improve Hopper reliability and CachyOS support ([#963](https://github.com/morluto/rea/issues/963)) ([98b2b02](https://github.com/morluto/rea/commit/98b2b027afd8c80b68e9daaf97bdaa4d942105cc))
|
||||
* **mcp:** advertise reserved process environment key ([#949](https://github.com/morluto/rea/issues/949)) ([e0d76ae](https://github.com/morluto/rea/commit/e0d76ae80ddc70ae4d5afcbd1427afb81eee3669))
|
||||
* **mcp:** isolate optional observation adapter failures ([#838](https://github.com/morluto/rea/issues/838)) ([be3cfbf](https://github.com/morluto/rea/commit/be3cfbffdfbbb65da75dcec0518e2ebf387e66bc))
|
||||
* **mcp:** preserve exact guided prompt context ([#761](https://github.com/morluto/rea/issues/761)) ([66c26f2](https://github.com/morluto/rea/commit/66c26f280b1d494806ee182b52564166760f0ae2))
|
||||
* **native-ui:** handle incomplete accessibility child collections ([#944](https://github.com/morluto/rea/issues/944)) ([ac3bae3](https://github.com/morluto/rea/commit/ac3bae3cb97c4fb622eb1b6ac1f3b84e6a70ba03))
|
||||
* **native:** inspect plists containing data, dates, and non-finite reals ([bc2cd8b](https://github.com/morluto/rea/commit/bc2cd8b874e115eee446860043758a80bd583ad0))
|
||||
* **native:** inspect plists containing data, dates, and non-finite reals ([5ead2cf](https://github.com/morluto/rea/commit/5ead2cfbc47896b43502363d3568330f5d94762e))
|
||||
* **native:** keep echoed codesign paths out of signature fields ([#865](https://github.com/morluto/rea/issues/865)) ([4aa2b60](https://github.com/morluto/rea/commit/4aa2b60c1d84607a138395ae93260b2e50c2a721))
|
||||
* **native:** keep echoed Mach-O paths out of load commands and symbols ([ab985c6](https://github.com/morluto/rea/commit/ab985c6a10e9da856ed151100cee8459793c3573))
|
||||
* **native:** keep echoed Mach-O paths out of load commands and symbols ([37fe105](https://github.com/morluto/rea/commit/37fe105ba168488d05226f2912bed802e2d48d75))
|
||||
* **native:** keep integral plist reals beyond the JSON range as reals ([#860](https://github.com/morluto/rea/issues/860)) ([3b92a2b](https://github.com/morluto/rea/commit/3b92a2bceddaa1659654da08160813a3affbc6b7))
|
||||
* **native:** keep plist XML element types for large numbers ([f85056d](https://github.com/morluto/rea/commit/f85056d83e42a461c8c947792774580294d0ceb9))
|
||||
* **native:** keep the resolved bundle plist and permission failures ([418a556](https://github.com/morluto/rea/commit/418a5562f6f640503c751924a4787586c2c5c98a))
|
||||
* **native:** pass Swift symbols to swift-demangle as operands ([dd90965](https://github.com/morluto/rea/commit/dd90965058a9e6f7dd7f30eb7e462572e777db7f))
|
||||
* **native:** pass Swift symbols to swift-demangle as operands ([6c88094](https://github.com/morluto/rea/commit/6c88094da45258b346fe594764e561f999622a58))
|
||||
* **native:** preserve incomplete native UI captures ([#991](https://github.com/morluto/rea/issues/991)) ([9f496ef](https://github.com/morluto/rea/commit/9f496ef2d4a3aa68816287bf5d2ba1620d72c97a))
|
||||
* **native:** report exact plist integers beyond the JSON number range ([c9f49db](https://github.com/morluto/rea/commit/c9f49db907db0513ad8b22c9b2bbdcee400aff11))
|
||||
* **native:** report exact plist integers beyond the JSON number range ([430c297](https://github.com/morluto/rea/commit/430c297ca4ddb3491c2b835b889486e6a2ffdc17))
|
||||
* **native:** report plist permission denials as access failures ([5738a0c](https://github.com/morluto/rea/commit/5738a0cf8c111833847fecb2f7d0c2ee35eabbd6))
|
||||
* **native:** report why a selected plist cannot be inspected ([14f9b3d](https://github.com/morluto/rea/commit/14f9b3d93119a60a3cf5f3d9c138ca829a5462e8))
|
||||
* **native:** report why a selected plist cannot be inspected ([0674fac](https://github.com/morluto/rea/commit/0674facd468c3321584ece908031feee7220b9c6))
|
||||
* **plist:** keep character references beyond Unicode literal ([5afd8a8](https://github.com/morluto/rea/commit/5afd8a850b2ebd62e3400a5e2ece1a1cfa98bab6))
|
||||
* **plist:** read XML plists containing a __proto__ key ([faaee69](https://github.com/morluto/rea/commit/faaee693d5bb4bb957387ebd0f4338513c4c914a))
|
||||
* **plist:** read XML plists containing a __proto__ key ([a08be51](https://github.com/morluto/rea/commit/a08be51e9f0d23bd46b66bbdb88dbda44bc54c88))
|
||||
* **plist:** report omitted __proto__ entries in signatures and Interface Builder ([15ab5db](https://github.com/morluto/rea/commit/15ab5dbcaf3fac09756e98926373965247dad622))
|
||||
* **plist:** report omitted __proto__ entries in signatures and Interface Builder ([6729ee7](https://github.com/morluto/rea/commit/6729ee7636889a0a2ebc9fca8e35860476f8ca34))
|
||||
* preserve native UI failure reasons and dedupe version literals ([#854](https://github.com/morluto/rea/issues/854)) ([2d4a910](https://github.com/morluto/rea/commit/2d4a910fcd30e8d5b96fcb957b931e9aa3e4ab0c))
|
||||
* **process:** clarify Windows capture support and recovery ([#798](https://github.com/morluto/rea/issues/798)) ([50787b2](https://github.com/morluto/rea/commit/50787b2faf1a4bea799d041960ca2b91199ef403))
|
||||
* **process:** harden filesystem snapshot identity and cancellation ([#956](https://github.com/morluto/rea/issues/956)) ([93aa9eb](https://github.com/morluto/rea/commit/93aa9eb5c2551984182a4025d836e4223e8ecc8a))
|
||||
* **process:** retain provider output until streams close ([#928](https://github.com/morluto/rea/issues/928)) ([b929251](https://github.com/morluto/rea/commit/b9292516f4eedc2dedba713abf57f665ef1fb596))
|
||||
* **reference:** retain partial imports for unreadable directories ([#766](https://github.com/morluto/rea/issues/766)) ([cffc548](https://github.com/morluto/rea/commit/cffc548cd3127c5db811f20db4816039846b3170))
|
||||
* **release:** drive checkpoint releases from frozen branches ([fc58fa1](https://github.com/morluto/rea/commit/fc58fa1eb01fe5a8200a78e9551799c9c1a3d256))
|
||||
* **release:** publish from explicit source checkpoints ([49887f0](https://github.com/morluto/rea/commit/49887f0239da4483153a5e1610058619855525db))
|
||||
* **semantics:** isolate named function expression bindings ([#776](https://github.com/morluto/rea/issues/776)) ([ae81f64](https://github.com/morluto/rea/commit/ae81f64d2ad10e5cdbf8356819351c02480aee7a))
|
||||
* **semantics:** preserve direct promise ownership boundaries ([#758](https://github.com/morluto/rea/issues/758)) ([e1ca412](https://github.com/morluto/rea/commit/e1ca412757bb01be855a66bf72006e3becd497b6))
|
||||
* **semantics:** preserve empty string property keys ([#764](https://github.com/morluto/rea/issues/764)) ([630bdaf](https://github.com/morluto/rea/commit/630bdafcacc621105f6a43ed68a6e51651aca629))
|
||||
* **semantics:** recognize Node builtin default namespaces ([#769](https://github.com/morluto/rea/issues/769)) ([24a0bf9](https://github.com/morluto/rea/commit/24a0bf9a618604e2fa30817d673ce911c7842819))
|
||||
* **semantics:** retain trailing rest argument flows ([#756](https://github.com/morluto/rea/issues/756)) ([4bfd803](https://github.com/morluto/rea/commit/4bfd8038be0a60c43a452bec15e23799a0b040c0))
|
||||
* **setup:** accept the pinned Hopper RPM launcher ([#933](https://github.com/morluto/rea/issues/933)) ([dda8e6a](https://github.com/morluto/rea/commit/dda8e6a72890f87bdf8bbd3250b68b6648aae553))
|
||||
* **setup:** read an OpenCode V2 server named type as a table entry ([#866](https://github.com/morluto/rea/issues/866)) ([4e17be5](https://github.com/morluto/rea/commit/4e17be57053fb0ce2561d92e05b6e38ef789b2d4))
|
||||
* **setup:** register OpenCode V2 configurations in mcp.servers ([957069d](https://github.com/morluto/rea/commit/957069d14d832ddeac3f90dd141d379b81e2832a))
|
||||
* **setup:** register OpenCode V2 configurations in mcp.servers ([69d8afe](https://github.com/morluto/rea/commit/69d8afefc19e014a4bba7a182983a9e77e100a75))
|
||||
* **target:** decode app Info.plist executables with an XML parser ([c57f1b7](https://github.com/morluto/rea/commit/c57f1b74e0af5cf5fbb459cb247e1448cbf8df1b))
|
||||
* **target:** decode app Info.plist executables with an XML parser ([c182f72](https://github.com/morluto/rea/commit/c182f72ad3d9eea10b084a43527acb7c8ca2d6bf))
|
||||
* **test:** disconnect Hopper fixture IPC on SIGTERM ([b912b11](https://github.com/morluto/rea/commit/b912b117f019d5d3e4b4e3ef683e9b25f3a709bc))
|
||||
* **windows:** accept ordinary drive path separators ([#791](https://github.com/morluto/rea/issues/791)) ([b62931f](https://github.com/morluto/rea/commit/b62931f8335f58bfe344883a4f149e9eef73d835))
|
||||
* **windows:** remove cancelled snapshot outputs ([#957](https://github.com/morluto/rea/issues/957)) ([d76f7d1](https://github.com/morluto/rea/commit/d76f7d150895289aa1fb85a057b81ca7ff102df4))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **artifacts:** stream ASAR members without full buffering ([#962](https://github.com/morluto/rea/issues/962)) ([abd7a5e](https://github.com/morluto/rea/commit/abd7a5ed091061e4b1e83e5933e9009fb5b8d577))
|
||||
* **call-path:** prune dead ends and enumerate without recursion ([#909](https://github.com/morluto/rea/issues/909)) ([e52fe97](https://github.com/morluto/rea/commit/e52fe9719fec03f2aa79b1a1b9e11b38c229903c))
|
||||
* **cli:** defer catalog digests and use canonical cache scope ([9a15525](https://github.com/morluto/rea/commit/9a1552529889e9f5b52a88739eb767b92e57e24e))
|
||||
* **cli:** defer unused catalog work during command startup ([#920](https://github.com/morluto/rea/issues/920)) ([9a15525](https://github.com/morluto/rea/commit/9a1552529889e9f5b52a88739eb767b92e57e24e))
|
||||
* **comparison:** reuse canonical sort keys ([#990](https://github.com/morluto/rea/issues/990)) ([ebb065e](https://github.com/morluto/rea/commit/ebb065e2dc15f5763276f401521896f66bae6339))
|
||||
* **dotnet:** index declaring-type ranges once per metadata table ([#918](https://github.com/morluto/rea/issues/918)) ([7a9f587](https://github.com/morluto/rea/commit/7a9f587c3a9998d828b83388438976527ca7074f))
|
||||
* **dotnet:** index ordered declaring-type ranges once per table ([7a9f587](https://github.com/morluto/rea/commit/7a9f587c3a9998d828b83388438976527ca7074f))
|
||||
* **evidence:** scale imports and dependency graphs ([#941](https://github.com/morluto/rea/issues/941)) ([7dc40d3](https://github.com/morluto/rea/commit/7dc40d3d9e00caf013109942a5d9a1d7fdf9ec62))
|
||||
* **inspector:** bound runtime script location authorization ([#945](https://github.com/morluto/rea/issues/945)) ([58381ee](https://github.com/morluto/rea/commit/58381eefa24f73580e31eaa09dc14b9c04d78d6c))
|
||||
* **javascript:** analyze member chains without recursive copying ([#932](https://github.com/morluto/rea/issues/932)) ([dcde958](https://github.com/morluto/rea/commit/dcde9584508830a5d99ac5e2032f539563c69002))
|
||||
* **javascript:** index Promise return-site ownership ([#971](https://github.com/morluto/rea/issues/971)) ([2cedde9](https://github.com/morluto/rea/commit/2cedde9f1cf72b04bef37f9f004bb68d5ff6502a))
|
||||
* **javascript:** index semantic fingerprint relations ([#942](https://github.com/morluto/rea/issues/942)) ([c92a344](https://github.com/morluto/rea/commit/c92a344d4770125d85a11f3fc7a23d800bd30003))
|
||||
* **javascript:** reduce analysis and source-map work ([#955](https://github.com/morluto/rea/issues/955)) ([c7530f9](https://github.com/morluto/rea/commit/c7530f9a816d84354516d3ea137f4a0d91d3b8b3))
|
||||
* **providers:** assemble fragmented responses once per line ([#911](https://github.com/morluto/rea/issues/911)) ([df7151b](https://github.com/morluto/rea/commit/df7151ba305eab3a33a9261a023705b29862b308))
|
||||
* **reconstruction:** index coverage evaluation ([#983](https://github.com/morluto/rea/issues/983)) ([f26c8b5](https://github.com/morluto/rea/commit/f26c8b5b8cff10b93173359b5453874d659f2440))
|
||||
* **setup:** reuse initial diagnostics during planning ([#940](https://github.com/morluto/rea/issues/940)) ([118d43e](https://github.com/morluto/rea/commit/118d43e787e38857714852cc846b1b604a61950d))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* add incremental capability migration safeguards ([#819](https://github.com/morluto/rea/issues/819)) ([75f50d8](https://github.com/morluto/rea/commit/75f50d86f6d504f4dd97a707871d39ec022b3047))
|
||||
* **android:** group layered source and verification ([#873](https://github.com/morluto/rea/issues/873)) ([8e8921d](https://github.com/morluto/rea/commit/8e8921d40c63b40ab7e1598f672063dd304d93f0))
|
||||
* **apple:** group artifact producers and verification ([#952](https://github.com/morluto/rea/issues/952)) ([ef1a0cc](https://github.com/morluto/rea/commit/ef1a0cc65af7918ef7204a39f42a4299b486c453))
|
||||
* **artifacts:** isolate JavaScript file acquisition ([#916](https://github.com/morluto/rea/issues/916)) ([f0a68dc](https://github.com/morluto/rea/commit/f0a68dce60e81b4d2f420bf774b61957a37326ac))
|
||||
* **binary:** group session selection and snapshot ownership ([#886](https://github.com/morluto/rea/issues/886)) ([dd114d3](https://github.com/morluto/rea/commit/dd114d39801973e958a582c8fcba7ccce0e50299))
|
||||
* **binary:** move production composition outside application workflows ([#965](https://github.com/morluto/rea/issues/965)) ([33d4761](https://github.com/morluto/rea/commit/33d4761cc7a96630b7c2fed890df83d433c45914))
|
||||
* **browser:** align inspector exclusions with canonical vocabulary ([#834](https://github.com/morluto/rea/issues/834)) ([de9593a](https://github.com/morluto/rea/commit/de9593ad48c0b97507f18949b7ad86ad9c743ebc))
|
||||
* **cli:** split core analysis command registrar ([#858](https://github.com/morluto/rea/issues/858)) ([8b9940a](https://github.com/morluto/rea/commit/8b9940a7b5d3fcd5a8eda6d397f0ab7c3aaf2ac9))
|
||||
* colocate investigation record implementation ([#867](https://github.com/morluto/rea/issues/867)) ([bb19c94](https://github.com/morluto/rea/commit/bb19c9477ef3eefd9c5b2e6cabe78cc94e6779ae))
|
||||
* compose investigation record ownership ([#845](https://github.com/morluto/rea/issues/845)) ([ce73c3f](https://github.com/morluto/rea/commit/ce73c3fed2e5fd20ae0113579345a69763eb7655))
|
||||
* declare auxiliary capabilities from source ([#853](https://github.com/morluto/rea/issues/853)) ([4a6c17c](https://github.com/morluto/rea/commit/4a6c17ce83dfd20412f0e168525314a20c61169e))
|
||||
* **domain:** consolidate property-key extraction behind semanticStaticPropertyKey ([#823](https://github.com/morluto/rea/issues/823)) ([764ff05](https://github.com/morluto/rea/commit/764ff05231f1a666dfa655492da42b4b6ba742c3))
|
||||
* **domain:** converge comparison mechanics on shared vocabulary ([#824](https://github.com/morluto/rea/issues/824)) ([c13c9d4](https://github.com/morluto/rea/commit/c13c9d41f8441a711266adee21429fb7433a6aa1))
|
||||
* **domain:** extract artifact path syntax helpers ([#830](https://github.com/morluto/rea/issues/830)) ([ef535b8](https://github.com/morluto/rea/commit/ef535b81fefc358b0bdb655e5aa057fc3949ce5f))
|
||||
* **domain:** extract source-map envelope walk and disambiguate stringValue ([#828](https://github.com/morluto/rea/issues/828)) ([64ebdf4](https://github.com/morluto/rea/commit/64ebdf45016bda679674cbd2287576eadf66a1fc))
|
||||
* **domain:** map validation taxonomies and add inventory Result seam ([#833](https://github.com/morluto/rea/issues/833)) ([78f4848](https://github.com/morluto/rea/commit/78f4848d70ce462426e9f221d6e7b3e34219bf46))
|
||||
* **domain:** split Interface Builder keyed-archive parse from graph builder ([#859](https://github.com/morluto/rea/issues/859)) ([da09706](https://github.com/morluto/rea/commit/da0970672da725dbb095ffcaefad07303ae85aab))
|
||||
* **firmware:** group layered source and verification ([#878](https://github.com/morluto/rea/issues/878)) ([507e8b3](https://github.com/morluto/rea/commit/507e8b3c7be75f8de69be214618394aca3585404))
|
||||
* **ghidra:** extract createClient and load-image attestation ([#856](https://github.com/morluto/rea/issues/856)) ([b7df5b7](https://github.com/morluto/rea/commit/b7df5b78052539e57967db8e6562befd70c6ebdc))
|
||||
* give Inspector its own capability directory ([#869](https://github.com/morluto/rea/issues/869)) ([021ba69](https://github.com/morluto/rea/commit/021ba690b07eab4b990be47f90946db9f70e0fc0))
|
||||
* **javascript:** group application workflows and verification ([#896](https://github.com/morluto/rea/issues/896)) ([0804722](https://github.com/morluto/rea/commit/0804722cdf721a8d767d02f9342ebdf8d186f7e1))
|
||||
* **javascript:** group domain semantics and contracts ([#907](https://github.com/morluto/rea/issues/907)) ([f6a58f1](https://github.com/morluto/rea/commit/f6a58f10629614fb7a6970421218b73d41bce6a5))
|
||||
* **managed:** group capability workflows and verification ([#926](https://github.com/morluto/rea/issues/926)) ([28713f9](https://github.com/morluto/rea/commit/28713f9b4dcb882f0e811653890fde70cb5fdadb))
|
||||
* **native:** group analyst semantics workflows and contracts ([#973](https://github.com/morluto/rea/issues/973)) ([ea4db4c](https://github.com/morluto/rea/commit/ea4db4c9f7cbc10dc701c4d5770109c08952a5b8))
|
||||
* **native:** split Apple dispatch metadata decode facets ([#855](https://github.com/morluto/rea/issues/855)) ([676959e](https://github.com/morluto/rea/commit/676959ecb55b606a567af3d2eb3d3edc96f0aa2f))
|
||||
* **provider:** declare deep capabilities from source ([#892](https://github.com/morluto/rea/issues/892)) ([b808e2d](https://github.com/morluto/rea/commit/b808e2d6bbbae38c1ff2e293e1cf77464d889bdd))
|
||||
* share Android and firmware provider factories ([#826](https://github.com/morluto/rea/issues/826)) ([3ed59b2](https://github.com/morluto/rea/commit/3ed59b281247f2180b3c79e658078507e4cc1be2))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add concise lawful-use disclaimer ([#888](https://github.com/morluto/rea/issues/888)) ([4037c65](https://github.com/morluto/rea/commit/4037c6545dc8cc47f4602d8f06e5329ed641cdbb))
|
||||
* add DX-Ball reconstruction showcase ([#876](https://github.com/morluto/rea/issues/876)) ([895079d](https://github.com/morluto/rea/commit/895079dd093f02487789d9aa967f0cccbfe379d8))
|
||||
* add prominent website and showcase links to READMEs ([2ccc86c](https://github.com/morluto/rea/commit/2ccc86cd13d71f08b553d3b9962175144b882539))
|
||||
* add README star history chart ([#850](https://github.com/morluto/rea/issues/850)) ([11f27de](https://github.com/morluto/rea/commit/11f27deddf88703368942d3ddfc239a7ebe72e3e))
|
||||
* add website and showcase links to READMEs ([#930](https://github.com/morluto/rea/issues/930)) ([2ccc86c](https://github.com/morluto/rea/commit/2ccc86cd13d71f08b553d3b9962175144b882539))
|
||||
* **apple:** describe directory-based framework plist ambiguity ([6d74992](https://github.com/morluto/rea/commit/6d74992efed3271e8370c505afdde347609f8353))
|
||||
* audit and improve localized readmes translations and wording ([00a6ac3](https://github.com/morluto/rea/commit/00a6ac3de99f432bd6dbb88148a2aab5f8d3d338))
|
||||
* clarify NativeAOT scope and regenerate conformance ledger ([c945931](https://github.com/morluto/rea/commit/c945931961cbd488163c6d92ef91336ed506942e))
|
||||
* fix stale IDA roadmap item, mermaid paths, toolchain wording ([d2aed17](https://github.com/morluto/rea/commit/d2aed172ab79d98ff4e62c14d8f34cc49c1d17b4))
|
||||
* move quick start up and mark skill install optional ([554e9b1](https://github.com/morluto/rea/commit/554e9b1612f7799c2bc7177eb1a9b04a0f27d2e9))
|
||||
* note Cloud Agent Node.js toolchain ([#837](https://github.com/morluto/rea/issues/837)) ([6aa6350](https://github.com/morluto/rea/commit/6aa6350c800796d0953937e894ff176ae248e930))
|
||||
* publish VitePress documentation on GitHub Pages ([c78fe69](https://github.com/morluto/rea/commit/c78fe6990bcface6853ac5c11c90df6bc0df562d))
|
||||
* separate ambiguous and unavailable provider recovery ([c3f2b8e](https://github.com/morluto/rea/commit/c3f2b8e7742915dc27ab7c801a8a1a9127169896))
|
||||
* show task-scoped readiness and provider preference in the quick start ([880cca1](https://github.com/morluto/rea/commit/880cca1bca497bb53c97a821b470df0f2235e043))
|
||||
* show task-scoped readiness and provider preference in the quick start ([5fd0db3](https://github.com/morluto/rea/commit/5fd0db37d94f1c1a0495920c79e121d799da0d30)), closes [#726](https://github.com/morluto/rea/issues/726)
|
||||
* update release boundary to npm 4.1.0 with Windows native bundle ([69be514](https://github.com/morluto/rea/commit/69be514c67c8b2eb8b450355a9782d9237748fda))
|
||||
* **website:** add Notion case study and improve onboarding ([a6ba772](https://github.com/morluto/rea/commit/a6ba772c08e2648e6602787a0ea537d75d72802d))
|
||||
* **website:** add TH04 bullet-ring case study ([f8d84da](https://github.com/morluto/rea/commit/f8d84da127fad871050c098b004a503b36795ad0))
|
||||
* **website:** add worked guides and keep Pages publishing manual ([#925](https://github.com/morluto/rea/issues/925)) ([4f25ee3](https://github.com/morluto/rea/commit/4f25ee33dcd4195f8d877f8835f81c4e08c95235))
|
||||
* **website:** clarify guides and case studies ([5065fcd](https://github.com/morluto/rea/commit/5065fcd5205ec7c0d259131431c2cf54d9f69cf6))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* **artifacts:** report compiled connector records when a storyboard route is missing ([d421d3f](https://github.com/morluto/rea/commit/d421d3f286ce515b5126859a0f9cac60667a168e))
|
||||
* **artifacts:** report observed action routes when a compiled route is missing ([c60b737](https://github.com/morluto/rea/commit/c60b737f7db8e2c9a1982cdb4357fcda1f47ff08))
|
||||
* **artifacts:** write the storyboard fixture's action in Cocoa syntax ([53bfa7e](https://github.com/morluto/rea/commit/53bfa7e3f792cdb9f2e95fc954f3616426414bcc))
|
||||
* eliminate fixture races and prune redundant scaffolding ([#849](https://github.com/morluto/rea/issues/849)) ([f72931d](https://github.com/morluto/rea/commit/f72931d9dd54609bfca422750fc2667f80af691c))
|
||||
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
* consolidate macOS native baseline checks ([#935](https://github.com/morluto/rea/issues/935)) ([810fe53](https://github.com/morluto/rea/commit/810fe53e799b89dc215c52c9245d36fb2a44d9c4))
|
||||
* verify native arm64 and Intel macOS workflows ([#898](https://github.com/morluto/rea/issues/898)) ([4801a24](https://github.com/morluto/rea/commit/4801a24089964860db7f4855be21d2d4313c7eab))
|
||||
|
||||
## [4.1.0](https://github.com/morluto/rea/compare/rea-agents-4.0.1...rea-agents-4.1.0) (2026-10-06)
|
||||
|
||||
|
||||
|
||||
+11
-29
@@ -10,7 +10,7 @@ When adding or changing an MCP tool, follow the [tool design guide](docs/tool-de
|
||||
|
||||
## Development setup
|
||||
|
||||
REA development requires Node.js 24.18.x and npm 11.16.x (pinned toolchain via `nvm use`; the supported runtime range is Node.js ^22.19 || ^24.11 || >=26, as the README badge states). Real-Hopper verification additionally requires either macOS 12+ or an officially supported Linux host (Ubuntu 24.04+, Fedora 41+, or 64-bit Arch) and an installed Hopper application. Linux demo verification uses its own private Xvfb display and does not require a desktop session. Run `nvm use` before installing dependencies.
|
||||
REA development requires Node.js 24.18.x and npm 11.16.x (pinned toolchain via `nvm use`; the supported runtime range is Node.js ^22.19 || ^24.11 || >=26, as the README badge states). Real-Hopper verification additionally requires either macOS 12+ or an officially supported Linux host (Ubuntu 24.04+, Fedora 41+, 64-bit Arch, or CachyOS) and an installed Hopper application. Linux demo verification uses its own private Xvfb display and does not require a desktop session. Run `nvm use` before installing dependencies.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
@@ -120,32 +120,14 @@ Describe the behavior change and verification performed in the pull request. Nev
|
||||
|
||||
## Maintainer release checklist
|
||||
|
||||
Run `npm run check:pr`, the isolated package verifier, package dry run, and two-target real-Hopper verifier described above. Build a local tarball and exercise the executable through the package boundary:
|
||||
Use the [checkpoint release guide](docs/releasing.md). Releases start from an
|
||||
explicit `release/VERSION` branch cut at a recorded commit. Pushing the branch prepares
|
||||
the bot PR. Wait for its exact-head CI and review; merging into that frozen
|
||||
branch triggers publication through the official Release workflow. Main
|
||||
pushes do not update release PRs or publish packages. Both publishers build
|
||||
the exact SHA tagged by Release Please.
|
||||
|
||||
```bash
|
||||
npm pack
|
||||
```
|
||||
|
||||
Use the exact filename printed by `npm pack` to run the packaged executable:
|
||||
|
||||
```bash
|
||||
npm exec --yes --package ./rea-agents-VERSION.tgz -- rea --help
|
||||
```
|
||||
|
||||
Replace `VERSION` with the packed version; do not use a tarball from an earlier
|
||||
build.
|
||||
|
||||
Publish the public package:
|
||||
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
After npm registry propagation, verify the published CLI and connect the client SDK version pinned in `package.json` to the published server to confirm the canonical tool catalog:
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@latest --help
|
||||
npx -y rea-agents@latest doctor
|
||||
npx -y rea-agents@latest setup --yes --all-detected
|
||||
npx -y rea-agents@latest mcp
|
||||
```
|
||||
Keep new implementation commits on main for the next release. The workflow
|
||||
owns packaged-artifact verification, npm publication, the published CLI/MCP
|
||||
canary, and MCP Registry publication. Sync release metadata back to main after
|
||||
publication; see the guide for partial-publication recovery and verification.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
**[Website](https://morluto.github.io/rea/) · [Guides](https://morluto.github.io/rea/guides/) · [DX-Ball showcase](https://morluto.github.io/rea/showcase/dx-ball/)**
|
||||
**[Website](https://morluto.github.io/rea/) · [Guides](https://morluto.github.io/rea/guides/) · [Showcases](https://morluto.github.io/rea/showcase/)**
|
||||
|
||||
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [Tool catalog](#tool-catalog-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
|
||||
|
||||
@@ -157,7 +157,7 @@ external-tool prerequisites depend on the selected workflow; the native
|
||||
provider guides describe their supported platforms.
|
||||
|
||||
- macOS 12 or newer
|
||||
- Ubuntu 24.04+, Fedora 41+, or 64-bit Arch Linux
|
||||
- Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, or CachyOS
|
||||
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+
|
||||
- npm; REA does not require or install a particular npm version
|
||||
|
||||
@@ -183,7 +183,7 @@ On macOS, setup can install Hopper in `~/Applications` after approval. It verifi
|
||||
|
||||
On supported Linux distributions, setup can install Hopper and its demo-session dependencies through your system package manager. You may see a system authorization prompt. Demo sessions use a private virtual display, leaving your desktop alone. See [Hopper installation](docs/installation.md#hopper) for download verification and platform details.
|
||||
|
||||
The normal Linux launcher is `/opt/hopper/bin/Hopper`. If Hopper was installed elsewhere:
|
||||
REA prefers an executable `/opt/hopper/bin/Hopper` on Linux. If it is unavailable, REA automatically checks `~/.local/share/rea/hopper/bin/Hopper`. If Hopper was installed elsewhere:
|
||||
|
||||
```bash
|
||||
export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper
|
||||
@@ -193,12 +193,12 @@ rea doctor --json
|
||||
If doctor reports a missing analysis engine even though the file exists, inspect shared-library resolution with:
|
||||
|
||||
```bash
|
||||
ldd /opt/hopper/bin/Hopper | grep 'not found'
|
||||
ldd /absolute/path/to/Hopper | grep 'not found'
|
||||
```
|
||||
|
||||
Install the missing packages and rerun `rea setup`. The Linux demo needs Xvfb, Python 3, X11, and XTEST; approved setup installs these dependencies. If you use the curl installer, add `~/.local/bin` to your shell `PATH` when needed.
|
||||
|
||||
REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence.
|
||||
REA uses `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` by default on macOS. On Linux it prefers executable `/opt/hopper/bin/Hopper`, then executable `~/.local/share/rea/hopper/bin/Hopper`, and keeps `/opt/hopper/bin/Hopper` as the diagnostic fallback if neither exists. Explicit `HOPPER_LAUNCHER_PATH` configuration always takes precedence.
|
||||
|
||||
### Ghidra analysis provider
|
||||
|
||||
@@ -410,7 +410,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Browser observation | 11 | 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 | 13 | captured website script export; Android/Apple inventory projections; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
@@ -434,6 +434,10 @@ Static Android APK inspection supports Linux and macOS; the current metadata bri
|
||||
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).
|
||||
Inspect a selected node's listener sources or observe an externally triggered
|
||||
execution window with `inspect_web_event_listeners` / `observe_web_execution`.
|
||||
Precise coverage resets counters and affects optimized execution; see
|
||||
[website runtime attribution](docs/web-runtime.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
|
||||
@@ -549,7 +553,7 @@ Setup offers supported agent integrations for selection. Existing REA registrati
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@4.1.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@5.0.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -138,7 +138,7 @@ rea setup
|
||||
### المتطلبات
|
||||
|
||||
- macOS 12 أو أحدث
|
||||
- Ubuntu 24.04+ أو Fedora 41+ أو Arch Linux بنواة 64 بت
|
||||
- Ubuntu 24.04+ أو Fedora 41+ أو Arch Linux بنواة 64 بت أو CachyOS
|
||||
- Node.js 22.x (>=22.19) أو 24.x (>=24.11) أو 26+ وnpm
|
||||
|
||||
يتطلب تحليل الملفات التنفيذية الأصلية Hopper أو Ghidra. Hopper برنامج منفصل، وللوضع التجريبي قيود يحددها المورّد، لكن الترخيص المدفوع ليس إلزاميًا.
|
||||
@@ -153,7 +153,7 @@ rea setup
|
||||
|
||||
يشخّص `npx -y rea-agents@latest doctor` الجهاز والاعتماديات وأدوات التحليل وإعدادات الوكيل من دون تغييرها. أضف `--json` للحصول على تشخيص منظم.
|
||||
|
||||
مسار Hopper الافتراضي على Linux هو `/opt/hopper/bin/Hopper`. استخدم `HOPPER_LAUNCHER_PATH` للمسارات الأخرى. إذا كان الملف موجودًا لكن محرك التحليل لا يظهر، فشغّل `ldd /opt/hopper/bin/Hopper | grep 'not found'` للتحقق من المكتبات الناقصة. راجع [دليل Hopper](docs/installation.md#hopper).
|
||||
على Linux، يفضّل REA الملف التنفيذي `/opt/hopper/bin/Hopper`، وإذا لم يكن متاحًا يتحقق تلقائيًا من `~/.local/share/rea/hopper/bin/Hopper`. استخدم `HOPPER_LAUNCHER_PATH` للمسارات الأخرى. إذا كان الملف موجودًا لكن محرك التحليل لا يظهر، فشغّل `ldd /absolute/path/to/Hopper | grep 'not found'` على مسار Hopper الفعلي للتحقق من المكتبات الناقصة. راجع [دليل Hopper](docs/installation.md#hopper).
|
||||
|
||||
### التحديث والإزالة
|
||||
|
||||
@@ -222,7 +222,7 @@ rea setup
|
||||
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
|
||||
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
|
||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| مراقبة المتصفح | 11 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 13 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
@@ -243,7 +243,7 @@ rea setup
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@4.1.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@5.0.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/
|
||||
<details>
|
||||
<summary><strong>هل يعمل REA على Linux أو Windows؟</strong></summary>
|
||||
|
||||
يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.x وJDK الكامل الذي يعلنه التثبيت (JDK 21 أو أحدث دون حد أعلى في إصدارات 12.1 الحالية)، إضافة إلى حد Windows x64 P0 التجريبي لتطبيقات PE الأصلية x86-64 على NTFS محلي؛ راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للنطاق الموثق.
|
||||
يدعم REA نظام macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux بنواة 64 بت وCachyOS. يعمل تحليل Ghidra للقراءة فقط على Linux x64 وmacOS x64/arm64 مع Ghidra 12.1.x وJDK الكامل الذي يعلنه التثبيت (JDK 21 أو أحدث دون حد أعلى في إصدارات 12.1 الحالية)، إضافة إلى حد Windows x64 P0 التجريبي لتطبيقات PE الأصلية x86-64 على NTFS محلي؛ راجع [دليل Windows Ghidra P0](docs/windows-ghidra-p0.md) للنطاق الموثق.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+4
-4
@@ -138,7 +138,7 @@ rea setup
|
||||
### 要件
|
||||
|
||||
- macOS 12 以降
|
||||
- Ubuntu 24.04+、Fedora 41+、または 64 ビット Arch Linux
|
||||
- Ubuntu 24.04+、Fedora 41+、64 ビット Arch Linux、または CachyOS
|
||||
- Node.js 22.x (>=22.19)、24.x (>=24.11)、または 26+ と npm
|
||||
|
||||
ネイティブバイナリ解析には Hopper または Ghidra が必要です。Hopper は別製品です。デモにはベンダー所定の制限がありますが、有料ライセンスは必須ではありません。
|
||||
@@ -153,7 +153,7 @@ Setup はインストールを確認し、パスを保存できます。Ghidra
|
||||
|
||||
`npx -y rea-agents@latest doctor` はホスト、依存関係、解析ツール、エージェント設定を変更せずに確認します。構造化された診断には `--json` を追加してください。
|
||||
|
||||
Linux の既定ランチャーは `/opt/hopper/bin/Hopper` です。別の場所には `HOPPER_LAUNCHER_PATH` を設定します。ファイルがあるのに解析エンジンが見つからない場合は、`ldd /opt/hopper/bin/Hopper | grep 'not found'` で不足ライブラリを確認してください。詳しくは [Hopper ガイド](docs/installation.md#hopper)を参照してください。
|
||||
Linux では実行可能な `/opt/hopper/bin/Hopper` を優先し、利用できない場合は `~/.local/share/rea/hopper/bin/Hopper` を自動的に確認します。それ以外の場所では `HOPPER_LAUNCHER_PATH` を設定します。ファイルがあるのに解析エンジンが見つからない場合は、実際の Hopper パスに対して `ldd /absolute/path/to/Hopper | grep 'not found'` を実行し、不足ライブラリを確認してください。詳しくは [Hopper ガイド](docs/installation.md#hopper)を参照してください。
|
||||
|
||||
### 更新とアンインストール
|
||||
|
||||
@@ -211,7 +211,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
| マネージド PE/CLI | 7 | .NET の識別、メタデータ、CIL 命令、ネイティブ依存関係、再構築インポート、ビルド比較 |
|
||||
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
|
||||
| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
|
||||
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| ブラウザー観察 | 11 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
||||
| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
@@ -232,7 +232,7 @@ Setup は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@4.1.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@5.0.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -139,7 +139,7 @@ rea setup
|
||||
### 요구 사항
|
||||
|
||||
- macOS 12 이상
|
||||
- Ubuntu 24.04+, Fedora 41+ 또는 64비트 Arch Linux
|
||||
- Ubuntu 24.04+, Fedora 41+, 64비트 Arch Linux 또는 CachyOS
|
||||
- Node.js 22.x (>=22.19), 24.x (>=24.11) 또는 26+와 npm
|
||||
|
||||
네이티브 바이너리 분석에는 Hopper 또는 Ghidra가 필요합니다. Hopper는 별도 소프트웨어입니다. 데모에는 공급업체의 제한이 있지만 유료 라이선스가 필수는 아닙니다.
|
||||
@@ -154,7 +154,7 @@ Setup은 설치를 확인하고 경로를 저장할 수 있습니다. Ghidra, Ja
|
||||
|
||||
`npx -y rea-agents@latest doctor`는 호스트, 의존성, 분석 도구, 에이전트 설정을 변경 없이 확인합니다. 구조화된 진단에는 `--json`을 추가하세요.
|
||||
|
||||
Linux의 기본 Hopper 실행 파일은 `/opt/hopper/bin/Hopper`입니다. 다른 위치에는 `HOPPER_LAUNCHER_PATH`를 설정하세요. 파일이 있는데도 분석 엔진이 없다고 하면 `ldd /opt/hopper/bin/Hopper | grep 'not found'`로 누락된 라이브러리를 확인하세요. 자세한 내용은 [Hopper 안내](docs/installation.md#hopper)를 참고하세요.
|
||||
Linux에서는 실행 가능한 `/opt/hopper/bin/Hopper`를 우선 사용하고, 사용할 수 없으면 `~/.local/share/rea/hopper/bin/Hopper`를 자동으로 확인합니다. 다른 위치에는 `HOPPER_LAUNCHER_PATH`를 설정하세요. 파일이 있는데도 분석 엔진이 없다고 하면 실제 Hopper 경로에 대해 `ldd /absolute/path/to/Hopper | grep 'not found'`를 실행해 누락된 라이브러리를 확인하세요. 자세한 내용은 [Hopper 안내](docs/installation.md#hopper)를 참고하세요.
|
||||
|
||||
### 업데이트와 제거
|
||||
|
||||
@@ -212,7 +212,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
|
||||
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
|
||||
| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
|
||||
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| 브라우저 관찰 | 11 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
@@ -233,7 +233,7 @@ Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@4.1.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@5.0.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -138,7 +138,7 @@ rea setup
|
||||
### 系统要求
|
||||
|
||||
- macOS 12 或更高版本
|
||||
- Ubuntu 24.04+、Fedora 41+ 或 64 位 Arch Linux
|
||||
- Ubuntu 24.04+、Fedora 41+、64 位 Arch Linux 或 CachyOS
|
||||
- Node.js 22.x (>=22.19)、24.x (>=24.11) 或 26+,以及 npm
|
||||
|
||||
原生二进制分析需要 Hopper 或 Ghidra。Hopper 是独立软件;演示模式有厂商规定的限制,不要求购买许可证。
|
||||
@@ -153,7 +153,7 @@ Setup 可以验证安装并保存路径,不会安装或升级 Ghidra、Java、
|
||||
|
||||
运行 `npx -y rea-agents@latest doctor`,检查主机、依赖、分析工具和智能体配置。该命令不会修改文件。添加 `--json` 可获取结构化诊断。
|
||||
|
||||
Linux 上的默认 Hopper 启动器为 `/opt/hopper/bin/Hopper`。其他路径可通过 `HOPPER_LAUNCHER_PATH` 指定。如果文件存在但仍报告缺少分析引擎,运行 `ldd /opt/hopper/bin/Hopper | grep 'not found'` 检查缺少的系统库。安装详情见 [Hopper 指南](docs/installation.md#hopper)。
|
||||
Linux 上 REA 会优先使用可执行的 `/opt/hopper/bin/Hopper`;如果不可用,则自动检查 `~/.local/share/rea/hopper/bin/Hopper`。其他路径可通过 `HOPPER_LAUNCHER_PATH` 指定。如果文件存在但仍报告缺少分析引擎,请对实际 Hopper 路径运行 `ldd /absolute/path/to/Hopper | grep 'not found'` 检查缺少的系统库。安装详情见 [Hopper 指南](docs/installation.md#hopper)。
|
||||
|
||||
### 更新与卸载
|
||||
|
||||
@@ -211,7 +211,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
|
||||
| 固件 | 2 | Linux 固件区域检查与显式提取 |
|
||||
| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
|
||||
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| 浏览器观察 | 11 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
@@ -232,7 +232,7 @@ Setup 支持 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsu
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@4.1.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@5.0.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
struct ChildBatch<Element> {
|
||||
let values: [Element]
|
||||
let complete: Bool
|
||||
}
|
||||
|
||||
struct ChildCount {
|
||||
let value: Int?
|
||||
let error: Int32?
|
||||
}
|
||||
|
||||
func serializeHelperOutput(_ value: Any) throws -> Data {
|
||||
try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .withoutEscapingSlashes])
|
||||
}
|
||||
|
||||
func captureChildCount(status: Int32, success: Int32, value: Int) -> ChildCount {
|
||||
guard status == success else { return ChildCount(value: nil, error: status) }
|
||||
return ChildCount(value: value, error: nil)
|
||||
}
|
||||
|
||||
func captureChildValues<Element>(requestedCount: Int, copy: () -> [Element]?) -> ChildBatch<Element> {
|
||||
guard requestedCount > 0 else { return ChildBatch(values: [], complete: true) }
|
||||
let returned = copy() ?? []
|
||||
|
||||
@@ -22,9 +22,10 @@ func attribute(_ element: AXUIElement, _ key: String) -> CFTypeRef? {
|
||||
var value: CFTypeRef?
|
||||
return AXUIElementCopyAttributeValue(element, key as CFString, &value) == .success ? value : nil
|
||||
}
|
||||
func childCount(_ element: AXUIElement) -> Int {
|
||||
func childCount(_ element: AXUIElement) -> ChildCount {
|
||||
var count = 0
|
||||
return AXUIElementGetAttributeValueCount(element, kAXChildrenAttribute as CFString, &count) == .success ? count : 0
|
||||
let status = AXUIElementGetAttributeValueCount(element, kAXChildrenAttribute as CFString, &count)
|
||||
return captureChildCount(status: status.rawValue, success: AXError.success.rawValue, value: count)
|
||||
}
|
||||
func children(_ element: AXUIElement, count: Int) -> ChildBatch<AXUIElement> {
|
||||
captureChildValues(requestedCount: count) {
|
||||
@@ -88,7 +89,11 @@ func observe(_ request: Request) async throws -> [String: Any] {
|
||||
var element = root
|
||||
for index in action.path ?? [] {
|
||||
var selectedChild: CFArray?
|
||||
guard index >= 0 && index < childCount(element),
|
||||
let count = childCount(element)
|
||||
guard let available = count.value else {
|
||||
try fail("element-count-unavailable", "Cannot validate selected accessibility path because child count failed with AXError \(count.error ?? -1)")
|
||||
}
|
||||
guard index >= 0 && index < available,
|
||||
AXUIElementCopyAttributeValues(element, kAXChildrenAttribute as CFString, index, 1, &selectedChild) == .success,
|
||||
let item = (selectedChild as? [AXUIElement])?.first else { try fail("element-missing", "Selected accessibility path no longer exists") }
|
||||
element = item
|
||||
@@ -104,15 +109,20 @@ func observe(_ request: Request) async throws -> [String: Any] {
|
||||
}
|
||||
guard outcome == .success else { try fail("action-failed", "Accessibility action failed with AXError \(outcome.rawValue)") }
|
||||
}
|
||||
var nodes: [[String: Any]] = []; var truncated = false
|
||||
var nodes: [[String: Any]] = []; var truncated = false; var gaps: [String] = []
|
||||
if request.accessibility, let root = selected {
|
||||
var pending: [(AXUIElement, [Int])] = [(root, [])]
|
||||
while let (element, path) = pending.popLast() {
|
||||
if nodes.count >= request.max_nodes { truncated = true; break }
|
||||
let totalChildren = childCount(element)
|
||||
let childCountResult = childCount(element)
|
||||
var actions: CFArray?
|
||||
AXUIElementCopyActionNames(element, &actions)
|
||||
nodes.append(["path": path, "role": text(element, kAXRoleAttribute), "title": text(element, kAXTitleAttribute), "value": text(element, kAXValueAttribute), "actions": actions as? [String] ?? [], "children_count": totalChildren])
|
||||
nodes.append(["path": path, "role": text(element, kAXRoleAttribute), "title": text(element, kAXTitleAttribute), "value": text(element, kAXValueAttribute), "actions": actions as? [String] ?? [], "children_count": childCountResult.value.map { $0 as Any } ?? NSNull()])
|
||||
guard let totalChildren = childCountResult.value else {
|
||||
truncated = true
|
||||
gaps.append("AX child count unavailable at path \(path): AXError \(childCountResult.error ?? -1)")
|
||||
continue
|
||||
}
|
||||
if path.count >= 32 { if totalChildren > 0 { truncated = true }; continue }
|
||||
let available = max(0, request.max_nodes - nodes.count - pending.count)
|
||||
let count = min(available, totalChildren)
|
||||
@@ -143,18 +153,18 @@ func observe(_ request: Request) async throws -> [String: Any] {
|
||||
}
|
||||
screenshot = ["mime_type": "image/png", "base64": png.base64EncodedString(), "sha256": SHA256.hash(data: png).map({ String(format: "%02x", $0) }).joined(), "width": image.width, "height": image.height]
|
||||
}
|
||||
return ["window": ["pid": request.pid, "window_id": request.window_id, "executable": request.executable, "launch_time": launch, "title": window[kCGWindowName as String] as? String ?? ""], "nodes": nodes, "truncated": truncated, "screenshot": screenshot, "gaps": request.accessibility ? [] : ["Accessibility capture was disabled"]]
|
||||
return ["window": ["pid": request.pid, "window_id": request.window_id, "executable": request.executable, "launch_time": launch, "title": window[kCGWindowName as String] as? String ?? ""], "nodes": nodes, "truncated": truncated, "screenshot": screenshot, "gaps": request.accessibility ? gaps : ["Accessibility capture was disabled"]]
|
||||
}
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let request = try JSONDecoder().decode(Request.self, from: Data(CommandLine.arguments[1].utf8))
|
||||
let result = try await observe(request)
|
||||
let output = try JSONSerialization.data(withJSONObject: ["ok": true, "result": result], options: [.sortedKeys])
|
||||
let output = try serializeHelperOutput(["ok": true, "result": result])
|
||||
FileHandle.standardOutput.write(output)
|
||||
} catch {
|
||||
let failure = error as? BoundaryFailure
|
||||
let output: [String: Any] = ["ok": false, "code": failure?.code ?? "capture-failed", "message": failure?.message ?? error.localizedDescription]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]) { FileHandle.standardOutput.write(data) }
|
||||
if let data = try? serializeHelperOutput(output) { FileHandle.standardOutput.write(data) }
|
||||
}
|
||||
exit(0)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export default defineConfig({
|
||||
{ text: "MCP contracts", link: "/mcp-contracts" },
|
||||
{ text: "Tool design", link: "/tool-design" },
|
||||
{ text: "Testing", link: "/testing" },
|
||||
{ text: "Releasing", link: "/releasing" },
|
||||
{ text: "Architecture decisions", link: "/adr/README" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ analyzer, or provider-version differences while appearing target-compatible.
|
||||
|
||||
The decision addressed the earlier representations of
|
||||
[`CompositeProvider`](https://github.com/morluto/rea/blob/main/src/application/binary/CompositeProvider.ts),
|
||||
[runtime composition](https://github.com/morluto/rea/blob/main/src/application/runtime.ts),
|
||||
[runtime composition](https://github.com/morluto/rea/blob/main/src/composition/binary.ts),
|
||||
[`BinarySession`](https://github.com/morluto/rea/blob/main/src/application/binary/BinarySession.ts),
|
||||
[`BinaryTarget`](https://github.com/morluto/rea/blob/main/src/domain/binaryTarget.ts),
|
||||
[analysis snapshot](https://github.com/morluto/rea/blob/main/src/domain/analysisSnapshot.ts), and
|
||||
|
||||
@@ -5,6 +5,10 @@ graph TD
|
||||
DISPATCH["scripts/rea.mjs<br/>Executable dispatcher<br/>routes mcp / one-shot"]
|
||||
end
|
||||
|
||||
subgraph "Production Composition"
|
||||
COMPOSITION["src/composition/<br/>fresh typed provider and session factories<br/>one-shot binding and independent optional loading"]
|
||||
end
|
||||
|
||||
subgraph "Adapters"
|
||||
SERVER["src/server/<br/>MCP request translation<br/>createServer, tool registration, toolResult"]
|
||||
HOPPER_BRIDGE["bridge/hopper_bridge.py<br/>Runs inside Hopper<br/>adapts operations to Hopper Python API"]
|
||||
@@ -18,7 +22,7 @@ graph TD
|
||||
REGISTRY["AnalysisProviderRegistry<br/>sorted deep candidates<br/>deterministic selection, no fallback"]
|
||||
RECORDS["src/application/investigation/<br/>composed InvestigationRecords + narrow ports<br/>EvidenceLedger and Unknown ownership"]
|
||||
SNAPSHOT["BinarySessionRecords + AnalysisSnapshotCache<br/>binary target/profile snapshots<br/>compatibility facade and post-commit notifications"]
|
||||
JS_RECON["src/application/ + src/artifacts/<br/>JavaScript artifact reconstruction<br/>directory/ASAR reads<br/>AST-only bundle projection"]
|
||||
JS_RECON["src/application/javascript/ + src/artifacts/javascript/<br/>JavaScript artifact reconstruction<br/>directory/ASAR reads<br/>AST-only bundle projection"]
|
||||
end
|
||||
|
||||
subgraph "Provider Layers"
|
||||
@@ -27,7 +31,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 scripts and native module resolution"]
|
||||
BROWSER["src/browser/<br/>CDP / Playwright capture<br/>captured scripts and native module resolution<br/>execution/: coverage, listener and source attribution"]
|
||||
JS_CODEC["src/javascript/sourceMaps/<br/>pinned upstream map codec<br/>owned heap-limited Node process, complete point evidence"]
|
||||
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"]
|
||||
@@ -69,6 +73,10 @@ graph TD
|
||||
MAIN --> SERVER
|
||||
MAIN --> APP
|
||||
CLI --> APP
|
||||
MAIN --> COMPOSITION
|
||||
CLI --> COMPOSITION
|
||||
SERVER --> COMPOSITION
|
||||
COMPOSITION --> SESSION
|
||||
APP --> FIRMWARE
|
||||
FIRMWARE --> PROCESS
|
||||
FIRMWARE --> CONTRACTS
|
||||
@@ -84,9 +92,10 @@ graph TD
|
||||
APP -->|record callbacks| RECORDS
|
||||
SERVER -->|narrow record ports| RECORDS
|
||||
RECORDS --> DOMAIN
|
||||
REGISTRY --> HOPPER
|
||||
REGISTRY --> GHIDRA
|
||||
REGISTRY --> IDA
|
||||
COMPOSITION --> HOPPER
|
||||
COMPOSITION --> GHIDRA
|
||||
COMPOSITION --> IDA
|
||||
COMPOSITION --> AUX
|
||||
IDA --> IDA_MCP
|
||||
IDA --> CONTRACTS
|
||||
IDA --> DOMAIN
|
||||
|
||||
@@ -6,17 +6,17 @@ there is no new universal provider interface or fixed module manifest.
|
||||
|
||||
## Ownership to preserve
|
||||
|
||||
| Responsibility | Current owner | Migration direction |
|
||||
| --------------------------------------------- | ---------------------------------------- | -------------------------------------------------------------- |
|
||||
| Analyst semantics and Evidence representation | domain | Keep pure and provider-neutral |
|
||||
| Named public input/output contracts | contracts | Keep exact CLI/MCP meaning and complete discovery |
|
||||
| Producer parsing and external tool protocols | provider adapters | Keep behind the relevant typed port |
|
||||
| Shared analyst workflows | application | Share between CLI and MCP |
|
||||
| Concrete provider construction | binary runtime, composition, MCP startup | Consolidate one capability at a time in production composition |
|
||||
| Investigation Evidence and Unknowns | InvestigationRecords/EvidenceLedger | Consume narrow read/write/atomic record ports |
|
||||
| Target/profile snapshots and invalidation | BinarySessionRecords/BinarySession | Keep binary-owned |
|
||||
| Subprocess ownership and host primitives | process/windows | Reuse; preserve cancellation and cleanup |
|
||||
| Public translation | CLI/MCP adapters | Delegate to shared workflows and named contracts |
|
||||
| Responsibility | Current owner | Migration direction |
|
||||
| --------------------------------------------- | ----------------------------------- | ----------------------------------------------------- |
|
||||
| Analyst semantics and Evidence representation | domain | Keep pure and provider-neutral |
|
||||
| Named public input/output contracts | contracts | Keep exact CLI/MCP meaning and complete discovery |
|
||||
| Producer parsing and external tool protocols | provider adapters | Keep behind the relevant typed port |
|
||||
| Shared analyst workflows | application | Share between CLI and MCP |
|
||||
| Concrete provider construction | composition, MCP startup | Keep fresh typed factories at the production boundary |
|
||||
| Investigation Evidence and Unknowns | InvestigationRecords/EvidenceLedger | Consume narrow read/write/atomic record ports |
|
||||
| Target/profile snapshots and invalidation | BinarySessionRecords/BinarySession | Keep binary-owned |
|
||||
| Subprocess ownership and host primitives | process/windows | Reuse; preserve cancellation and cleanup |
|
||||
| Public translation | CLI/MCP adapters | Delegate to shared workflows and named contracts |
|
||||
|
||||
Retain exact Evidence IDs, provenance, detached reads, atomic Evidence/Unknown
|
||||
mutations and optimistic revisions. Preserve current close and failed-cleanup
|
||||
@@ -69,8 +69,8 @@ paths removed during a move. Pass exact source paths for a focused check.
|
||||
|
||||
Pure source and its tests keep inward dependencies. Application/server provider
|
||||
construction guards cover the migrated deep, Android, firmware, JavaScript recovery and observation
|
||||
implementations. The existing binary runtime is an exact temporary composition
|
||||
exception; shared browser capture/export helpers remain admitted while their
|
||||
implementations. Application workflows cannot import composition, including the
|
||||
former binary runtime entrypoint; shared browser capture/export helpers remain admitted while their
|
||||
ownership is reviewed. Test lane restrictions still use Oxlint. Producer-dependent
|
||||
browser and managed comparison tests live in boundary lanes with their original
|
||||
fixtures and assertions. This is a development guard, not runtime authorization.
|
||||
@@ -104,8 +104,8 @@ inside the provider. The existing launcher seams remain available to boundary
|
||||
fixtures, which now exercise the production factories.
|
||||
|
||||
Application workflows and MCP registration must not import these concrete
|
||||
providers. Composition may import their implementations. Binary composition is
|
||||
still at its existing entrypoint while its direct-analysis callers are investigated.
|
||||
providers. Composition may import their implementations. Binary composition also
|
||||
uses this outer boundary after its direct-analysis callers were migrated.
|
||||
This concrete pilot does not introduce a shared provider interface or module
|
||||
manifest.
|
||||
|
||||
@@ -329,10 +329,9 @@ records still have their separate owner in `src/application/investigation/`.
|
||||
|
||||
`AnalysisProvider.ts` stays shared because its identity/execution types also serve
|
||||
nonbinary ports. `BinaryTargetResolver.ts` stays shared because Android, firmware,
|
||||
managed and artifact workflows use its target parsing. The existing `runtime.ts`
|
||||
remains the exact temporary production wiring entrypoint while direct-analysis
|
||||
callers are migrated deliberately; application workflows gain no outward
|
||||
composition dependency.
|
||||
managed and artifact workflows use its target parsing. Production wiring lives
|
||||
in `src/composition/binary.ts`; application workflows gain no outward composition
|
||||
dependency.
|
||||
|
||||
The three colocated application tests move with their owner. Composition cases
|
||||
remain in `tests/composition/analysis-sessions/`; filesystem and SDK cases retain
|
||||
@@ -356,3 +355,73 @@ repository-root default. Both point to `_reference/firmware-integration/generate
|
||||
when invoked through the documented npm commands. Optional ext4/Ghidra lanes keep
|
||||
their own prerequisites. Source-fixture generation and synthetic producer tests
|
||||
do not establish real Binwalk/Unblob analysis or another host's execution support.
|
||||
|
||||
## Apple artifact capability ownership
|
||||
|
||||
`src/domain/apple/` owns Apple application anatomy/projection, asset catalog
|
||||
facts, Interface Builder graphs, keyed archive semantics and plist values.
|
||||
`src/application/apple/AppleApplicationService.ts` retains the shared workflow
|
||||
that projects authenticated IPA/macOS inventory Evidence.
|
||||
|
||||
`src/artifacts/apple/` owns the actual producer boundary: directory acquisition,
|
||||
assetutil invocation, plist/NIB decoding, encoded view-parent interpretation and
|
||||
keyed archive inspection. These helpers are used by ArtifactProvider, retain
|
||||
its existing typed analysis boundary, injected assetutil seam and original reader
|
||||
cleanup, and do not require a new port for each helper. Producer code no longer
|
||||
lives in the application layer. Guards reject application/composition/caller
|
||||
imports from this owner while admitting pure facts and shared artifact readers.
|
||||
Adapter-source tests retain their assertions in the existing forked adapter lane.
|
||||
|
||||
`scripts/verify/apple/` groups archive, asset catalog, Interface Builder,
|
||||
Objective-C/Swift dispatch and macOS bundle verification. The macOS bundle fixture
|
||||
builder lives in `scripts/fixtures/apple/`; NIB byte fixtures stay beside their
|
||||
artifact decoder. Existing format-specific conformance sources and goldens keep
|
||||
their locations, and relocated verifiers resolve them against their own file
|
||||
URLs. Public npm entrypoints and CLI/MCP contracts stay stable; generated-document
|
||||
cache inputs include the relocated runtime scripts and fixture builder.
|
||||
|
||||
Portable decoder and injected producer tests establish their format/boundary
|
||||
claims. Actual Swift/Xcode, assetutil, native NIB, bundle signing and DMG lifecycle
|
||||
claims require the real Apple lane. Native binary API/value-flow semantics and
|
||||
shared cross-domain contract aggregates keep their separate owners.
|
||||
|
||||
## Binary production composition closure
|
||||
|
||||
`src/composition/binary.ts` is the shared production owner for deep candidate
|
||||
construction, lazy auxiliary composition and the managed-only session factory.
|
||||
It preserves the existing constructor bodies, selection policy and fresh session
|
||||
per invocation. Factory registration acquires no engine or target.
|
||||
|
||||
`DirectAnalysis` and `DirectAnalysisStatus` receive these two existing factories
|
||||
through `DirectAnalysisDependencies`. They retain configuration parsing, selected
|
||||
status environment, cancellation, snapshot replay/binding, Evidence projection
|
||||
and finally-close. The dependency uses the existing BinarySession type, including
|
||||
its replay policy; it adds no universal backend or lifecycle contract.
|
||||
|
||||
`src/composition/directAnalysis.ts` binds the production factories for CLI and
|
||||
one-shot managed MCP callers. Binding creates no session and stores no retained
|
||||
analysis state. MCP startup constructs its connection session through the same
|
||||
binary factory. Source and compiled fixtures use these entrypoints. The former
|
||||
application runtime path is removed, and the source guard no longer admits its
|
||||
outward provider/composition imports.
|
||||
|
||||
## Native analyst capability ownership
|
||||
|
||||
`src/domain/native/` owns portable native inspection, instruction/data types,
|
||||
load-image facts, API boundaries, annotations, metadata recovery, UI observation
|
||||
semantics and value/investigation graphs. `src/application/native/` owns shared
|
||||
API projection, call routes, dispatch inspection and UI/value tracing. Its tests
|
||||
keep the existing service lane. `src/contracts/native/` owns the named native
|
||||
adapter contracts; shared official/enhanced aggregates remain at their owners.
|
||||
|
||||
Producer interpretation, external commands and host UI lifecycle remain in
|
||||
`src/native/`, Hopper, Ghidra and IDA. The shared function dossier still lives in
|
||||
`domain/hopperValues.ts`: its historical name does not make its meaning exclusive
|
||||
to Hopper. This move retains existing typed ports, exact result schemas,
|
||||
observed/derived/unknown distinctions, profiles and Evidence links.
|
||||
|
||||
Source and compiled verifier imports follow the new owners. Host UI and real
|
||||
native-value verifier locations are retained until their own proof lanes migrate;
|
||||
interactive macOS UI success and real dependency tracing require their respective
|
||||
host/engine workflows. Portable source fixtures and injected call-route tests
|
||||
establish their narrower boundaries, without expanding provider/platform support.
|
||||
|
||||
@@ -408,7 +408,7 @@ For a client that requires manual configuration, use:
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@4.1.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@5.0.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"package": {
|
||||
"name": "rea-agents",
|
||||
"version": "4.1.0",
|
||||
"version": "5.0.0",
|
||||
"sdk": {
|
||||
"server": "2.3.1",
|
||||
"client": "2.3.1",
|
||||
@@ -10,7 +10,7 @@
|
||||
"skill_version": "27"
|
||||
},
|
||||
"tools": {
|
||||
"total": 131,
|
||||
"total": 133,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
@@ -142,7 +142,7 @@
|
||||
{
|
||||
"id": "browser",
|
||||
"surface": "browser-provider",
|
||||
"count": 9,
|
||||
"count": 11,
|
||||
"tools": [
|
||||
"analyze_web_bundle",
|
||||
"capture_browser_scenario",
|
||||
@@ -150,8 +150,10 @@
|
||||
"compare_web_captures",
|
||||
"compare_web_screenshots",
|
||||
"discover_webmcp_tools",
|
||||
"inspect_web_event_listeners",
|
||||
"inspect_web_page",
|
||||
"list_browser_targets",
|
||||
"observe_web_execution",
|
||||
"observe_web_session"
|
||||
]
|
||||
},
|
||||
@@ -389,8 +391,10 @@
|
||||
"compare_web_captures",
|
||||
"compare_web_screenshots",
|
||||
"discover_webmcp_tools",
|
||||
"inspect_web_event_listeners",
|
||||
"inspect_web_page",
|
||||
"list_browser_targets",
|
||||
"observe_web_execution",
|
||||
"observe_web_session"
|
||||
]
|
||||
},
|
||||
@@ -566,7 +570,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 86,
|
||||
"primary_count": 88,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
@@ -622,6 +626,7 @@
|
||||
"inspect-native-load-image",
|
||||
"inspect-plist",
|
||||
"inspect-signature",
|
||||
"inspect-web-event-listeners",
|
||||
"inspect-web-page",
|
||||
"instructions",
|
||||
"list-architectures",
|
||||
@@ -630,6 +635,7 @@
|
||||
"list-javascript-runtime-targets",
|
||||
"observe-javascript-runtime",
|
||||
"observe-native-ui",
|
||||
"observe-web-execution",
|
||||
"observe-web-session",
|
||||
"project-android-application-graph",
|
||||
"project-apple-application-graph",
|
||||
@@ -664,15 +670,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 86,
|
||||
"mcp_tools": 131,
|
||||
"cli_commands": 88,
|
||||
"mcp_tools": 133,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "f49e05c52c6fd9955edcede27fbaaa248fdb917caa7b27edb8d97f7a30ad250e",
|
||||
"tools_sha256": "af7fe81ec689c2a77f04579b6f4beb4abe86aa71d49c4844d8e0f4d7ed7a3a1e",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "54ac160db3b96580a9a0203a07fe3db01ffc05224db621f48b4abdf979e4f50b",
|
||||
"providers_sha256": "a7d885531ee25d42c51385444faec544d34cd7eba6250a7dc75c4faedd4454a3"
|
||||
"combined_sha256": "83b4c3445ed0ec003c0aeefa3188f123d9ae319484f8b1e2b611d2fe278c9c72",
|
||||
"providers_sha256": "aea15f517c822d80918ccaba57a4fa06557f484cf27b8acbaed4e1a660fac896"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Releasing from a checkpoint
|
||||
|
||||
REA releases use a frozen source checkpoint. Main can continue accepting
|
||||
changes while a release is tested and published. The Release workflow runs
|
||||
on `release/*` branches; main pushes do not refresh release PRs.
|
||||
|
||||
## 1. Cut the release branch
|
||||
|
||||
Choose the next version from the unreleased Conventional Commits, including
|
||||
breaking changes. Record the full source SHA and create `release/VERSION` at
|
||||
that commit. The version is a maintainer choice; Release Please's proposed
|
||||
version must agree before publication.
|
||||
|
||||
For example, after selecting a reviewed commit for 5.0.0:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git branch release/5.0.0 SOURCE_SHA
|
||||
git push origin release/5.0.0
|
||||
```
|
||||
|
||||
Replace `SOURCE_SHA` with the selected full commit SHA. The checkpoint must
|
||||
include this release workflow and CI support for release branches. For an
|
||||
older checkpoint, backport only the release infrastructure first and record
|
||||
that additional commit. Do not merge later implementation changes into the
|
||||
release branch or rebase the candidate onto a moving main.
|
||||
|
||||
The branch push runs Release Please against that branch and opens a PR for
|
||||
the version, changelog, and registry metadata. The generation step normalizes
|
||||
the product catalog. To retry preparation after a failed run, select the same
|
||||
frozen branch explicitly:
|
||||
|
||||
```bash
|
||||
gh workflow run release.yml --ref release/5.0.0
|
||||
```
|
||||
|
||||
## 2. Review and merge the bot PR
|
||||
|
||||
Record the final bot PR head after normalization. Approve GitHub-blocked bot
|
||||
workflow runs for that head when needed. Review the candidate's version,
|
||||
notes, generated metadata, and package contents. Wait for the candidate's CI
|
||||
and relevant real-provider checks. Routine local iterations need focused
|
||||
checks; CI owns full deterministic coverage and platform lanes. If an artifact
|
||||
or real-provider check is unavailable, report that limit before deciding to
|
||||
publish.
|
||||
|
||||
Merge the reviewed PR into `release/5.0.0` with its head SHA matched. **This
|
||||
merge authorizes and triggers publication.** Further main commits do not change
|
||||
this candidate. Do not push or merge anything else into the release branch
|
||||
while publication is running. A necessary release fix must be reviewed and
|
||||
tested before the bot PR merges and establishes a new recorded checkpoint.
|
||||
|
||||
## 3. Verify publication
|
||||
|
||||
The merge push runs Release Please again, creates the tag and GitHub release,
|
||||
and publishes npm followed by MCP Registry metadata. Both publishers check
|
||||
out Release Please's exact release SHA. The triggering workflow also runs
|
||||
from that release commit so npm's provenance records the actual source.
|
||||
|
||||
Before invoking Release Please, the workflow checks that the release branch
|
||||
still equals the triggering SHA and rejects main or tag refs. Before either
|
||||
registry publish, it rejects a mismatch between the tagged release SHA and
|
||||
the triggering SHA. These checks detect moved branches; maintainers must also
|
||||
keep the release branch frozen throughout publication.
|
||||
See [npm's provenance implementation](https://github.com/npm/cli/blob/v11.16.0/workspaces/libnpmpublish/lib/provenance.js)
|
||||
for the use of GitHub's workflow ref and commit SHA.
|
||||
|
||||
The workflow builds the bundled Windows controls and verifies the packaged
|
||||
artifact before npm publication. It then verifies the published CLI, the
|
||||
capability-scoped MCP catalog, and the isolated package update path before
|
||||
publishing MCP Registry metadata.
|
||||
|
||||
Record these outcomes separately:
|
||||
|
||||
- GitHub release and tag, including the resolved commit SHA.
|
||||
- npm's exact version and integrity, with the published-package canary passed.
|
||||
- MCP Registry's exact server version and matching npm package version.
|
||||
|
||||
A GitHub tag alone does not establish npm or MCP Registry publication.
|
||||
|
||||
## Sync metadata back to main
|
||||
|
||||
After publication, open a PR from the release branch back to main. Preserve
|
||||
main's later implementation changes and resolve generated-file conflicts by
|
||||
regenerating from the combined contracts with the released package version.
|
||||
Review and test this synchronization PR, then use a merge commit so the release
|
||||
tag remains in main's ancestry. Keep the released tag unchanged.
|
||||
|
||||
Close any superseded rolling release PR. Future releases repeat the checkpoint
|
||||
procedure from main; never resume automatic release-PR refreshes on main pushes.
|
||||
|
||||
## Partial publication and retries
|
||||
|
||||
Inspect the failed job and public registry state before retrying. When npm is
|
||||
already published, the npm job verifies that exact version instead of
|
||||
publishing it again. Re-run failed jobs in the original publication run so its
|
||||
release SHA and outputs stay fixed. If only MCP publication failed, retry that
|
||||
job after checking that the npm canary succeeded.
|
||||
|
||||
Do not start a fresh workflow run to repair an already-created release:
|
||||
Release Please will not create the same release again. Do not move the tag,
|
||||
delete the release, or unpublish npm as a retry. A defective public package
|
||||
requires a reviewed correction and a new version.
|
||||
@@ -89,6 +89,20 @@ then checks that `inspect-artifact` plus `project-apple-application-graph`
|
||||
report the same bundle anatomy for all three through the CLI, with stdio MCP
|
||||
parity. It also checks that the DMG is detached afterwards. It runs in macOS CI.
|
||||
|
||||
Apple artifact verifiers live in `scripts/verify/apple/`, with the macOS bundle
|
||||
builder under `scripts/fixtures/apple/` and NIB byte fixtures beside the decoder
|
||||
in `src/artifacts/apple/`. The npm entrypoints are unchanged. Format-specific
|
||||
Swift/XIB/asset sources and goldens retain their locations; real Apple workflows
|
||||
resolve them from the verifier file URL and run in the macOS CI lane.
|
||||
|
||||
Portable native semantics and their tests live in `src/domain/native/`; shared
|
||||
analyst workflows and service-lane tests live in `src/application/native/`.
|
||||
Named native contracts live in `src/contracts/native/`. Provider protocol and
|
||||
host UI tests retain their adapter/boundary lanes, and real verifier command
|
||||
names remain unchanged.
|
||||
The existing Apple CI job also runs the host Swift-demangling CLI/MCP regression
|
||||
suites, including option-like symbols, carriage returns and multiline rejection.
|
||||
|
||||
MCP SDK transport tests with recording providers remain integration tests.
|
||||
They are useful for schema drift and failure projection but do not prove that
|
||||
Hopper, Ghidra or another substituted engine works. `verify:package` proves
|
||||
@@ -537,3 +551,19 @@ checks an installed package after building the verifier dependencies. The separa
|
||||
conditional `real-web-source-map` CI job supplies Chrome and an isolated pinned
|
||||
fixture compiler; static/unit checks do not acquire a browser. See
|
||||
[the source location guide](web-source-location.md) for the verified decoder profile.
|
||||
|
||||
### Website runtime attribution lane
|
||||
|
||||
`npm run verify:browser:runtime` uses caller-supplied
|
||||
`REA_BROWSER_EXECUTABLE` and an owned synthetic site/profile. It exercises public
|
||||
CLI and stdio MCP for precise execution and native listener source locations,
|
||||
including actual armed progress, Unicode/CRLF digests, repeated source URLs with
|
||||
distinct script IDs, zero branches and function-only unknowns on repeated
|
||||
coverage, request initiators and an externally owned page that remains open.
|
||||
|
||||
An optional entrypoint argument to `scripts/verify-browser-runtime.mjs` runs the
|
||||
same checks through an isolated installed package. The conditional
|
||||
`real-web-runtime` CI job runs only for relevant changes and needs no fixture
|
||||
compiler. Ordinary unit/static gates acquire no browser. See
|
||||
[website runtime attribution](web-runtime.md) for effects, resource bounds and
|
||||
coverage limits.
|
||||
|
||||
@@ -56,7 +56,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement
|
||||
46,src/application/Uninstall.ts:removeManagedPath symlink,Managed cache/state/skill path is a symbolic link,Refused to follow symlink {path}.,Exposes local path and does not explain manual safety check,"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",implemented,focused assertion passed
|
||||
47,src/application/Uninstall.ts:removeManagedPath failure,Managed item removal fails,{path} could not be removed.,Exposes local path and gives no recovery,"This item could not be removed. Check file permissions, then rerun uninstall.",implemented,verified by injected managed-path removal failure test
|
||||
48,src/application/DoctorDiagnostics.ts:node,Runtime version is outside supported ranges,"Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+.",Low risk; exact dependency recovery,Keep current copy,unchanged,verified by doctor unit test
|
||||
49,src/application/Doctor.ts:host,Host OS/distribution is unsupported,"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",Low risk; clearly states supported choices,Keep current copy,unchanged,verified by doctor unit tests
|
||||
49,src/application/Doctor.ts:host,Host OS/distribution is unsupported,"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and CachyOS.",Low risk; clearly states supported choices,Keep current copy,unchanged,verified by doctor unit tests
|
||||
50,src/application/Doctor.ts:analysis engine,Optional deep-analysis executable cannot be found,"Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",Low risk in setup-specific diagnostic; exact recovery paths,Keep current copy,unchanged,verified by doctor unit tests
|
||||
51,src/application/Doctor.ts:target,Optional target path fails shared validation,Supply a readable local app or program path.,Low risk; tells caller valid target class,Keep current copy,unchanged,verified by doctor unit test
|
||||
52,src/application/Setup.ts:approval,Setup plan lacks approval,"Review the setup plan, then rerun interactively or with --yes.",Low risk; exact authorization recovery,Keep current copy,unchanged,verified by setup unit tests
|
||||
@@ -67,7 +67,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement
|
||||
57,src/application/Setup.ts:unsupported platform,Platform is neither macOS nor Linux,REA supports Hopper on macOS and selected 64-bit Linux distributions.,Low risk; states support boundary,Keep current copy,unchanged,verified by setup unit test
|
||||
58,src/application/SetupHost.ts:unsupported Node,Runtime version cannot run setup,"Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ and rerun setup.",Low risk; exact dependency and retry,Keep current copy,unchanged,verified by setup host tests
|
||||
59,src/application/Setup.ts:unsupported macOS,macOS version is missing or older than 12,Upgrade to macOS 12 or newer.,Low risk; exact support floor,Keep current copy,unchanged,verified by setup host tests
|
||||
60,src/application/Setup.ts:unsupported Linux,Linux distribution is outside supported set,"REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",Low risk; exact supported alternatives,Keep current copy,unchanged,verified by setup unit test
|
||||
60,src/application/Setup.ts:unsupported Linux,Linux distribution is outside supported set,"REA supports Hopper on Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and CachyOS.",Low risk; exact supported alternatives,Keep current copy,unchanged,verified by setup unit test
|
||||
61,src/application/ProcessCli.ts:readJson read,Process scenario or evidence file cannot be read,Raw filesystem exception through CLI framework,Can expose local paths/codes and no recovery,"Process input file could not be read. Check that the path exists and is readable.",implemented,verified by focused CLI test
|
||||
62,src/application/ProcessCli.ts:readJson size,Process scenario or evidence file exceeds 64 MiB,Process JSON input exceeds byte limit,Does not state limit or recovery,"Process input file is too large. Reduce it below 64 MiB, then try again.",implemented,verified by focused sparse-file CLI test
|
||||
63,src/application/ProcessCli.ts:readJson parse,Process scenario or evidence file is malformed JSON,Raw JSON parser exception through CLI framework,Can expose parser implementation and lacks recovery,"Process input file is not valid JSON. Repair the file, then try again.",implemented,verified by focused CLI test
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_e7e60d5d08bfe335c4c0b38ab4598eb634444b835202e325241749d152808c94",
|
||||
"manifest_id": "ecm_880356f1ceceb9e29b43e3e89f7a326aa83132e1e1fae0573245848ba714f404",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "265556bd52f52ad829187fe71831cf05329248286c86e3102f0cdbf8841e1d6d"
|
||||
"sha256": "97dc167b6e0d41ac69953501c96a918319fa6e4b3be0affe2fc2119294e7101d"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Website runtime source attribution
|
||||
|
||||
REA integrates native Chrome DevTools Protocol inspection and precise V8 coverage through two composable tools. Supply an existing loopback CDP endpoint and a page target from `list_browser_targets`. With omitted origins, REA selects the page's current HTTP(S) origin. The browser and page remain externally owned.
|
||||
|
||||
## Observe execution
|
||||
|
||||
```bash
|
||||
rea observe-web-execution http://127.0.0.1:9222 TARGET_ID \
|
||||
--observation-ms 10000 --json
|
||||
```
|
||||
|
||||
MCP: `observe_web_execution` with `cdp_endpoint`, `target_id`, optional `allowed_origins` and optional `observation_ms` (default 10000). The operation itself selects instrumentation; no extra approval flag is needed.
|
||||
|
||||
Starting precise coverage resets counters and prevents optimized execution until REA stops it. This changes timing/JIT behavior. REA requests accurate call counts and detailed ranges, takes one resetting sample, stops coverage, disables enabled domains and detaches its own connection. It does not navigate, click, evaluate selected application code or close the page.
|
||||
|
||||
Wait for the actual armed progress event before performing an action. MCP emits negotiated progress notifications; CLI emits `rea_progress` JSON on stderr while the Evidence result stays on stdout. Use ordinary commands, scripts or user actions to trigger behavior. CLI SIGINT/SIGTERM and MCP cancellation await instrumentation cleanup; unconfirmed cleanup is an error with the target, failed commands and previous failure.
|
||||
|
||||
Results retain:
|
||||
|
||||
- The selected target, frame and document loader identity; local armed/end timestamps and separate backend monotonic coverage timestamps.
|
||||
- Every admitted producer function/block range, exclusive end offset and execution count in UTF-16 code units, with verified bounds when exact text is available. Nested/overlapping counts are preserved and must not be summed.
|
||||
- Main-document script inventory, exact session script IDs, resource start coordinates, sourceURL/source-map declarations, producer hash and independently computed UTF-8 text digest.
|
||||
- Armed-window requests from the selected main frame and selected origins, complete reported initiator objects (including stack hierarchy/descriptions), derived callsites, exact script-ID associations and unresolved async parent IDs. Source association does not establish UI causality.
|
||||
|
||||
The coverage counter interval starts at backend acceptance before the armed receipt and ends at the resetting sample after the local timer. Counts may include execution during these command intervals; request events are restricted to the locally armed window. The result retains both backend timestamps and the distinct local clock.
|
||||
|
||||
Source metadata remains collected through receipt of the resetting sample, including scripts parsed during the final command. REA then freezes this inventory for source joins. Fatal protocol parsing failures terminate the observer even when no command is outstanding; listener inspection checks producer failures after its final document assertion.
|
||||
|
||||
A document replacement ends the window; final coverage then remains unavailable. Missing coverage is unknown: an already loaded script may be absent from a sample. A detailed request also does not guarantee block granularity for already compiled functions. If `is_block_coverage` is false, the function-only ranges are observed and branch execution remains unknown. Native Chrome verification reproduced block coverage in the first window and function-only coverage in a subsequent window, including without REA.
|
||||
|
||||
Same-document navigation such as `history.pushState()` remains valid when the frame and known loader identity stay stable and the current origin is allowed. Unknown loader identity retains a conservative URL check; newly reported or replaced loaders are rejected. Malformed producer events end an armed window immediately and trigger cleanup, independently of the selected duration.
|
||||
|
||||
Target detachment ends the window even when the browser's root connection stays open. When the closed session cannot acknowledge cleanup, the operation returns a failure with the selected target, failed commands and original producer reason; it does not claim successful cleanup.
|
||||
|
||||
Child frames, workers, service workers, WebAssembly, chronological execution order and framework-specific causal interpretation are outside this operation. Script ownership comes from frame/context metadata, never URL equality. Repeated URLs and synthetic names therefore retain separate identities.
|
||||
|
||||
Observed script metadata remains available when ownership is unknown; source text and source association remain excluded or unknown. Request/initiator resource URLs omit transport userinfo. Stack and script URLs can also be inert `sourceURL` names: REA preserves declared names and unknown representations, and only removes script URL userinfo when stable producer metadata explicitly reports `hasSourceURL: false`. This policy also applies when metadata arrives after a request. Unknown producer extension fields and source-map declarations are preserved.
|
||||
|
||||
## Inspect a node's listeners
|
||||
|
||||
```bash
|
||||
rea inspect-web-event-listeners http://127.0.0.1:9222 TARGET_ID '#submit' --json
|
||||
```
|
||||
|
||||
MCP: `inspect_web_event_listeners` with the same scope and an explicit CSS `selector`. Native DOM selection inspects the first matching node in the main document. A missing match is an explicit empty result.
|
||||
|
||||
The result contains directly registered listener types/flags, callback coordinates, source identity/text/digest and execution unknowns. REA uses native DOM/DOMDebugger operations and releases its own remote object group. It never invokes a listener or dispatches an event. Delegated ancestor listeners, descendants, closed shadow roots and framework registries are not enumerated.
|
||||
|
||||
## Bounds and verification
|
||||
|
||||
Each protocol message has a 64 MiB budget; script/context metadata and request metadata each have an 8 MiB retained budget. Complete retained source text has a 32 MiB budget. Oversized or malformed evidence returns no partial successful result. Each command and the complete inspection/source-join phase have a 20-second deadline; cleanup has a separate 5-second command deadline plus transport closure. The selected observation duration must fit the host timer's positive 32-bit millisecond range. REA does not control the externally owned browser's CPU or memory.
|
||||
|
||||
`npm run verify:browser:runtime` uses caller-supplied `REA_BROWSER_EXECUTABLE` and the native `ps` command on Linux/macOS against an owned synthetic fixture, through real public CLI and stdio MCP. It checks exact Unicode/CRLF text/digests, same-URL identities, same-document navigation, inert declared names, executed and zero-count branches, function-only unknowns on repeated observation, listener selection, initiator association, actual arming and preservation of the external page. A separate fixture page closes during an armed MCP window to verify prompt termination and inline cleanup diagnostics. The optional positional entrypoint to `scripts/verify-browser-runtime.mjs` permits the same checks against an isolated installed tarball.
|
||||
|
||||
Real workflow coverage is Linux x64 / Chrome 153 / Node 24. Other browser engines and host workflows remain unverified. The conditional `real-web-runtime` CI lane acquires Chrome only for relevant changes; ordinary unit/static checks need no browser.
|
||||
|
||||
Primary protocol references: [Profiler and Debugger](https://raw.githubusercontent.com/ChromeDevTools/devtools-protocol/master/pdl/js_protocol.pdl), [DOMDebugger](https://raw.githubusercontent.com/ChromeDevTools/devtools-protocol/master/pdl/domains/DOMDebugger.pdl).
|
||||
|
||||
Source ownership requires both the selected main frame and producer evidence for its default execution world. Extension/automation isolated worlds, worker worlds and unknown worlds cannot establish website source joins. Contradictory world metadata stays unowned. Referenced source records retain the reported execution context and script auxiliary context metadata; source text is read only for proven main-document default worlds.
|
||||
|
||||
Known script/context identities are monitored through asynchronous source completion after the sample cutoff. A changed identity excludes its retained text, frame attribution, coverage and request/source joins. Chromium's specific native CSS syntax rejection returns an input error for `selector`, including the selected target; unrelated node, command and transport failures retain their original category.
|
||||
+12
-2
@@ -3,6 +3,10 @@ set -euo pipefail
|
||||
|
||||
PACKAGE="rea-agents"
|
||||
REPOSITORY="morluto/rea"
|
||||
numeric_identifier='(0|[1-9][0-9]*)'
|
||||
prerelease_identifier="(${numeric_identifier}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)"
|
||||
build_identifier='[0-9A-Za-z-]+'
|
||||
version_pattern="${numeric_identifier}\\.${numeric_identifier}\\.${numeric_identifier}(-${prerelease_identifier}(\\.${prerelease_identifier})*)?(\\+${build_identifier}(\\.${build_identifier})*)?"
|
||||
version="${REA_VERSION:-}"
|
||||
dry_run=false
|
||||
start_setup=true
|
||||
@@ -67,14 +71,20 @@ if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] ||
|
||||
fi
|
||||
|
||||
if [[ -n "$version" ]]; then
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail "version must be an exact semantic version."
|
||||
[[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version."
|
||||
else
|
||||
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
|
||||
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION."
|
||||
[[ "$tag" =~ ^rea-agents-([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
|
||||
[[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
|
||||
version="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# npm's exact-version resolver cleans build metadata from the requested version,
|
||||
# so it may install the base release and make the post-install identity check fail.
|
||||
if [[ "$version" =~ \+ ]]; then
|
||||
fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry."
|
||||
fi
|
||||
|
||||
prefix_args=()
|
||||
if [[ "$platform" == "Linux" ]]; then
|
||||
prefix_args=(--prefix "$HOME/.local")
|
||||
|
||||
@@ -284,7 +284,7 @@ static Handle createPrivateFile(Runtime& root, const std::wstring& relative) {
|
||||
verifyPrivate(parent->get(), parentPath, parentPath == root.path);
|
||||
Security security;
|
||||
auto file = createRelative(parent->get(), path.substr(path.find_last_of(L'\\') + 1),
|
||||
GENERIC_WRITE | GENERIC_READ | READ_CONTROL, false, security, path);
|
||||
DELETE | GENERIC_WRITE | GENERIC_READ | READ_CONTROL, false, security, path);
|
||||
verifyPrivate(file.get(), path, false);
|
||||
return file;
|
||||
}
|
||||
@@ -412,6 +412,18 @@ static void copySnapshot(napi_env, void* pointer) {
|
||||
static void completeSnapshot(napi_env env, napi_status status, void* pointer) {
|
||||
std::unique_ptr<SnapshotWork> work(static_cast<SnapshotWork*>(pointer));
|
||||
work->root->snapshotPending = false;
|
||||
const auto reject = [&](const Failure& failure) {
|
||||
try {
|
||||
dispose(work->destination.get(), work->path);
|
||||
work->destination.reset();
|
||||
napi_reject_deferred(env, work->deferred, failureValue(env, failure));
|
||||
} catch (const Failure& cleanupFailure) {
|
||||
const Failure combined(
|
||||
failure.constraint + "; incomplete snapshot cleanup failed: " + cleanupFailure.constraint,
|
||||
work->path, cleanupFailure.win32);
|
||||
napi_reject_deferred(env, work->deferred, failureValue(env, combined));
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (work->failure) throw *work->failure;
|
||||
require(status == napi_ok && !work->root->snapshotCancelled.load(), "Native snapshot worker was cancelled", work->path, ERROR_OPERATION_ABORTED);
|
||||
@@ -422,10 +434,9 @@ static void completeSnapshot(napi_env env, napi_status status, void* pointer) {
|
||||
work->root->immutableFiles.push_back(std::move(work->destination));
|
||||
check(napi_resolve_deferred(env, work->deferred, result));
|
||||
} catch (const Failure& failure) {
|
||||
napi_reject_deferred(env, work->deferred, failureValue(env, failure));
|
||||
reject(failure);
|
||||
} catch (const std::exception& failure) {
|
||||
napi_reject_deferred(env, work->deferred,
|
||||
failureValue(env, Failure(failure.what(), work->path, ERROR_GEN_FAILURE)));
|
||||
reject(Failure(failure.what(), work->path, ERROR_GEN_FAILURE));
|
||||
}
|
||||
napi_delete_reference(env, work->rootReference);
|
||||
napi_delete_async_work(env, work->work);
|
||||
@@ -506,7 +517,13 @@ napi_value filesystemCall(napi_env env, const std::wstring& operation, const std
|
||||
auto file = runtimeDirectory(root, path.substr(0, path.find_last_of(L'\\')));
|
||||
file->path = path;
|
||||
file->requestedPath = path;
|
||||
file->handles.push_back(openComponent(path, GENERIC_READ, false));
|
||||
// Retained output handles have write and DELETE access, so this read open
|
||||
// must share both to pass Windows' symmetric share check. Those retained
|
||||
// handles still omit FILE_SHARE_WRITE and FILE_SHARE_DELETE, blocking
|
||||
// competing writes and delete opens.
|
||||
file->handles.push_back(openComponent(path, GENERIC_READ, false,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE |
|
||||
FILE_SHARE_DELETE));
|
||||
verifyPrivate(file->get(), path, false);
|
||||
auto result = identity(env, file->get(), path);
|
||||
set(env, result, "handle", wrap(env, std::move(file)));
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "4.1.0",
|
||||
"version": "5.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "4.1.0",
|
||||
"version": "5.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "8.0.4",
|
||||
|
||||
+9
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"mcpName": "io.github.morluto/rea",
|
||||
"version": "4.1.0",
|
||||
"version": "5.0.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -83,7 +83,7 @@
|
||||
"check:pr": "turbo run typecheck lint format:check knip metadata:check test:run && npm run docs:check && npm run jscpd && npm run scan:todos",
|
||||
"precheck:ci": "npm run deps:check",
|
||||
"check:ci": "turbo run typecheck lint format:check knip metadata:check jscpd scan:todos",
|
||||
"build:cached": "turbo run build",
|
||||
"build:cached": "node scripts/check-dependency-install.mjs && turbo run build",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check .",
|
||||
"lint": "oxlint && npm run verify:module-boundaries",
|
||||
@@ -121,17 +121,17 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify:test-temp-hygiene": "npm run build:cached && node scripts/verify-test-temp-hygiene.mjs",
|
||||
"verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs",
|
||||
"verify:apple-dispatch": "npm run build:cached && node scripts/verify-apple-dispatch.mjs",
|
||||
"verify:apple-dispatch": "npm run build:cached && node scripts/verify/apple/dispatch.mjs",
|
||||
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
|
||||
"verify:keyed-archive": "npm run build:cached && node scripts/verify-keyed-archive.mjs",
|
||||
"verify:macos-bundle": "npm run build:cached && node scripts/verify-macos-bundle.mjs",
|
||||
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",
|
||||
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
|
||||
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
|
||||
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
|
||||
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
|
||||
"verify:interface-builder": "npm run build:cached && node scripts/verify-interface-builder.mjs",
|
||||
"verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs",
|
||||
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
|
||||
"verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs",
|
||||
"verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs",
|
||||
@@ -171,7 +171,7 @@
|
||||
"docs:error-schema": "npm run build:cached && node scripts/generate-error-schema.mjs",
|
||||
"prepack": "node scripts/prepack.mjs",
|
||||
"prepare": "node scripts/prepare.mjs",
|
||||
"verify:asset-catalog": "npm run build:cached && node scripts/verify-asset-catalog.mjs",
|
||||
"verify:asset-catalog": "npm run build:cached && node scripts/verify/apple/asset-catalog.mjs",
|
||||
"verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial",
|
||||
"test:focused": "node scripts/run-development-tests.mjs focused",
|
||||
"build:windows-native": "node scripts/build-windows-native.mjs",
|
||||
@@ -187,7 +187,8 @@
|
||||
"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",
|
||||
"verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs"
|
||||
"verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs",
|
||||
"verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
/** Owned synthetic site keeps source bytes, repeated URLs and expected execution independent of REA. */
|
||||
export async function startBrowserRuntimeSite() {
|
||||
let origin;
|
||||
let evidenceRequests = 0;
|
||||
const sources = () => ({
|
||||
selected: `// 🔥 UTF-16 prefix\r\nfunction chosen() {\r\n history.pushState({}, '', '/same-document?caller=value#fragment');\r\n fetch('/evidence?marker=chosen');\r\n if (document.body.dataset.branch === 'unused') { fetch('/never'); }\r\n}\r\ndocument.querySelector('#run').addEventListener('click', chosen, { passive: true });\r\n//# sourceURL=${origin.replace("http://", "http://declared:label@")}/same.js\r\n`,
|
||||
other: `function untouched() { return 'other source'; }\r\ndocument.querySelector('#child').addEventListener('click', untouched);\r\n//# sourceURL=${origin.replace("http://", "http://declared:label@")}/same.js\r\n`,
|
||||
});
|
||||
const server = createServer((request, response) => {
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
if (request.url?.startsWith("/evidence")) {
|
||||
evidenceRequests += 1;
|
||||
response.end("ok");
|
||||
return;
|
||||
}
|
||||
if (request.url === "/a.js" || request.url === "/b.js") {
|
||||
response.setHeader(
|
||||
"content-type",
|
||||
"application/javascript; charset=utf-8",
|
||||
);
|
||||
response.end(
|
||||
request.url === "/a.js" ? sources().selected : sources().other,
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.end(
|
||||
'<!doctype html><meta charset="utf-8"><button id="run"><span id="child">Run</span></button><script src="/a.js"></script><script src="/b.js"></script>',
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Runtime fixture did not bind TCP");
|
||||
origin = `http://127.0.0.1:${address.port}`;
|
||||
return {
|
||||
origin,
|
||||
sources: sources(),
|
||||
evidenceRequests: () => evidenceRequests,
|
||||
close: () =>
|
||||
new Promise((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -24,8 +24,10 @@ SUPPORTED_HOPPER_SHA256 = {
|
||||
"1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13",
|
||||
}
|
||||
EXPECTED_SCREEN = (1280, 1024)
|
||||
EXPECTED_DIALOG = (189, 370, 901, 284)
|
||||
DEMO_CLICK = (305, 632)
|
||||
DIALOG_TITLES = {"Registration", "Registration — Hopper Disassembler"}
|
||||
HOPPER_WINDOW_TITLE = "Hopper Disassembler"
|
||||
DEMO_CLICK_LEFT = 116
|
||||
DEMO_CLICK_BOTTOM = 22
|
||||
PRIVATE_DISPLAY_UNAVAILABLE = 70
|
||||
X11_AUTHORIZATION_FAILED = 71
|
||||
UNSUPPORTED_HOPPER_BUILD = 72
|
||||
@@ -197,6 +199,47 @@ def windows(x11: ctypes.CDLL, display: int, root: int) -> list[tuple[int, int, i
|
||||
return found
|
||||
|
||||
|
||||
def window_title(x11: ctypes.CDLL, display: int, window: int) -> str | None:
|
||||
title = ctypes.c_char_p()
|
||||
if not x11.XFetchName(display, window, ctypes.byref(title)) or not title.value:
|
||||
return None
|
||||
try:
|
||||
return title.value.decode("utf-8", "replace")
|
||||
finally:
|
||||
x11.XFree(ctypes.cast(title, ctypes.c_void_p))
|
||||
|
||||
|
||||
def window_transient_for(
|
||||
x11: ctypes.CDLL, display: int, window: int
|
||||
) -> int | None:
|
||||
parent = ctypes.c_ulong()
|
||||
if not x11.XGetTransientForHint(display, window, ctypes.byref(parent)):
|
||||
return None
|
||||
return int(parent.value)
|
||||
|
||||
|
||||
def find_demo_dialog(
|
||||
x11: ctypes.CDLL,
|
||||
display: int,
|
||||
observed: list[tuple[int, int, int, int, int]],
|
||||
) -> tuple[int, int, int, int, int] | None:
|
||||
for candidate in observed:
|
||||
window = candidate[0]
|
||||
if window_title(x11, display, window) not in DIALOG_TITLES:
|
||||
continue
|
||||
parent = window_transient_for(x11, display, window)
|
||||
if parent is None:
|
||||
continue
|
||||
if window_title(x11, display, parent) == HOPPER_WINDOW_TITLE:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def demo_click(dialog: tuple[int, int, int, int, int]) -> tuple[int, int]:
|
||||
_window, x, y, _width, height = dialog
|
||||
return x + DEMO_CLICK_LEFT, y + height - DEMO_CLICK_BOTTOM
|
||||
|
||||
|
||||
def configure_x11(x11: ctypes.CDLL, xtst: ctypes.CDLL) -> None:
|
||||
window_pointer = ctypes.POINTER(ctypes.c_ulong)
|
||||
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
|
||||
@@ -213,6 +256,18 @@ def configure_x11(x11: ctypes.CDLL, xtst: ctypes.CDLL) -> None:
|
||||
ctypes.POINTER(XWindowAttributes),
|
||||
]
|
||||
x11.XGetWindowAttributes.restype = ctypes.c_int
|
||||
x11.XFetchName.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
x11.XFetchName.restype = ctypes.c_int
|
||||
x11.XGetTransientForHint.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
]
|
||||
x11.XGetTransientForHint.restype = ctypes.c_int
|
||||
x11.XQueryTree.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
@@ -645,6 +700,7 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
|
||||
deadline = time.monotonic() + 10
|
||||
observed: list[tuple[int, int, int, int, int]] = []
|
||||
dialog: tuple[int, int, int, int, int] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
if socket.exists():
|
||||
x11.XCloseDisplay(display)
|
||||
@@ -660,7 +716,8 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
"owned Hopper process exited",
|
||||
)
|
||||
observed = windows(x11, display, root)
|
||||
if any(window[1:] == EXPECTED_DIALOG for window in observed):
|
||||
dialog = find_demo_dialog(x11, display, observed)
|
||||
if dialog is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
@@ -676,8 +733,10 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
"expected Hopper demo dialog not found",
|
||||
)
|
||||
|
||||
assert dialog is not None
|
||||
click_x, click_y = demo_click(dialog)
|
||||
input_results = (
|
||||
xtst.XTestFakeMotionEvent(display, -1, DEMO_CLICK[0], DEMO_CLICK[1], 0),
|
||||
xtst.XTestFakeMotionEvent(display, -1, click_x, click_y, 0),
|
||||
xtst.XTestFakeButtonEvent(display, 1, True, 0),
|
||||
xtst.XTestFakeButtonEvent(display, 1, False, 0),
|
||||
)
|
||||
@@ -696,9 +755,8 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
finally:
|
||||
if display is not None:
|
||||
x11.XCloseDisplay(display)
|
||||
if child is not None and child.poll() is None:
|
||||
child.terminate()
|
||||
child.wait()
|
||||
if child is not None:
|
||||
stop_process(child)
|
||||
stop_process(xvfb)
|
||||
capture.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { access } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { CdpBrowserProvider } from "../../dist/browser/CdpBrowserProvider.js";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js";
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import { webExecutionSchema } from "../../dist/domain/webExecution.js";
|
||||
import { webEventListenersSchema } from "../../dist/domain/webEventListeners.js";
|
||||
import { startBrowserRuntimeSite } from "../fixtures/browser-runtime-site.mjs";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
import {
|
||||
startRuntimeFixtureBrowser,
|
||||
closeRuntimeFixtureResources,
|
||||
} from "./browser-runtime-fixture-lifecycle.mjs";
|
||||
import { verifyRuntimeTargetTermination } from "./browser-runtime-termination-e2e.mjs";
|
||||
import { verifyRuntimeInvalidSelectors } from "./browser-runtime-selector-e2e.mjs";
|
||||
import {
|
||||
createRuntimeIsolatedWorld,
|
||||
triggerRuntimeFixtureAction,
|
||||
isolatedFixtureUrl,
|
||||
} from "./browser-runtime-world-e2e.mjs";
|
||||
|
||||
/** Exercise native browser runtime attribution through public CLI and stdio MCP, including arming. */
|
||||
export async function verifyBrowserRuntime(
|
||||
executable,
|
||||
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
) {
|
||||
await access(executable);
|
||||
const site = await startBrowserRuntimeSite();
|
||||
let fixture;
|
||||
let primaryError;
|
||||
let stderr = "";
|
||||
let action;
|
||||
let client;
|
||||
try {
|
||||
fixture = await startRuntimeFixtureBrowser(executable, site.origin);
|
||||
const { browser, profile } = fixture;
|
||||
browser.stderr.on("data", (chunk) => {
|
||||
if (stderr.length < 65536) stderr += chunk;
|
||||
});
|
||||
const port = await waitForBrowserDevtoolsPort({
|
||||
child: browser,
|
||||
executable,
|
||||
activePortPath: join(profile, "DevToolsActivePort"),
|
||||
stderr: () => stderr,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const endpoint = `http://127.0.0.1:${port}`;
|
||||
const targets = await new CdpBrowserProvider().listTargets({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [site.origin],
|
||||
});
|
||||
if (!targets.ok) throw targets.error;
|
||||
const target = targets.value.targets[0];
|
||||
assert.ok(target, "owned fixture page target missing");
|
||||
const pages = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
const page = pages.find((candidate) => candidate.id === target.target_id);
|
||||
action = await CdpConnection.connect(
|
||||
page.webSocketDebuggerUrl,
|
||||
"inspect_web_page",
|
||||
);
|
||||
await ready(action);
|
||||
let isolatedContext = await createRuntimeIsolatedWorld(action);
|
||||
const env = {
|
||||
...process.env,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper",
|
||||
};
|
||||
const input = { cdp_endpoint: endpoint, target_id: target.target_id };
|
||||
const cliListener = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-web-event-listeners",
|
||||
endpoint,
|
||||
target.target_id,
|
||||
"#run",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 40_000, maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
const cliListenerProof = assertListeners(
|
||||
parseEvidence(JSON.parse(cliListener.stdout)),
|
||||
site,
|
||||
);
|
||||
const cliExecution = await executionCli(
|
||||
entrypoint,
|
||||
input,
|
||||
env,
|
||||
action,
|
||||
isolatedContext,
|
||||
);
|
||||
const cliProof = assertExecution(
|
||||
parseEvidence(JSON.parse(cliExecution)),
|
||||
site,
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
client = new Client({ name: "web-runtime-real-e2e", version: "1" });
|
||||
await client.connect(transport);
|
||||
const invalidSelectors = await verifyRuntimeInvalidSelectors(
|
||||
client,
|
||||
entrypoint,
|
||||
input,
|
||||
env,
|
||||
);
|
||||
const listenerResponse = await client.callTool({
|
||||
name: "inspect_web_event_listeners",
|
||||
arguments: { ...input, selector: "#run" },
|
||||
});
|
||||
assert.notEqual(
|
||||
listenerResponse.isError,
|
||||
true,
|
||||
mcpTextValue(listenerResponse),
|
||||
);
|
||||
const mcpListenerProof = assertListeners(
|
||||
parseEvidence(JSON.parse(mcpTextValue(listenerResponse)).evidence),
|
||||
site,
|
||||
);
|
||||
const mcpProof = await executionMcp(
|
||||
client,
|
||||
input,
|
||||
action,
|
||||
site,
|
||||
isolatedContext,
|
||||
false,
|
||||
);
|
||||
assert.equal(cliProof.selected_sha256, mcpProof.selected_sha256);
|
||||
assert.equal(cliListenerProof.sha256, mcpListenerProof.sha256);
|
||||
await reloadFixture(action);
|
||||
isolatedContext = await createRuntimeIsolatedWorld(action);
|
||||
const freshMcpProof = await executionMcp(
|
||||
client,
|
||||
input,
|
||||
action,
|
||||
site,
|
||||
isolatedContext,
|
||||
);
|
||||
assert.equal(site.evidenceRequests(), 3);
|
||||
const termination = await verifyRuntimeTargetTermination(
|
||||
client,
|
||||
input,
|
||||
site.origin,
|
||||
);
|
||||
const stillOpen = await action.send("Runtime.evaluate", {
|
||||
expression: "Boolean(document.querySelector('#run'))",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(
|
||||
stillOpen.result.value,
|
||||
true,
|
||||
"REA must preserve the externally owned page",
|
||||
);
|
||||
const location = await action.send("Runtime.evaluate", {
|
||||
expression: "location.href",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(
|
||||
location.result.value,
|
||||
`${site.origin}/same-document?caller=value#fragment`,
|
||||
);
|
||||
return {
|
||||
product: target,
|
||||
cli: cliProof,
|
||||
mcp: mcpProof,
|
||||
listener: cliListenerProof,
|
||||
public_cases: 8,
|
||||
invalid_selectors: invalidSelectors,
|
||||
termination,
|
||||
fresh_mcp: freshMcpProof,
|
||||
page_remained_open: true,
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await closeRuntimeFixtureResources(
|
||||
[
|
||||
() => client?.close(),
|
||||
() => action?.close(),
|
||||
() => fixture?.close(),
|
||||
() => site.close(),
|
||||
],
|
||||
primaryError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executionMcp(
|
||||
client,
|
||||
input,
|
||||
action,
|
||||
site,
|
||||
isolatedContext,
|
||||
requireBlock = true,
|
||||
) {
|
||||
let actionPromise;
|
||||
const response = await client.callTool(
|
||||
{
|
||||
name: "observe_web_execution",
|
||||
arguments: { ...input, observation_ms: 300 },
|
||||
},
|
||||
{
|
||||
timeout: 40_000,
|
||||
onprogress: (notification) => {
|
||||
if (
|
||||
actionPromise === undefined &&
|
||||
notification.message?.includes(
|
||||
"browser_execution: Browser execution observation armed",
|
||||
)
|
||||
) {
|
||||
actionPromise = triggerRuntimeFixtureAction(action, isolatedContext);
|
||||
void actionPromise.catch(() => undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(actionPromise, "MCP did not emit an actual armed notification");
|
||||
await actionPromise;
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
return assertExecution(
|
||||
parseEvidence(JSON.parse(mcpTextValue(response)).evidence),
|
||||
site,
|
||||
requireBlock,
|
||||
);
|
||||
}
|
||||
|
||||
async function ready(connection) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const result = await connection.send("Runtime.evaluate", {
|
||||
expression:
|
||||
"typeof chosen === 'function' && typeof untouched === 'function'",
|
||||
returnByValue: true,
|
||||
});
|
||||
if (result.result.value === true) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("Runtime fixture sources did not load");
|
||||
}
|
||||
|
||||
async function executionCli(entrypoint, input, env, action, isolatedContext) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"observe-web-execution",
|
||||
input.cdp_endpoint,
|
||||
input.target_id,
|
||||
"--observation-ms",
|
||||
"300",
|
||||
"--json",
|
||||
],
|
||||
{ env, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let actionPromise;
|
||||
const timeout = setTimeout(() => child.kill("SIGTERM"), 40_000);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
if (actionPromise === undefined && stderr.includes('"completed":1')) {
|
||||
actionPromise = triggerRuntimeFixtureAction(action, isolatedContext);
|
||||
void actionPromise.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const [code] = await once(child, "close");
|
||||
assert.equal(code, 0, stderr);
|
||||
assert.ok(actionPromise, "CLI did not report an actual armed point");
|
||||
await actionPromise;
|
||||
return stdout;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
function sourceByText(result, text) {
|
||||
const source = result.sources.find(
|
||||
(item) => item.source.state === "captured" && item.source.text === text,
|
||||
);
|
||||
assert.ok(source, "Exact independently known source text must be retained");
|
||||
assert.equal(
|
||||
source.source.sha256,
|
||||
createHash("sha256").update(text).digest("hex"),
|
||||
);
|
||||
assert.equal(source.source.utf16_units, text.length);
|
||||
assert.equal(source.source.utf8_bytes, Buffer.byteLength(text));
|
||||
return source;
|
||||
}
|
||||
function assertListeners(evidence, site) {
|
||||
const result = webEventListenersSchema.parse(evidence.normalized_result);
|
||||
assert.equal(
|
||||
result.listeners.length,
|
||||
1,
|
||||
"The selected node's child listener must not be attributed to the selected node",
|
||||
);
|
||||
const listener = result.listeners.find((item) => item.type === "click");
|
||||
assert.ok(listener);
|
||||
assert.equal(listener.passive, true);
|
||||
const source = sourceByText(result, site.sources.selected);
|
||||
assert.equal(listener.location.script_id, source.script_id);
|
||||
assert.equal(listener.location.source_association, "script_id");
|
||||
assert.equal(listener.execution, "unknown");
|
||||
return {
|
||||
sha256: source.source.sha256,
|
||||
line: listener.location.line_number,
|
||||
column: listener.location.column_number,
|
||||
};
|
||||
}
|
||||
function assertExecution(evidence, site, requireBlock = true) {
|
||||
const result = webExecutionSchema.parse(evidence.normalized_result);
|
||||
assert.ok(
|
||||
result.coverage.scripts.every(
|
||||
(item) => item.reported_url !== isolatedFixtureUrl,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
result.sources.every(
|
||||
(item) =>
|
||||
item.url !== isolatedFixtureUrl || item.source.state !== "captured",
|
||||
),
|
||||
);
|
||||
const selected = sourceByText(result, site.sources.selected);
|
||||
const other = sourceByText(result, site.sources.other);
|
||||
assert.equal(selected.url, other.url);
|
||||
assert.equal(selected.has_source_url, true);
|
||||
assert.equal(
|
||||
selected.url,
|
||||
`${site.origin.replace("http://", "http://declared:label@")}/same.js`,
|
||||
);
|
||||
assert.notEqual(selected.script_id, other.script_id);
|
||||
assert.ok(
|
||||
selected.source.utf8_bytes > selected.source.utf16_units,
|
||||
"Unicode oracle must distinguish UTF-8 bytes from UTF-16 units",
|
||||
);
|
||||
const coverage = result.coverage.scripts.find(
|
||||
(script) => script.script_id === selected.script_id,
|
||||
);
|
||||
const fn = coverage?.functions.find((item) => item.name === "chosen");
|
||||
assert.ok(
|
||||
fn,
|
||||
"Externally invoked callback must have precise function evidence",
|
||||
);
|
||||
assert.ok(fn.ranges.some((range) => range.count === 1));
|
||||
if (requireBlock)
|
||||
assert.equal(
|
||||
fn.is_block_coverage,
|
||||
true,
|
||||
"Fresh fixture function must supply block granularity",
|
||||
);
|
||||
if (fn.is_block_coverage)
|
||||
assert.ok(
|
||||
fn.ranges.some((range) => range.count === 0),
|
||||
`Unexecuted branch must remain zero within block coverage: ${JSON.stringify(fn)}`,
|
||||
);
|
||||
assert.equal(
|
||||
fn.ranges[0].start_offset,
|
||||
site.sources.selected.indexOf("function chosen"),
|
||||
"Coverage offsets must index the retained UTF-16 source, including the astral prefix",
|
||||
);
|
||||
for (const range of fn.ranges) {
|
||||
assert.equal(range.source_bounds, "verified");
|
||||
assert.ok(range.end_offset <= selected.source.utf16_units);
|
||||
}
|
||||
const request = result.requests.find((item) =>
|
||||
item.url.endsWith("/evidence?marker=chosen"),
|
||||
);
|
||||
assert.ok(request, "The selected request initiator must be retained");
|
||||
assert.ok(
|
||||
request.callsites.some(
|
||||
(site) =>
|
||||
site.script_id === selected.script_id &&
|
||||
site.source_association === "script_id",
|
||||
),
|
||||
);
|
||||
assert.equal(request.causal_attribution, "unknown");
|
||||
assert.ok(
|
||||
request.callsites.some(
|
||||
(site) =>
|
||||
site.script_id === selected.script_id && site.url === selected.url,
|
||||
),
|
||||
);
|
||||
assert.equal(result.script_inventory.coverage_absence, "unknown");
|
||||
assert.equal(result.instrumentation.cleanup, "confirmed");
|
||||
return {
|
||||
selected_sha256: selected.source.sha256,
|
||||
other_sha256: other.source.sha256,
|
||||
zero_branch: fn.is_block_coverage
|
||||
? "observed-zero"
|
||||
: "unknown-function-only",
|
||||
granularity: fn.is_block_coverage ? "block" : "function",
|
||||
repeated_url_identity: true,
|
||||
same_document_navigation: true,
|
||||
declared_source_url_preserved: true,
|
||||
request_script_id: selected.script_id,
|
||||
isolated_execution_proved_and_excluded: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadFixture(connection) {
|
||||
await connection.send("Page.enable");
|
||||
let dispose;
|
||||
const committed = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("Fixture reload did not commit")),
|
||||
5000,
|
||||
);
|
||||
const remove = connection.onEvent((event) => {
|
||||
if (
|
||||
event.method === "Page.frameNavigated" &&
|
||||
!event.params.frame?.parentId
|
||||
)
|
||||
resolve();
|
||||
});
|
||||
dispose = () => {
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
};
|
||||
});
|
||||
void committed.catch(() => undefined);
|
||||
try {
|
||||
await connection.send("Page.reload", { ignoreCache: true });
|
||||
await committed;
|
||||
await ready(connection);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnOwnedProviderProcess } from "../../dist/process/ProviderProcess.js";
|
||||
import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js";
|
||||
import { liveProcesses } from "../../dist/process/ProcessOwnershipProcessTree.js";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
|
||||
/** Launch only the synthetic fixture browser in its own verified process group. */
|
||||
export async function startRuntimeFixtureBrowser(executable, origin) {
|
||||
const profile = await mkdtemp(join(tmpdir(), "rea-real-web-runtime-"));
|
||||
let owned;
|
||||
try {
|
||||
owned = await spawnOwnedProviderProcess({
|
||||
command: executable,
|
||||
arguments: [
|
||||
"--headless=new",
|
||||
...(process.env.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${profile}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-sync",
|
||||
origin,
|
||||
],
|
||||
runId: randomUUID(),
|
||||
// Installed Chrome launch scripts exec another binary; do not require an argv prefix.
|
||||
expectedCommand: null,
|
||||
});
|
||||
owned.process.stdout?.resume();
|
||||
return {
|
||||
browser: owned.process,
|
||||
profile,
|
||||
close: async () => {
|
||||
await stopOwnedBrowser(owned, profile);
|
||||
await rm(profile, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
},
|
||||
};
|
||||
} catch (cause) {
|
||||
if (owned !== undefined) await stopOwnedBrowser(owned, profile);
|
||||
await rm(profile, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopOwnedBrowser(owned, profile) {
|
||||
const child = owned.process;
|
||||
const exited =
|
||||
child.exitCode === null && child.signalCode === null
|
||||
? once(child, "exit")
|
||||
: Promise.resolve();
|
||||
void exited.catch(() => undefined);
|
||||
let connection;
|
||||
let timer;
|
||||
const escalation = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGKILL");
|
||||
}, 2_000);
|
||||
try {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
try {
|
||||
const [port, browserPath] = (
|
||||
await readFile(join(profile, "DevToolsActivePort"), "utf8")
|
||||
)
|
||||
.trim()
|
||||
.split("\n");
|
||||
assert.match(port, /^\d+$/);
|
||||
assert.match(browserPath, /^\/devtools\/browser\/[^\s]+$/);
|
||||
const signal = AbortSignal.timeout(2_000);
|
||||
connection = await CdpConnection.connect(
|
||||
`ws://127.0.0.1:${port}${browserPath}`,
|
||||
"inspect_web_page",
|
||||
signal,
|
||||
);
|
||||
// This browser belongs to the verifier; public REA operations never close it.
|
||||
await connection.send("Browser.close", {}, undefined, signal);
|
||||
} catch {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
if (owned.cleanup !== undefined) {
|
||||
const result = await owned.cleanup();
|
||||
assert.equal(result.cleaned, true, JSON.stringify(result));
|
||||
}
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Owned fixture browser did not exit after shutdown"),
|
||||
),
|
||||
5_000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(escalation);
|
||||
clearTimeout(timer);
|
||||
await connection?.close();
|
||||
}
|
||||
if (process.platform === "win32") return;
|
||||
const host = createSystemProcessOwnershipHost();
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (true) {
|
||||
const members = liveProcesses(await host.listProcesses()).filter(
|
||||
(member) => member.processGroupId === owned.ownership.processGroupId,
|
||||
);
|
||||
if (members.length === 0) return;
|
||||
if (Date.now() >= deadline)
|
||||
throw new Error(
|
||||
`Owned fixture group remains: ${JSON.stringify(members.map(({ pid }) => pid))}`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
/** Attempt every owned cleanup and retain the original verification failure if cleanup also fails. */
|
||||
export async function closeRuntimeFixtureResources(tasks, primaryError) {
|
||||
const failures = [];
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
await task();
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(
|
||||
primaryError === undefined ? failures : [primaryError, ...failures],
|
||||
"Runtime fixture cleanup failed",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
|
||||
/** Verify native malformed-selector classification through the public CLI and MCP. */
|
||||
export async function verifyRuntimeInvalidSelectors(
|
||||
client,
|
||||
entrypoint,
|
||||
input,
|
||||
env,
|
||||
) {
|
||||
const cli = await new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-web-event-listeners",
|
||||
input.cdp_endpoint,
|
||||
input.target_id,
|
||||
"[",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 40_000, maxBuffer: 64 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
||||
);
|
||||
});
|
||||
assert.ok(cli.error, "Invalid native CSS must fail in the CLI");
|
||||
assert.equal(typeof cli.error.code, "number", cli.stderr);
|
||||
assertInputFailure(JSON.parse(cli.stdout), input.target_id);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_web_event_listeners",
|
||||
arguments: { ...input, selector: "[" },
|
||||
});
|
||||
assert.equal(response.isError, true, mcpTextValue(response));
|
||||
assertInputFailure(JSON.parse(mcpTextValue(response)).error, input.target_id);
|
||||
return { cli: "invalid_input", mcp: "invalid_input", selector: "[" };
|
||||
}
|
||||
|
||||
function assertInputFailure(failure, targetId) {
|
||||
assert.equal(failure.code, "invalid_request");
|
||||
assert.equal(failure.category, "invalid_input");
|
||||
assert.equal(failure.details.operation, "inspect_web_event_listeners");
|
||||
assert.ok(
|
||||
failure.details.issues.some(
|
||||
(issue) =>
|
||||
issue.path[0] === "selector" && issue.message.includes(targetId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
import { closeRuntimeFixtureResources } from "./browser-runtime-fixture-lifecycle.mjs";
|
||||
|
||||
/** Close a separately owned fixture page after actual MCP arming and inspect native cleanup diagnostics. */
|
||||
export async function verifyRuntimeTargetTermination(client, input, origin) {
|
||||
const version = await (
|
||||
await fetch(`${input.cdp_endpoint}/json/version`)
|
||||
).json();
|
||||
const root = await CdpConnection.connect(
|
||||
version.webSocketDebuggerUrl,
|
||||
"observe_web_execution",
|
||||
);
|
||||
let targetId;
|
||||
let closed = false;
|
||||
let primaryError;
|
||||
try {
|
||||
({ targetId } = await root.send("Target.createTarget", { url: origin }));
|
||||
assert.equal(typeof targetId, "string");
|
||||
await waitForTarget(input.cdp_endpoint, targetId, origin);
|
||||
let closePromise;
|
||||
const response = await client.callTool(
|
||||
{
|
||||
name: "observe_web_execution",
|
||||
arguments: {
|
||||
...input,
|
||||
target_id: targetId,
|
||||
observation_ms: 2_147_483_647,
|
||||
},
|
||||
},
|
||||
{
|
||||
timeout: 20_000,
|
||||
onprogress: (notification) => {
|
||||
if (
|
||||
closePromise === undefined &&
|
||||
notification.message?.includes(
|
||||
"browser_execution: Browser execution observation armed",
|
||||
)
|
||||
) {
|
||||
closePromise = root
|
||||
.send("Target.closeTarget", { targetId })
|
||||
.then((result) => {
|
||||
assert.equal(result.success, true);
|
||||
closed = true;
|
||||
});
|
||||
void closePromise.catch(() => undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(
|
||||
closePromise,
|
||||
"The separate page must be closed after actual arming",
|
||||
);
|
||||
await closePromise;
|
||||
assert.equal(
|
||||
response.isError,
|
||||
true,
|
||||
"Detached instrumentation cannot claim confirmed cleanup",
|
||||
);
|
||||
const failure = mcpTextValue(response);
|
||||
assert.match(failure, /cleanup.*confirmed/u);
|
||||
assert.ok(
|
||||
failure.includes(targetId),
|
||||
"Failure must retain the selected target identity",
|
||||
);
|
||||
assert.match(failure, /Profiler\.stopPreciseCoverage/u);
|
||||
return {
|
||||
target_id: targetId,
|
||||
page_closed_after_arming: true,
|
||||
failure_inline: true,
|
||||
cleanup: "unconfirmed_reported",
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
await closeRuntimeFixtureResources(
|
||||
[
|
||||
() =>
|
||||
targetId === undefined || closed
|
||||
? undefined
|
||||
: root.send("Target.closeTarget", { targetId }),
|
||||
() => root.close(),
|
||||
],
|
||||
primaryError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTarget(endpoint, targetId, origin) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const pages = await (await fetch(`${endpoint}/json/list`)).json();
|
||||
if (
|
||||
pages.some((page) => page.id === targetId && page.url.startsWith(origin))
|
||||
)
|
||||
return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(
|
||||
"Separate fixture target did not settle on its selected origin",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
export const isolatedFixtureUrl = "rea-isolated-fixture.js";
|
||||
|
||||
/** Create an explicitly owned native isolated-world fixture outside REA's public operations. */
|
||||
export async function createRuntimeIsolatedWorld(connection) {
|
||||
const tree = await connection.send("Page.getFrameTree");
|
||||
const world = await connection.send("Page.createIsolatedWorld", {
|
||||
frameId: tree.frameTree.frame.id,
|
||||
worldName: "rea-verifier-isolated",
|
||||
});
|
||||
const contextId = world.executionContextId;
|
||||
assert.ok(Number.isInteger(contextId));
|
||||
const initialized = await connection.send("Runtime.evaluate", {
|
||||
contextId,
|
||||
expression: `function reaIsolatedFixture() { return 'isolated-proof'; }
|
||||
//# sourceURL=${isolatedFixtureUrl}`,
|
||||
});
|
||||
assert.equal(initialized.exceptionDetails, undefined);
|
||||
return contextId;
|
||||
}
|
||||
|
||||
/** Invoke a known isolated callback and the selected site's callback after actual REA arming. */
|
||||
export async function triggerRuntimeFixtureAction(connection, contextId) {
|
||||
const proof = await connection.send("Runtime.evaluate", {
|
||||
contextId,
|
||||
expression: "reaIsolatedFixture()",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(proof.result.value, "isolated-proof");
|
||||
return connection.send("Runtime.evaluate", {
|
||||
expression: "document.querySelector('#run').click()",
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export const toolFamilyCatalog = (sources) => {
|
||||
surface: "browser-provider",
|
||||
contracts: [
|
||||
...sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
...sources.webRuntimeContracts.WEB_RUNTIME_TOOL_CONTRACTS,
|
||||
...sources.browserScenarioContracts.BROWSER_SCENARIO_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
@@ -171,7 +172,10 @@ export const providerCatalog = (sources) => {
|
||||
},
|
||||
{
|
||||
identity: sources.browserProvider.CDP_BROWSER_PROVIDER_IDENTITY,
|
||||
contracts: sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
contracts: [
|
||||
...sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
...sources.webRuntimeContracts.WEB_RUNTIME_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
identity:
|
||||
|
||||
@@ -40,7 +40,7 @@ const SOURCE_PATHS = {
|
||||
officialContracts: "dist/contracts/officialToolContracts.js",
|
||||
enhancedContracts: "dist/contracts/enhancedToolContracts.js",
|
||||
sessionContracts: "dist/contracts/sessionToolContracts.js",
|
||||
nativeContracts: "dist/contracts/nativeToolContracts.js",
|
||||
nativeContracts: "dist/contracts/native/nativeToolContracts.js",
|
||||
artifactContracts: "dist/contracts/artifactToolContracts.js",
|
||||
managedContracts: "dist/contracts/managed/managedToolContracts.js",
|
||||
firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js",
|
||||
@@ -50,6 +50,7 @@ const SOURCE_PATHS = {
|
||||
managedWorkflowContracts:
|
||||
"dist/contracts/managed/managedWorkflowToolContracts.js",
|
||||
browserContracts: "dist/contracts/browserToolContracts.js",
|
||||
webRuntimeContracts: "dist/contracts/webRuntimeToolContracts.js",
|
||||
browserScenarioContracts: "dist/contracts/browserScenarioToolContracts.js",
|
||||
electronContracts: "dist/contracts/javascript/electronToolContracts.js",
|
||||
javascriptRuntimeObservationContracts:
|
||||
|
||||
@@ -69,6 +69,7 @@ const failedBoundary = (file, target) => {
|
||||
return "provider-construction";
|
||||
if (
|
||||
(file.startsWith("src/artifacts/javascript/") ||
|
||||
file.startsWith("src/artifacts/apple/") ||
|
||||
/^src\/artifacts\/ArtifactHash\.(?:js|ts)$/u.test(file)) &&
|
||||
(["application", "composition", "server", "cli", "main"].includes(
|
||||
targetLayer,
|
||||
@@ -88,12 +89,14 @@ const failedBoundary = (file, target) => {
|
||||
)
|
||||
return "pure-layer";
|
||||
}
|
||||
if (file === "src/application/runtime.ts") return undefined;
|
||||
if (layer === "application" && targetLayer === "composition")
|
||||
return "application-composition";
|
||||
if (
|
||||
(layer === "application" || layer === "server") &&
|
||||
(MIGRATED_PROVIDER_ROOTS.has(targetLayer) ||
|
||||
/^src\/(?:artifacts\/ArtifactProvider|dotnet\/ManagedStaticProvider|native\/NativeMacOSProvider)\.[^/]+$/u.test(
|
||||
target,
|
||||
) ||
|
||||
/^src\/browser\/[^/]*Provider\.[^/]+$/u.test(target))
|
||||
)
|
||||
return "provider-construction";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { parseEvidence } from "../../dist/domain/evidence.js";
|
||||
import { analysisProfileSchema } from "../../dist/domain/analysisProfile.js";
|
||||
import { nativeValueTraceSchema } from "../../dist/domain/nativeValueTrace.js";
|
||||
import { nativeValueTraceSchema } from "../../dist/domain/native/nativeValueTrace.js";
|
||||
|
||||
const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
|
||||
const environment = () => ({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Package the public Notes example for the static website."""
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
||||
|
||||
|
||||
EXAMPLE_FILES = (
|
||||
"package.json",
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"renderer.js",
|
||||
"csv.js",
|
||||
"index.html",
|
||||
)
|
||||
|
||||
|
||||
def prepare_notes_example(site):
|
||||
"""Create a ZIP with public source only and reproducible metadata."""
|
||||
source = site / "examples/notes-electron"
|
||||
contents = BytesIO()
|
||||
with ZipFile(contents, "w") as archive:
|
||||
for name in EXAMPLE_FILES:
|
||||
entry = ZipInfo(f"notes-example/{name}", (1980, 1, 1, 0, 0, 0))
|
||||
entry.create_system = 3
|
||||
entry.external_attr = 0o100644 << 16
|
||||
archive.writestr(
|
||||
entry,
|
||||
(source / name).read_bytes(),
|
||||
compress_type=ZIP_DEFLATED,
|
||||
compresslevel=9,
|
||||
)
|
||||
destination = site / "examples/notes-example.zip"
|
||||
destination.write_bytes(contents.getvalue())
|
||||
print(f"Prepared examples/notes-example.zip ({len(EXAMPLE_FILES)} files).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
prepare_notes_example(root / "website/public")
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { verifyBrowserRuntime } from "./lib/browser-runtime-e2e.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const executable = process.env.REA_BROWSER_EXECUTABLE;
|
||||
if (executable === undefined)
|
||||
throw new Error(
|
||||
"verify:browser:runtime requires caller-supplied REA_BROWSER_EXECUTABLE",
|
||||
);
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
await promisify(execFile)("ps", ["-p", String(process.pid), "-o", "pid="], {
|
||||
timeout: 5_000,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:browser:runtime requires the host ps command for owned fixture cleanup",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
const run = createVerifierRun();
|
||||
const result = await verifyBrowserRuntime(executable, process.argv[2]);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ status: "passed", ...result, verifier: await completeVerifierRun(run) },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { observeNativeUi } from "../dist/native/NativeUiObservation.js";
|
||||
import { NATIVE_UI_HELPER_MAX_BUFFER } from "../dist/native/NativeUiOutputBudget.js";
|
||||
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
@@ -30,7 +31,38 @@ try {
|
||||
"-o",
|
||||
childRetrievalTest,
|
||||
]);
|
||||
await promisify(execFile)(childRetrievalTest, []);
|
||||
const childRetrieval = await promisify(execFile)(childRetrievalTest, []);
|
||||
const [slashRichProducerJson, numericProducerJson] = childRetrieval.stdout
|
||||
.trimEnd()
|
||||
.split("\n");
|
||||
if (
|
||||
slashRichProducerJson === undefined ||
|
||||
numericProducerJson === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"Native UI serializer seam did not emit its producer bytes",
|
||||
);
|
||||
}
|
||||
const slashRichConsumerJson = JSON.stringify(
|
||||
JSON.parse(slashRichProducerJson),
|
||||
);
|
||||
if (slashRichProducerJson !== slashRichConsumerJson) {
|
||||
throw new Error(
|
||||
"Swift helper JSON bytes differ from the normalized JavaScript consumer representation",
|
||||
);
|
||||
}
|
||||
const numericConsumerJson = JSON.stringify(JSON.parse(numericProducerJson));
|
||||
if (
|
||||
Buffer.byteLength(numericProducerJson) <=
|
||||
Buffer.byteLength(numericConsumerJson)
|
||||
) {
|
||||
throw new Error(
|
||||
"Foundation numeric fixture no longer exercises larger producer JSON bytes",
|
||||
);
|
||||
}
|
||||
if (Buffer.byteLength(numericProducerJson) > NATIVE_UI_HELPER_MAX_BUFFER) {
|
||||
throw new Error("Foundation numeric fixture exceeds the raw helper budget");
|
||||
}
|
||||
|
||||
const contents = join(root, "Fixture.app", "Contents");
|
||||
await mkdir(join(contents, "MacOS"), { recursive: true });
|
||||
|
||||
@@ -17,8 +17,8 @@ import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { nativeDataTypeSchema } from "../dist/domain/nativeDataType.js";
|
||||
import { nativeLoadImageSchema } from "../dist/domain/nativeLoadImage.js";
|
||||
import { nativeDataTypeSchema } from "../dist/domain/native/nativeDataType.js";
|
||||
import { nativeLoadImageSchema } from "../dist/domain/native/nativeLoadImage.js";
|
||||
|
||||
const mode = process.argv[2] ?? "symbols";
|
||||
if (
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import sys
|
||||
from urllib.parse import unquote, urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
|
||||
class HtmlReferences(HTMLParser):
|
||||
@@ -89,15 +90,39 @@ def check_publisher(root):
|
||||
return []
|
||||
|
||||
|
||||
def check_example_archive(site):
|
||||
"""Verify the downloadable ZIP contains the current six source files."""
|
||||
source = site / "examples/notes-electron"
|
||||
expected = {
|
||||
f"notes-example/{name}": source / name
|
||||
for name in ("package.json", "main.js", "preload.js", "renderer.js", "csv.js", "index.html")
|
||||
}
|
||||
path = site / "examples/notes-example.zip"
|
||||
if not path.is_file():
|
||||
return ["Notes example ZIP is missing; run python3 scripts/prepare-website.py."]
|
||||
try:
|
||||
with ZipFile(path) as archive:
|
||||
if sorted(archive.namelist()) != sorted(expected):
|
||||
return ["Notes example ZIP must contain exactly the six files in notes-example/."]
|
||||
return [
|
||||
f"Notes example ZIP has stale source: {name}"
|
||||
for name, original in expected.items()
|
||||
if archive.read(name) != original.read_bytes()
|
||||
]
|
||||
except (BadZipFile, RuntimeError) as error:
|
||||
return [f"Notes example ZIP cannot be read: {error}"]
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the same checks locally, on website PRs and before publishing."""
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
pages, references, errors = check_site(root / "website/public")
|
||||
errors.extend(check_example_archive(root / "website/public"))
|
||||
errors.extend(check_publisher(root))
|
||||
if errors:
|
||||
print("Website checks failed:\n" + "\n".join(errors), file=sys.stderr)
|
||||
return 1
|
||||
print(f"Verified {pages} HTML pages, {references} local references and SVG XML.")
|
||||
print(f"Verified {pages} HTML pages, {references} local references, SVG XML and the example ZIP.")
|
||||
print("website-pages.yml is the sole GitHub Pages publisher.")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -263,6 +263,57 @@ try {
|
||||
]);
|
||||
native.call("runtime_snapshot_cancel", [runtime.handle]);
|
||||
await assert.rejects(cancelled, /cancelled/u);
|
||||
assert.equal(await exists(join(runtime.path, "cancelled.bin")), false);
|
||||
const readbackRuntime = WindowsPrivateRuntime.create(
|
||||
workspace.replaceAll("\\", "/"),
|
||||
"readback-",
|
||||
);
|
||||
try {
|
||||
readbackRuntime.writeFile("written.txt", "runtime-write-readback");
|
||||
assert.equal(
|
||||
readbackRuntime.readFile("written.txt"),
|
||||
"runtime-write-readback",
|
||||
);
|
||||
const readbackSnapshot = await readbackRuntime.snapshot(
|
||||
source,
|
||||
"snapshot.bin",
|
||||
);
|
||||
assert.equal(
|
||||
readbackSnapshot.sha256,
|
||||
createHash("sha256")
|
||||
.update(await readFile(source))
|
||||
.digest("hex"),
|
||||
);
|
||||
assert.equal(
|
||||
readbackRuntime.readFile("snapshot.bin"),
|
||||
await readFile(source, "utf8"),
|
||||
);
|
||||
const writtenPath = join(readbackRuntime.observation.path, "written.txt");
|
||||
await assert.rejects(writeFile(writtenPath, "replace"));
|
||||
await assert.rejects(rename(writtenPath, `${writtenPath}.renamed`));
|
||||
await assert.rejects(rm(writtenPath));
|
||||
assert.equal(
|
||||
readbackRuntime.readFile("written.txt"),
|
||||
"runtime-write-readback",
|
||||
);
|
||||
const snapshotPath = join(
|
||||
readbackRuntime.observation.path,
|
||||
"snapshot.bin",
|
||||
);
|
||||
await assert.rejects(rename(snapshotPath, `${snapshotPath}.renamed`));
|
||||
await assert.rejects(rm(snapshotPath));
|
||||
assert.equal(
|
||||
readbackRuntime.readFile("snapshot.bin"),
|
||||
await readFile(source, "utf8"),
|
||||
);
|
||||
report.controls = {
|
||||
...report.controls,
|
||||
completedRuntimeFileReadback: true,
|
||||
completedRuntimeFileMutationDenied: true,
|
||||
};
|
||||
} finally {
|
||||
await readbackRuntime.close();
|
||||
}
|
||||
report.controls = {
|
||||
...report.controls,
|
||||
protectedDaclReadback: true,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "./lib/artifact-e2e.mjs";
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
@@ -30,7 +30,7 @@ try {
|
||||
"clang",
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../tests/conformance/interface-builder/fixture.c",
|
||||
"../../../tests/conformance/interface-builder/fixture.c",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
@@ -44,7 +44,10 @@ try {
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"actool",
|
||||
fileURLToPath(
|
||||
new URL("../tests/conformance/native/Assets.xcassets", import.meta.url),
|
||||
new URL(
|
||||
"../../../tests/conformance/native/Assets.xcassets",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
"--compile",
|
||||
resources,
|
||||
@@ -4,8 +4,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { inspectAppleDispatchMetadata } from "../dist/native/AppleDispatchMetadata.js";
|
||||
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
|
||||
import { inspectAppleDispatchMetadata } from "../../../dist/native/AppleDispatchMetadata.js";
|
||||
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
@@ -14,7 +14,7 @@ if (process.platform !== "darwin")
|
||||
const exec = promisify(execFile);
|
||||
const source = (name) =>
|
||||
fileURLToPath(
|
||||
new URL(`../tests/conformance/native/${name}`, import.meta.url),
|
||||
new URL(`../../../tests/conformance/native/${name}`, import.meta.url),
|
||||
);
|
||||
|
||||
/** Both link modes: legacy LC_DYLD_INFO binds and the default chained fixups. */
|
||||
@@ -8,8 +8,8 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../dist/artifacts/ArtifactProvider.js";
|
||||
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../../../dist/artifacts/ArtifactProvider.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const fixtureRoot = await mkdtemp(join(tmpdir(), "rea-interface-builder-"));
|
||||
@@ -18,7 +18,7 @@ const resourcesPath = join(appPath, "Contents", "Resources");
|
||||
const executablePath = join(appPath, "Contents", "MacOS", "Fixture");
|
||||
const nibPath = join(resourcesPath, "Main.nib");
|
||||
const sourceRoot = fileURLToPath(
|
||||
new URL("../tests/conformance/interface-builder/", import.meta.url),
|
||||
new URL("../../../tests/conformance/interface-builder/", import.meta.url),
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "./lib/artifact-e2e.mjs";
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
@@ -25,7 +25,7 @@ try {
|
||||
join(root, "modules"),
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../tests/conformance/native/keyed-archive.swift",
|
||||
"../../../tests/conformance/native/keyed-archive.swift",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
@@ -66,7 +66,7 @@ try {
|
||||
const expected = JSON.parse(
|
||||
await readFile(
|
||||
new URL(
|
||||
"../tests/fixtures/golden/keyed-archive/graph.json",
|
||||
"../../../tests/fixtures/golden/keyed-archive/graph.json",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
@@ -124,7 +124,7 @@ try {
|
||||
join(root, "modules"),
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../tests/conformance/native/keyed-archive-uid-root.swift",
|
||||
"../../../tests/conformance/native/keyed-archive-uid-root.swift",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
artifactCliEvidence,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "./lib/artifact-e2e.mjs";
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
import {
|
||||
buildMacosBundleFixture,
|
||||
preflightMacosBundleFixture,
|
||||
} from "./lib/macos-bundle-fixture.mjs";
|
||||
} from "../../fixtures/apple/macos-bundle.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
+2
-2
@@ -8,13 +8,13 @@
|
||||
"url": "https://github.com/morluto/rea",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "4.1.0",
|
||||
"version": "5.0.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"registryBaseUrl": "https://registry.npmjs.org",
|
||||
"identifier": "rea-agents",
|
||||
"version": "4.1.0",
|
||||
"version": "5.0.0",
|
||||
"runtimeHint": "npx",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
|
||||
@@ -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: "27"
|
||||
tool_count: 131
|
||||
catalog_digest: "54ac160db3b96580a9a0203a07fe3db01ffc05224db621f48b4abdf979e4f50b"
|
||||
tool_count: 133
|
||||
catalog_digest: "83b4c3445ed0ec003c0aeefa3188f123d9ae319484f8b1e2b611d2fe278c9c72"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { OfficialToolName } from "../contracts/officialToolContracts.js";
|
||||
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
|
||||
import type { NativeToolName } from "../contracts/nativeToolContracts.js";
|
||||
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { ArtifactAnalysisOperation } from "../contracts/artifactToolContracts.js";
|
||||
import type { ManagedToolName } from "../contracts/managed/managedToolContracts.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
|
||||
@@ -189,6 +189,12 @@ export const buildCapabilityInventory = (
|
||||
};
|
||||
|
||||
const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
// These built-in workflows accept a caller-selected endpoint; they do not load the optional passive provider.
|
||||
if (
|
||||
context.name === "observe_web_execution" ||
|
||||
context.name === "inspect_web_event_listeners"
|
||||
)
|
||||
return { reason: "available", remediation: null };
|
||||
if (context.name === "get_navigation_context")
|
||||
return navigationContextAvailability(context.descriptors);
|
||||
const javascriptApplication = javascriptApplicationAvailability(context);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { parseConfig } from "../config.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { EnhancedTools } from "./EnhancedTools.js";
|
||||
import { executeFunctionAnalysisEvidence } from "./FunctionAnalysisEvidence.js";
|
||||
import { createBinarySession, createManagedBinarySession } from "./runtime.js";
|
||||
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
|
||||
import type { BinarySession } from "./binary/BinarySession.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { NativeToolName } from "../contracts/nativeToolContracts.js";
|
||||
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { ArtifactAnalysisOperation } from "../contracts/artifactToolContracts.js";
|
||||
import {
|
||||
isManagedToolName,
|
||||
@@ -38,11 +39,6 @@ import {
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
|
||||
export {
|
||||
runCapabilityStatus,
|
||||
runProviderStatus,
|
||||
} from "./DirectAnalysisStatus.js";
|
||||
|
||||
type DirectAnalysisTool =
|
||||
| "annotate_native_function"
|
||||
| "inspect_native_load_image"
|
||||
@@ -70,6 +66,7 @@ type DirectAnalysisTool =
|
||||
* retain a target or provider client for a subsequent command.
|
||||
*/
|
||||
export const runDirectAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
path: string,
|
||||
tool: DirectAnalysisTool,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
@@ -82,7 +79,7 @@ export const runDirectAnalysis = async (
|
||||
} = {},
|
||||
): Promise<JsonValue> =>
|
||||
withProcessCancellation(options.signal, (signal) =>
|
||||
runAnalysis(path, tool, arguments_, {
|
||||
runAnalysis(dependencies, path, tool, arguments_, {
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
signal,
|
||||
@@ -97,6 +94,7 @@ export const runDirectAnalysis = async (
|
||||
|
||||
/** Execute one provider-native semantic operation with atomic provenance. */
|
||||
export const runProviderAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
...[path, tool, arguments_, logger = silentLogger, signal]: readonly [
|
||||
path: string,
|
||||
tool: NativeToolName | ArtifactAnalysisOperation | ManagedToolName,
|
||||
@@ -106,9 +104,9 @@ export const runProviderAnalysis = async (
|
||||
]
|
||||
): Promise<JsonValue> =>
|
||||
isManagedToolName(tool)
|
||||
? runManagedProviderAnalysis(path, tool, signal)
|
||||
? runManagedProviderAnalysis(dependencies, path, tool, signal)
|
||||
: withProcessCancellation(signal, (operationSignal) =>
|
||||
runAnalysis(path, tool, arguments_, {
|
||||
runAnalysis(dependencies, path, tool, arguments_, {
|
||||
logger,
|
||||
snapshotPath: undefined,
|
||||
signal: operationSignal,
|
||||
@@ -117,12 +115,13 @@ export const runProviderAnalysis = async (
|
||||
|
||||
/** Execute managed metadata inspection in an isolated managed-only session. */
|
||||
export const runManagedProviderExecution = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
path: string,
|
||||
tool: ManagedToolName,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> =>
|
||||
withProcessCancellation(signal, async (operationSignal) => {
|
||||
const session = createManagedBinarySession();
|
||||
const session = dependencies.createManagedBinarySession();
|
||||
try {
|
||||
const opened = await session.open(path, { signal: operationSignal });
|
||||
if (!opened.ok) return opened;
|
||||
@@ -133,11 +132,17 @@ export const runManagedProviderExecution = async (
|
||||
});
|
||||
|
||||
const runManagedProviderAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
path: string,
|
||||
tool: ManagedToolName,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JsonValue> => {
|
||||
const execution = await runManagedProviderExecution(path, tool, signal);
|
||||
const execution = await runManagedProviderExecution(
|
||||
dependencies,
|
||||
path,
|
||||
tool,
|
||||
signal,
|
||||
);
|
||||
if (!execution.ok) return cliError(execution.error);
|
||||
const value = execution.value;
|
||||
return createEvidence(value.subject ?? undefined, value.provider, {
|
||||
@@ -151,6 +156,7 @@ const runManagedProviderAnalysis = async (
|
||||
};
|
||||
|
||||
const runAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
path: string,
|
||||
tool:
|
||||
| NativeToolName
|
||||
@@ -177,7 +183,7 @@ const runAnalysis = async (
|
||||
const { logger, signal, snapshotPath } = options;
|
||||
const config = parseConfig(options.environment ?? process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const session = createBinarySession(config.value, logger);
|
||||
const session = dependencies.createBinarySession(config.value, logger);
|
||||
try {
|
||||
const prepared = await prepareSnapshot({
|
||||
path,
|
||||
@@ -247,13 +253,13 @@ const runAnalysis = async (
|
||||
};
|
||||
|
||||
const canReplayDirectTool = (
|
||||
session: ReturnType<typeof createBinarySession>,
|
||||
tool: Parameters<typeof runAnalysis>[1],
|
||||
session: BinarySession,
|
||||
tool: Parameters<typeof runAnalysis>[2],
|
||||
): boolean =>
|
||||
tool !== "trace_native_ui_action" && session.allowsSnapshotReplay(tool);
|
||||
|
||||
const executeAnalysisTool = async (input: {
|
||||
readonly session: ReturnType<typeof createBinarySession>;
|
||||
readonly session: BinarySession;
|
||||
readonly openedTarget: BinaryTarget;
|
||||
readonly tool:
|
||||
| NativeToolName
|
||||
@@ -372,7 +378,7 @@ const prepareSnapshot = async (options: {
|
||||
};
|
||||
|
||||
const replayProviderFor = (
|
||||
session: ReturnType<typeof createBinarySession>,
|
||||
session: BinarySession,
|
||||
tool:
|
||||
| NativeToolName
|
||||
| ArtifactAnalysisOperation
|
||||
@@ -384,7 +390,7 @@ const replayProviderFor = (
|
||||
: session.providerIdentity(tool);
|
||||
|
||||
const analysisProfileForEvidence = (
|
||||
session: ReturnType<typeof createBinarySession>,
|
||||
session: BinarySession,
|
||||
tool:
|
||||
| NativeToolName
|
||||
| ArtifactAnalysisOperation
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { BinarySession } from "./binary/BinarySession.js";
|
||||
|
||||
/** Existing one-shot session factories supplied by the production boundary. */
|
||||
export interface DirectAnalysisDependencies {
|
||||
readonly createBinarySession: (
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
) => BinarySession;
|
||||
readonly createManagedBinarySession: () => BinarySession;
|
||||
}
|
||||
@@ -3,15 +3,16 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { createServerIdentity } from "../serverIdentity.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { createBinarySession } from "./runtime.js";
|
||||
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
|
||||
|
||||
const runSessionStatus = async (
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
|
||||
logger: Logger = silentLogger,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): Promise<JsonValue> => {
|
||||
const config = parseConfig(environment);
|
||||
if (!config.ok) return { error: projectAnalysisError(config.error) };
|
||||
const session = createBinarySession(config.value, logger);
|
||||
const session = dependencies.createBinarySession(config.value, logger);
|
||||
try {
|
||||
return {
|
||||
...jsonObjectSchema.parse(session.status()),
|
||||
@@ -26,12 +27,14 @@ const runSessionStatus = async (
|
||||
|
||||
/** List binary-session provider candidates and auxiliary operation availability. */
|
||||
export const runProviderStatus = (
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
|
||||
logger: Logger = silentLogger,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
) => runSessionStatus(logger, environment);
|
||||
) => runSessionStatus(dependencies, logger, environment);
|
||||
|
||||
/** List binary-session operation descriptors; this is not the full MCP catalog. */
|
||||
export const runCapabilityStatus = (
|
||||
dependencies: Pick<DirectAnalysisDependencies, "createBinarySession">,
|
||||
logger: Logger = silentLogger,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
) => runSessionStatus(logger, environment);
|
||||
) => runSessionStatus(dependencies, logger, environment);
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("doctor", () => {
|
||||
),
|
||||
).toEqual({
|
||||
node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
|
||||
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, CachyOS, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
||||
target: "Supply a readable local app or program path.",
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
|
||||
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`);
|
||||
return check("host", supported, detail, {
|
||||
remediation:
|
||||
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, CachyOS, and the experimental Windows x64 Ghidra P0 boundary.",
|
||||
classification: "unsupported_host",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
discoverObjcClasses,
|
||||
discoverObjcProtocols,
|
||||
} from "../domain/symbolAnalysis.js";
|
||||
import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
|
||||
import { traceNativeValues } from "./NativeValueTrace.js";
|
||||
import { traceNativeUiAction } from "./NativeUiActionTrace.js";
|
||||
import { inspectNativeDispatch } from "./native/NativeDispatchMetadataInspection.js";
|
||||
import { traceNativeValues } from "./native/NativeValueTrace.js";
|
||||
import { traceNativeUiAction } from "./native/NativeUiActionTrace.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
import {
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from "./EnhancedToolTypes.js";
|
||||
import { traceCallPath } from "./CallPathTracing.js";
|
||||
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
|
||||
import { projectNativeApiInspection } from "./NativeApiInspection.js";
|
||||
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
|
||||
export type { ValidatedEnhancedCall } from "./EnhancedToolTypes.js";
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { fstatSync } from "node:fs";
|
||||
import {
|
||||
mkdtemp,
|
||||
lstat,
|
||||
open,
|
||||
readdir,
|
||||
rm,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { hashFile } from "./FilesystemSnapshot.js";
|
||||
|
||||
const countOpenDescriptorsFor = async (path: string): Promise<number> => {
|
||||
const directory = process.platform === "linux" ? "/proc/self/fd" : "/dev/fd";
|
||||
const expected = await lstat(path);
|
||||
const descriptors = await readdir(directory);
|
||||
const matches = await Promise.all(
|
||||
descriptors.map(async (descriptor) => {
|
||||
if (!/^\d+$/u.test(descriptor)) return false;
|
||||
try {
|
||||
const stats = fstatSync(Number(descriptor));
|
||||
return stats.dev === expected.dev && stats.ino === expected.ino;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "EBADF")
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return matches.filter(Boolean).length;
|
||||
};
|
||||
|
||||
it("does not hash a replacement file using the earlier path metadata", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-identity-"));
|
||||
const path = join(directory, "observed-file");
|
||||
try {
|
||||
await writeFile(path, "captured file\n");
|
||||
const expected = await lstat(path);
|
||||
await rm(path);
|
||||
await writeFile(path, "replacement with a different identity and size\n");
|
||||
|
||||
await expect(hashFile(path, expected, 1_000)).resolves.toBeNull();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a same-size rewrite even when its modification time is restored", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-ctime-"));
|
||||
const path = join(directory, "observed-file");
|
||||
try {
|
||||
const fixedMtime = new Date("2020-01-02T03:04:05.000Z");
|
||||
await writeFile(path, "before\n");
|
||||
await utimes(path, fixedMtime, fixedMtime);
|
||||
const expected = await lstat(path);
|
||||
await writeFile(path, "after!\n");
|
||||
await utimes(path, fixedMtime, fixedMtime);
|
||||
const rewritten = await lstat(path);
|
||||
|
||||
expect(rewritten).toMatchObject({
|
||||
dev: expected.dev,
|
||||
ino: expected.ino,
|
||||
mode: expected.mode,
|
||||
size: expected.size,
|
||||
mtimeMs: expected.mtimeMs,
|
||||
});
|
||||
expect(rewritten.ctimeMs).not.toBe(expected.ctimeMs);
|
||||
|
||||
await expect(hashFile(path, expected, 1_000)).resolves.toBeNull();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects cancellation for an empty file before returning its digest", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-empty-cancel-"));
|
||||
const path = join(directory, "observed-file");
|
||||
try {
|
||||
await writeFile(path, "");
|
||||
const expected = await lstat(path);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
hashFile(path, expected, 1_000, controller.signal),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"closes the opened file when cancellation arrives during open",
|
||||
async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-cancel-"));
|
||||
const path = join(directory, "observed-file");
|
||||
try {
|
||||
await writeFile(path, "captured file\n");
|
||||
const expected = await lstat(path);
|
||||
const controller = new AbortController();
|
||||
const held = await open(path, "r");
|
||||
expect(await countOpenDescriptorsFor(path)).toBe(1);
|
||||
await held.close();
|
||||
expect(await countOpenDescriptorsFor(path)).toBe(0);
|
||||
const hashing = hashFile(path, expected, 1_000, controller.signal);
|
||||
// hashFile reaches `open` synchronously before its first suspension, so
|
||||
// this abort is observed after the descriptor is acquired and is handled
|
||||
// by the `finally` close path.
|
||||
controller.abort();
|
||||
|
||||
await expect(hashing).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(await countOpenDescriptorsFor(path)).toBe(0);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -19,6 +19,16 @@ const hasSameIdentity = (
|
||||
before.ino === after.ino &&
|
||||
(expected === "directory" ? after.isDirectory() : after.isSymbolicLink());
|
||||
|
||||
const hasSameFileState = (before: Stats, after: Stats): boolean =>
|
||||
before.dev === after.dev &&
|
||||
before.ino === after.ino &&
|
||||
before.isFile() &&
|
||||
after.isFile() &&
|
||||
before.mode === after.mode &&
|
||||
before.size === after.size &&
|
||||
before.mtimeMs === after.mtimeMs &&
|
||||
before.ctimeMs === after.ctimeMs;
|
||||
|
||||
const lstatIfPresent = async (path: string): Promise<Stats | undefined> => {
|
||||
try {
|
||||
return await lstat(path);
|
||||
@@ -29,18 +39,26 @@ const lstatIfPresent = async (path: string): Promise<Stats | undefined> => {
|
||||
}
|
||||
};
|
||||
|
||||
const hashFile = async (
|
||||
/** Hash a file only when its opened descriptor still matches the captured path state. */
|
||||
export const hashFile = async (
|
||||
path: string,
|
||||
expected: Stats,
|
||||
maxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> => {
|
||||
signal?.throwIfAborted();
|
||||
const handle = await open(
|
||||
path,
|
||||
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0),
|
||||
fsConstants.O_RDONLY |
|
||||
(fsConstants.O_NOFOLLOW ?? 0) |
|
||||
(fsConstants.O_NONBLOCK ?? 0),
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.size > maxBytes) return null;
|
||||
signal?.throwIfAborted();
|
||||
if (!hasSameFileState(expected, stats) || stats.size > maxBytes)
|
||||
return null;
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes));
|
||||
let position = 0;
|
||||
@@ -57,8 +75,8 @@ const hashFile = async (
|
||||
position += bytesRead;
|
||||
}
|
||||
const after = await handle.stat();
|
||||
if (after.size !== stats.size || after.mtimeMs !== stats.mtimeMs)
|
||||
return null;
|
||||
signal?.throwIfAborted();
|
||||
if (!hasSameFileState(stats, after)) return null;
|
||||
return hash.digest("hex");
|
||||
} finally {
|
||||
await handle.close();
|
||||
@@ -110,7 +128,7 @@ export const snapshotRoots = async (
|
||||
if (stats.isFile()) {
|
||||
const sha256 =
|
||||
remainingBytes >= stats.size
|
||||
? await hashFile(path, remainingBytes, signal)
|
||||
? await hashFile(path, stats, remainingBytes, signal)
|
||||
: null;
|
||||
remainingBytes -= sha256 === null ? 0 : stats.size;
|
||||
if (sha256 === null) truncated = true;
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("Linux Hopper host classification", () => {
|
||||
['ID=ubuntu\nVERSION_ID="24.04"\n', "deb"],
|
||||
["ID=fedora\nVERSION_ID=41\n", "rpm"],
|
||||
["ID=arch\n", "arch"],
|
||||
["ID=cachyos\nID_LIKE=arch\n", "arch"],
|
||||
] as const)("accepts an official Hopper distribution", (document, family) => {
|
||||
expect(parseLinuxDistribution(document)).toMatchObject({
|
||||
packageFamily: family,
|
||||
@@ -88,6 +89,8 @@ describe("Linux Hopper host classification", () => {
|
||||
'ID=ubuntu\nVERSION_ID="22.04"\n',
|
||||
"ID=fedora\nVERSION_ID=40\n",
|
||||
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
|
||||
"ID=manjaro\nID_LIKE=arch\n",
|
||||
"ID=garuda\nID_LIKE=arch\n",
|
||||
])("rejects unsupported vendor/version combinations", (document) => {
|
||||
expect(parseLinuxDistribution(document).supported).toBe(false);
|
||||
});
|
||||
|
||||
@@ -150,6 +150,7 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
||||
const supported =
|
||||
packageFamily !== undefined &&
|
||||
(id === "arch" ||
|
||||
id === "cachyos" ||
|
||||
(id === "ubuntu" && versionAtLeast(versionId, 24)) ||
|
||||
(id === "fedora" && versionAtLeast(versionId, 41)));
|
||||
const identity = {
|
||||
|
||||
@@ -449,6 +449,7 @@ export const resolveProcessResult = (
|
||||
export const prepareProcessCapture = async (
|
||||
scenario: ProcessScenario,
|
||||
signal: AbortSignal | undefined,
|
||||
captureSnapshot: typeof snapshotRoots = snapshotRoots,
|
||||
host: ProcessPreparationHost = systemProcessPreparationHost,
|
||||
): Promise<{
|
||||
readonly temporaryRoot: string;
|
||||
@@ -456,7 +457,7 @@ export const prepareProcessCapture = async (
|
||||
readonly before: SnapshotResult;
|
||||
}> => {
|
||||
assertNotCancelled(signal);
|
||||
const before = await snapshotRoots(scenario, signal);
|
||||
const before = await captureSnapshot(scenario, signal);
|
||||
const temporaryRoot = await host.createTemporaryRoot();
|
||||
try {
|
||||
const runId = randomUUID();
|
||||
|
||||
@@ -165,7 +165,11 @@ const readJson = async (path: string): Promise<unknown> => {
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(bytes.toString("utf8"));
|
||||
const text = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(bytes);
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return parsed;
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
|
||||
@@ -233,6 +233,20 @@ const finishProcessRun = async (options: {
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeCaptureFailure = (
|
||||
cause: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
): unknown => {
|
||||
if (cause instanceof ProcessCaptureError) return cause;
|
||||
if (
|
||||
signal?.aborted === true &&
|
||||
(cause === signal.reason ||
|
||||
(cause instanceof Error && cause.name === "AbortError"))
|
||||
)
|
||||
return processCaptureCancelled();
|
||||
return cause;
|
||||
};
|
||||
|
||||
const completeCapture = async (options: {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly hostPlatform: NodeJS.Platform;
|
||||
@@ -245,6 +259,7 @@ const completeCapture = async (options: {
|
||||
readonly interactions: readonly InteractionEvent[];
|
||||
readonly exit: Awaited<ReturnType<typeof awaitTerminalExit>>;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly captureSnapshot: typeof snapshotRoots;
|
||||
readonly initiallyTruncated: boolean;
|
||||
readonly eventJournal: readonly ProcessCaptureEventJournalEntry[];
|
||||
readonly recordEvent: RecordProcessCaptureEvent;
|
||||
@@ -262,7 +277,7 @@ const completeCapture = async (options: {
|
||||
const samplingPartial = (await runtime.stopSampler()).partial;
|
||||
await settleProcessCaptureJournal(options.eventJournal);
|
||||
assertNotCancelled(options.signal);
|
||||
const after = await snapshotRoots(scenario, options.signal);
|
||||
const after = await options.captureSnapshot(scenario, options.signal);
|
||||
options.recordEvent("filesystem_checkpoints", 1);
|
||||
const renderedFrames = await runtime.renderer.frames();
|
||||
const checkpoints: UnverifiedProcessCapture["filesystem_checkpoints"] = [
|
||||
@@ -319,10 +334,12 @@ const runProcessScenario = async (
|
||||
signal?: AbortSignal,
|
||||
hostEnvironment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
hostPlatform: NodeJS.Platform = process.platform,
|
||||
captureSnapshot: typeof snapshotRoots = snapshotRoots,
|
||||
): Promise<ProcessCapture> => {
|
||||
const { temporaryRoot, runId, before } = await prepareProcessCapture(
|
||||
scenario,
|
||||
signal,
|
||||
captureSnapshot,
|
||||
);
|
||||
const frames: TerminalFrame[] = [];
|
||||
const samples: ProcessSample[] = [];
|
||||
@@ -377,11 +394,12 @@ const runProcessScenario = async (
|
||||
initiallyTruncated: before.truncated,
|
||||
eventJournal,
|
||||
recordEvent,
|
||||
captureSnapshot,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
runtime?.terminal.kill("SIGKILL");
|
||||
executionFailure = cause;
|
||||
executionFailure = normalizeCaptureFailure(cause, signal);
|
||||
}
|
||||
return finishProcessRun({
|
||||
runtime,
|
||||
@@ -401,6 +419,7 @@ export const captureProcessScenario = async (
|
||||
signal?: AbortSignal,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
captureSnapshot: typeof snapshotRoots = snapshotRoots,
|
||||
): Promise<Result<ProcessCapture, ProcessCaptureError | AnalysisError>> => {
|
||||
const ownershipReason = processCaptureOwnershipUnavailableReason(platform);
|
||||
if (ownershipReason !== undefined)
|
||||
@@ -419,12 +438,19 @@ export const captureProcessScenario = async (
|
||||
environment,
|
||||
);
|
||||
return ok(
|
||||
await runProcessScenario(resolvedScenario, signal, environment, platform),
|
||||
await runProcessScenario(
|
||||
resolvedScenario,
|
||||
signal,
|
||||
environment,
|
||||
platform,
|
||||
captureSnapshot,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
const failure = normalizeCaptureFailure(cause, signal);
|
||||
return err(
|
||||
cause instanceof ProcessCaptureError
|
||||
? cause
|
||||
failure instanceof ProcessCaptureError
|
||||
? failure
|
||||
: new ProcessCaptureError("process capture failed", { cause }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ describe("setup scoped readiness", () => {
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(result.remediation).toBe(
|
||||
"Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.",
|
||||
"Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and CachyOS; configure an existing supported provider instead.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export const hostRemediation = async (
|
||||
: undefined;
|
||||
}
|
||||
if ((await host.linuxDistribution())?.supported === true) return undefined;
|
||||
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.";
|
||||
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and CachyOS; configure an existing supported provider instead.";
|
||||
};
|
||||
|
||||
/** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
ObserveWebExecutionInput,
|
||||
WebExecution,
|
||||
} from "../domain/webExecution.js";
|
||||
import type {
|
||||
InspectWebEventListenersInput,
|
||||
WebEventListeners,
|
||||
} from "../domain/webEventListeners.js";
|
||||
|
||||
/** Provider-neutral live source attribution operations with distinct instrumentation authority. */
|
||||
export interface WebRuntimePort {
|
||||
identity(): ProviderIdentity;
|
||||
observeExecution(
|
||||
input: ObserveWebExecutionInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebExecution, AnalysisError>>;
|
||||
inspectEventListeners(
|
||||
input: InspectWebEventListenersInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebEventListeners, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { WebRuntimePort } from "./WebRuntimePort.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { WebExecution } from "../domain/webExecution.js";
|
||||
import type { WebEventListeners } from "../domain/webEventListeners.js";
|
||||
|
||||
const browser = {
|
||||
product: "RecordingChrome/1",
|
||||
protocol_version: "1.3",
|
||||
revision: "fixture",
|
||||
user_agent: "fixture",
|
||||
js_version: "fixture",
|
||||
};
|
||||
const target = {
|
||||
target_id: "selected-page",
|
||||
initial_url: "http://127.0.0.1:9222/app",
|
||||
origin: "http://127.0.0.1:9222",
|
||||
frame_id: "main-frame",
|
||||
loader_id: "fixture-document",
|
||||
};
|
||||
const at = "2026-01-01T00:00:00.000Z";
|
||||
|
||||
/** Immutable application-port result; it makes no real browser or socket claim. */
|
||||
export const webExecutionFixture = (
|
||||
targetId = target.target_id,
|
||||
duration = 5,
|
||||
): WebExecution => ({
|
||||
browser,
|
||||
target: { ...target, target_id: targetId },
|
||||
window: {
|
||||
armed_at: at,
|
||||
ended_at: at,
|
||||
requested_ms: duration,
|
||||
end_reason: "window_elapsed",
|
||||
producer_started_seconds: 1,
|
||||
producer_sampled_seconds: 2,
|
||||
},
|
||||
coverage: {
|
||||
state: "captured",
|
||||
reason: null,
|
||||
offset_units: "utf16-code-units",
|
||||
end_offset: "exclusive",
|
||||
scripts: [],
|
||||
excluded_scripts: 0,
|
||||
},
|
||||
requests: [],
|
||||
excluded_requests: 0,
|
||||
sources: [],
|
||||
script_inventory: {
|
||||
main_document_scripts: 0,
|
||||
not_reported_script_ids: [],
|
||||
coverage_absence: "unknown",
|
||||
},
|
||||
instrumentation: {
|
||||
resets_execution_counters: true,
|
||||
disables_optimized_execution: true,
|
||||
takes_one_resetting_sample: true,
|
||||
executes_selected_code: false,
|
||||
cleanup: "confirmed",
|
||||
page_ownership: "external",
|
||||
},
|
||||
limitations: ["Recording port fixture, not real browser evidence."],
|
||||
});
|
||||
|
||||
/** Immutable listener port result for application and SDK schema boundaries. */
|
||||
export const webListenersFixture = (
|
||||
targetId = target.target_id,
|
||||
selector = "#selected",
|
||||
): WebEventListeners => ({
|
||||
browser,
|
||||
target: { ...target, target_id: targetId },
|
||||
inspected_at: at,
|
||||
selected_node: { selector, match: "found", backend_node_id: 22 },
|
||||
listeners: [],
|
||||
sources: [],
|
||||
cleanup: "confirmed",
|
||||
limitations: ["Recording port fixture, not real listener evidence."],
|
||||
});
|
||||
|
||||
/** Provider-neutral recording seam preserves the exact parsed caller target and window. */
|
||||
export const recordingWebRuntimePort = (): WebRuntimePort => ({
|
||||
identity: () => ({
|
||||
id: "runtime-recording",
|
||||
name: "Runtime recording fixture",
|
||||
version: "1",
|
||||
}),
|
||||
observeExecution: async (input) =>
|
||||
ok(webExecutionFixture(input.target_id, input.observation_ms)),
|
||||
inspectEventListeners: async (input) =>
|
||||
ok(webListenersFixture(input.target_id, input.selector)),
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { WebRuntimeService } from "./WebRuntimeService.js";
|
||||
import {
|
||||
recordingWebRuntimePort,
|
||||
webListenersFixture,
|
||||
} from "./WebRuntimeService.fixture.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
it("rejects malformed input and prior cancellation without calling a provider", async () => {
|
||||
const provider = recordingWebRuntimePort();
|
||||
const service = new WebRuntimeService({
|
||||
identity: () => provider.identity(),
|
||||
observeExecution: () => {
|
||||
throw new Error("Provider must not be called");
|
||||
},
|
||||
inspectEventListeners: () => {
|
||||
throw new Error("Provider must not be called");
|
||||
},
|
||||
});
|
||||
const malformed = await service.observe({
|
||||
cdp_endpoint: "https://remote.example",
|
||||
target_id: "page",
|
||||
});
|
||||
if (malformed.ok)
|
||||
throw new Error("Invalid endpoint must fail before execution");
|
||||
expect(malformed.error._tag).toBe("AnalysisInputError");
|
||||
const cancelled = await service.inspect({}, { signal: AbortSignal.abort() });
|
||||
if (cancelled.ok)
|
||||
throw new Error("Prior cancellation must fail before inspection");
|
||||
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
|
||||
it("preserves actual provider failures and rejects changed target/selector identities", async () => {
|
||||
const provider = recordingWebRuntimePort();
|
||||
const args = {
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
selector: "#selected",
|
||||
};
|
||||
const captured = webListenersFixture();
|
||||
const timeout = new AnalysisTimeoutError("observe_web_execution", 20_000);
|
||||
const service = new WebRuntimeService({
|
||||
identity: () => provider.identity(),
|
||||
observeExecution: async () => err(timeout),
|
||||
inspectEventListeners: async () =>
|
||||
ok({
|
||||
...captured,
|
||||
selected_node: { ...captured.selected_node, selector: "#different" },
|
||||
}),
|
||||
});
|
||||
const failed = await service.observe({
|
||||
cdp_endpoint: args.cdp_endpoint,
|
||||
target_id: args.target_id,
|
||||
observation_ms: 5,
|
||||
});
|
||||
expect(failed).toEqual(err(timeout));
|
||||
const changed = await service.inspect(args);
|
||||
if (changed.ok) throw new Error("Changed selection must be rejected");
|
||||
expect(changed.error._tag).toBe("AnalysisOutputError");
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { z } from "zod";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { WebRuntimePort } from "./WebRuntimePort.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
observeWebExecutionInputSchema,
|
||||
webExecutionSchema,
|
||||
} from "../domain/webExecution.js";
|
||||
import {
|
||||
inspectWebEventListenersInputSchema,
|
||||
webEventListenersSchema,
|
||||
} from "../domain/webEventListeners.js";
|
||||
|
||||
/** Shared public workflows keep instrumentation and listener inspection independently selectable. */
|
||||
export class WebRuntimeService {
|
||||
constructor(readonly provider: WebRuntimePort) {}
|
||||
|
||||
/** Observe externally triggered execution; selection authorizes the declared instrumentation. */
|
||||
async observe(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const operation = "observe_web_execution";
|
||||
const parsed = parseInput(
|
||||
observeWebExecutionInputSchema,
|
||||
rawInput,
|
||||
operation,
|
||||
options,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = await this.provider.observeExecution(parsed.value, options);
|
||||
if (!result.ok) return result;
|
||||
const verified = webExecutionSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
if (
|
||||
verified.data.target.target_id !== parsed.value.target_id ||
|
||||
verified.data.window.requested_ms !== parsed.value.observation_ms
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider changed the selected target or requested window.",
|
||||
),
|
||||
);
|
||||
return this.evidence(operation, parsed.value, verified.data, options);
|
||||
}
|
||||
|
||||
/** Inspect one selected node's listener declarations without dispatching an event. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const operation = "inspect_web_event_listeners";
|
||||
const parsed = parseInput(
|
||||
inspectWebEventListenersInputSchema,
|
||||
rawInput,
|
||||
operation,
|
||||
options,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = await this.provider.inspectEventListeners(
|
||||
parsed.value,
|
||||
options,
|
||||
);
|
||||
if (!result.ok) return result;
|
||||
const verified = webEventListenersSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
if (
|
||||
verified.data.target.target_id !== parsed.value.target_id ||
|
||||
verified.data.selected_node.selector !== parsed.value.selector
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider changed the selected target or node selector.",
|
||||
),
|
||||
);
|
||||
return this.evidence(operation, parsed.value, verified.data, options);
|
||||
}
|
||||
|
||||
private evidence(
|
||||
operation: string,
|
||||
input:
|
||||
| z.infer<typeof observeWebExecutionInputSchema>
|
||||
| z.infer<typeof inspectWebEventListenersInputSchema>,
|
||||
result:
|
||||
| z.infer<typeof webExecutionSchema>
|
||||
| z.infer<typeof webEventListenersSchema>,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (
|
||||
input.allowed_origins.length > 0 &&
|
||||
!input.allowed_origins.includes(result.target.origin)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider returned a document outside the selected origin scope.",
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
createEvidence(undefined, this.provider.identity(), {
|
||||
predicateType: `rea.${operation.replaceAll("_", "-")}`,
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
limitations: result.limitations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parseInput = <T>(
|
||||
schema: z.ZodType<T>,
|
||||
rawInput: unknown,
|
||||
operation: string,
|
||||
options: ExecutionOptions | undefined,
|
||||
): Result<T, AnalysisError> => {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const parsed = schema.safeParse(rawInput);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{ cause: parsed.error },
|
||||
projectInputIssues(parsed.error.issues, rawInput),
|
||||
),
|
||||
);
|
||||
};
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
appleApplicationProjectionInputSchema,
|
||||
projectAppleApplication,
|
||||
} from "../domain/appleApplication.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { APPLE_APPLICATION_PROVIDER } from "./InvestigationProviders.js";
|
||||
import { projectInventoryEvidence } from "./InventoryProjectionEvidence.js";
|
||||
} from "../../domain/apple/appleApplication.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js";
|
||||
|
||||
const OPERATION = "project_apple_application_graph" as const;
|
||||
|
||||
@@ -68,7 +68,8 @@ export abstract class BinarySessionRecords {
|
||||
): Result<number, EvidenceIntegrityError> {
|
||||
const imported = this.#records.mergeEvidenceBundle(bundle);
|
||||
if (!imported.ok) return imported;
|
||||
if (imported.value.changed) this.#emitSnapshotChanged();
|
||||
if (imported.value.metadataChanged) this.invalidateSnapshot();
|
||||
else if (imported.value.changed) this.#emitSnapshotChanged();
|
||||
return ok(imported.value.recordsAdded);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,78 @@ const update = (
|
||||
});
|
||||
|
||||
describe("residual unknown registry reads and identity", () => {
|
||||
it("merges record-only bundles without changing retained unknown history", () => {
|
||||
const store = ledger();
|
||||
const created = store.recordUnknown(
|
||||
input("Does a later record preserve this unknown?"),
|
||||
mutation("record-only-import"),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
const originalUnknowns = store.export().unknowns;
|
||||
const addition = evidence("record-only-addition");
|
||||
const bundle = createEvidenceBundle([addition]);
|
||||
|
||||
expect(store.import(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
recordsAdded: 1,
|
||||
unknownsAdded: 0,
|
||||
changed: true,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
expect(store.export().unknowns).toEqual(originalUnknowns);
|
||||
expect(store.import(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 0,
|
||||
changed: false,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("residual unknown dependency histories", () => {
|
||||
it("imports a valid dependency chain deeper than the JavaScript call stack", () => {
|
||||
const records = [];
|
||||
const unknowns: ResidualUnknown[] = [];
|
||||
let previousId: string | undefined;
|
||||
for (let index = 0; index < 10_000; index += 1) {
|
||||
const mutationEvidence = mutation(`deep-chain-${index}`);
|
||||
records.push(mutationEvidence);
|
||||
const relationships =
|
||||
previousId === undefined
|
||||
? []
|
||||
: [{ type: "depends-on" as const, unknown_id: previousId }];
|
||||
const unknown = createResidualUnknown(
|
||||
input(`Does chain item ${index} meet the expected state?`, {
|
||||
domain: "chain-profile",
|
||||
required_authority: null,
|
||||
relationships,
|
||||
}),
|
||||
mutationEvidence.evidence_id,
|
||||
null,
|
||||
);
|
||||
unknowns.push(unknown);
|
||||
previousId = unknown.unknown_id;
|
||||
}
|
||||
const bundle = createEvidenceBundle(records, unknowns);
|
||||
const store = ledger();
|
||||
|
||||
expect(store.import(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
recordsAdded: 10_000,
|
||||
unknownsAdded: 10_000,
|
||||
changed: true,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
expect(store.export()).toEqual(bundle);
|
||||
});
|
||||
|
||||
it("returns detached unknowns and evidence bundles from every read surface", () => {
|
||||
const store = ledger();
|
||||
expect(store.record(evidence("detached-record")).ok).toBe(true);
|
||||
|
||||
@@ -38,9 +38,55 @@ describe("evidence ledger recording", () => {
|
||||
});
|
||||
expect(ledger.import(createEvidenceBundle([evidence]))).toEqual({
|
||||
ok: true,
|
||||
value: { recordsAdded: 0, unknownsAdded: 0, changed: false },
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 0,
|
||||
changed: false,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
expect(ledger.export().records).toEqual([evidence]);
|
||||
const relocatedBundleRecord = createEvidence(
|
||||
{ ...TARGET, path: "/relocated/fixture-renamed" },
|
||||
PROVIDER,
|
||||
{ operation: "health", parameters: {}, result: true },
|
||||
);
|
||||
expect(
|
||||
ledger.import(createEvidenceBundle([relocatedBundleRecord])),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 0,
|
||||
changed: true,
|
||||
metadataChanged: true,
|
||||
},
|
||||
});
|
||||
expect(ledger.get(evidence.evidence_id)).toEqual(relocatedBundleRecord);
|
||||
expect(ledger.export().records).toEqual([relocatedBundleRecord]);
|
||||
expect(
|
||||
ledger.import(createEvidenceBundle([relocatedBundleRecord])),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 0,
|
||||
changed: false,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
const conflictingBundle = createEvidenceBundle([
|
||||
evidence,
|
||||
relocatedBundleRecord,
|
||||
]);
|
||||
expect(ledger.import(conflictingBundle).ok).toBe(false);
|
||||
expect(ledger.get(evidence.evidence_id)).toEqual(relocatedBundleRecord);
|
||||
ledger.clear();
|
||||
expect(ledger.export().records).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores JSON object key order during imported identity checks", () => {
|
||||
const ledger = new EvidenceLedger();
|
||||
const ordered = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "health",
|
||||
parameters: { alpha: 1, beta: 2 },
|
||||
@@ -60,11 +106,78 @@ describe("evidence ledger recording", () => {
|
||||
);
|
||||
expect(ledger.import(reorderedBundle)).toEqual({
|
||||
ok: true,
|
||||
value: { recordsAdded: 0, unknownsAdded: 0, changed: false },
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 0,
|
||||
changed: false,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
ledger.clear();
|
||||
expect(ledger.export().records).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evidence ledger metadata imports", () => {
|
||||
it("reports and commits metadata replacement when importing unknown history", () => {
|
||||
const ledger = new EvidenceLedger();
|
||||
const mutation = createEvidence(TARGET, PROVIDER, {
|
||||
predicateType: "rea.residual-unknown-mutation",
|
||||
operation: "record_unknown",
|
||||
parameters: {},
|
||||
result: { action: "record", question: "Which path is current?" },
|
||||
});
|
||||
const created = ledger.recordUnknown(
|
||||
recordUnknownInputSchema.parse({
|
||||
approved: true,
|
||||
question: "Which path is current?",
|
||||
severity: "high",
|
||||
domain: "fixture",
|
||||
required_authority: null,
|
||||
required_confidence: "derived",
|
||||
required_environment: null,
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
}),
|
||||
mutation,
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
const relocatedMutation = createEvidence(
|
||||
{ ...TARGET, path: "/relocated/mutation" },
|
||||
PROVIDER,
|
||||
{
|
||||
predicateType: "rea.residual-unknown-mutation",
|
||||
operation: "record_unknown",
|
||||
parameters: {},
|
||||
result: { action: "record", question: "Which path is current?" },
|
||||
},
|
||||
);
|
||||
expect(relocatedMutation.evidence_id).toBe(mutation.evidence_id);
|
||||
const bundle = createEvidenceBundle(
|
||||
[relocatedMutation],
|
||||
ledger.export().unknowns,
|
||||
);
|
||||
|
||||
expect(ledger.import(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 0,
|
||||
changed: true,
|
||||
metadataChanged: true,
|
||||
},
|
||||
});
|
||||
expect(ledger.get(mutation.evidence_id)).toEqual(relocatedMutation);
|
||||
expect(ledger.import(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 0,
|
||||
changed: false,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains more than ten thousand inline evidence records", () => {
|
||||
const ledger = new EvidenceLedger();
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface EvidenceImportDelta {
|
||||
readonly recordsAdded: number;
|
||||
readonly unknownsAdded: number;
|
||||
readonly changed: boolean;
|
||||
readonly metadataChanged: boolean;
|
||||
}
|
||||
|
||||
type ImportResult = Result<EvidenceImportDelta, EvidenceLedgerFailure>;
|
||||
@@ -70,11 +71,50 @@ export class EvidenceLedger {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// A record-only bundle cannot change the already-valid unknown graph. The
|
||||
// bundle parser has validated every incoming record and its manifests, so
|
||||
// checking ID conflicts and committing only the additions preserves the
|
||||
// atomic merge without rebuilding and reparsing the entire ledger.
|
||||
if (bundle.unknowns.length === 0) {
|
||||
const additions = new Map<string, Evidence>();
|
||||
const replacements = new Map<string, Evidence>();
|
||||
let metadataChanged = false;
|
||||
for (const evidence of bundle.records) {
|
||||
const existing = this.#records.get(evidence.evidence_id);
|
||||
if (existing !== undefined) {
|
||||
if (!recordsAgree(existing, evidence))
|
||||
return err(
|
||||
new EvidenceIntegrityError("Conflicting evidence record"),
|
||||
);
|
||||
if (canonicalize(existing) !== canonicalize(evidence)) {
|
||||
replacements.set(evidence.evidence_id, evidence);
|
||||
metadataChanged = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
additions.set(evidence.evidence_id, evidence);
|
||||
}
|
||||
for (const [id, evidence] of replacements)
|
||||
this.#records.set(id, evidence);
|
||||
for (const [id, evidence] of additions) this.#records.set(id, evidence);
|
||||
return ok({
|
||||
recordsAdded: additions.size,
|
||||
unknownsAdded: 0,
|
||||
changed: additions.size > 0 || metadataChanged,
|
||||
metadataChanged,
|
||||
});
|
||||
}
|
||||
const pending = new Map(this.#records);
|
||||
let metadataChanged = false;
|
||||
for (const evidence of bundle.records) {
|
||||
const existing = pending.get(evidence.evidence_id);
|
||||
if (existing !== undefined && !recordsAgree(existing, evidence))
|
||||
return err(new EvidenceIntegrityError("Conflicting evidence record"));
|
||||
if (
|
||||
existing !== undefined &&
|
||||
canonicalize(existing) !== canonicalize(evidence)
|
||||
)
|
||||
metadataChanged = true;
|
||||
pending.set(evidence.evidence_id, evidence);
|
||||
}
|
||||
const pendingUnknowns = new Map(this.#unknownRevisions);
|
||||
@@ -108,7 +148,8 @@ export class EvidenceLedger {
|
||||
return ok({
|
||||
recordsAdded: added,
|
||||
unknownsAdded,
|
||||
changed: added > 0 || unknownsAdded > 0,
|
||||
changed: added > 0 || unknownsAdded > 0 || metadataChanged,
|
||||
metadataChanged,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -133,11 +133,21 @@ describe("investigation record ownership", () => {
|
||||
).toBe(true);
|
||||
expect(records.mergeEvidenceBundle(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: { recordsAdded: 0, unknownsAdded: 1, changed: true },
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 1,
|
||||
changed: true,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
expect(records.mergeEvidenceBundle(bundle)).toEqual({
|
||||
ok: true,
|
||||
value: { recordsAdded: 0, unknownsAdded: 0, changed: false },
|
||||
value: {
|
||||
recordsAdded: 0,
|
||||
unknownsAdded: 0,
|
||||
changed: false,
|
||||
metadataChanged: false,
|
||||
},
|
||||
});
|
||||
records.clear();
|
||||
expect(records.exportEvidenceBundle()).toEqual(createEvidenceBundle([]));
|
||||
|
||||
+7
-7
@@ -7,19 +7,19 @@ import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
type AnalysisOperation,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { EnhancedTools } from "./EnhancedTools.js";
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { EnhancedTools } from "../EnhancedTools.js";
|
||||
import {
|
||||
nativeInvestigationGraphSchema,
|
||||
nativeInvestigationTraceSchema,
|
||||
} from "../domain/nativeInvestigationGraph.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
} from "../../domain/native/nativeInvestigationGraph.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
const target = "a".repeat(64);
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { functionDossierSchema } from "../domain/hopperValues.js";
|
||||
import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
|
||||
import { nativeApiBoundarySchema } from "../domain/nativeApiBoundary.js";
|
||||
import { functionDossierSchema } from "../../domain/hopperValues.js";
|
||||
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
|
||||
import { nativeApiBoundarySchema } from "../../domain/native/nativeApiBoundary.js";
|
||||
import { projectNativeApiInspection } from "./NativeApiInspection.js";
|
||||
|
||||
describe("native API switch uncertainty", () => {
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import type { FunctionDossier } from "../domain/hopperValues.js";
|
||||
import type { FunctionDossier } from "../../domain/hopperValues.js";
|
||||
import {
|
||||
nativeApiInspectionResultSchema,
|
||||
type NativeApiBoundary,
|
||||
type NativeApiInspectionResult,
|
||||
} from "../domain/nativeApiBoundary.js";
|
||||
} from "../../domain/native/nativeApiBoundary.js";
|
||||
|
||||
const unavailableBoundary = (): NativeApiBoundary => ({
|
||||
available: false,
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
|
||||
import { createAnalysisExecution } from "./AnalysisProvider.js";
|
||||
import { ghidraReferenceEdge } from "../domain/ghidraValues.fixture.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { createAnalysisExecution } from "../AnalysisProvider.js";
|
||||
import { ghidraReferenceEdge } from "../../domain/ghidraValues.fixture.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
describe("typed native call routes", () => {
|
||||
@@ -1,11 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
|
||||
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
|
||||
import {
|
||||
parseRelatedAddresses,
|
||||
referenceKindSchema,
|
||||
} from "../domain/hopperValues.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
} from "../../domain/hopperValues.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const referencesSchema = z.object({
|
||||
reference_kinds_available: z.boolean().optional(),
|
||||
+6
-6
@@ -2,12 +2,12 @@ import { z } from "zod";
|
||||
import {
|
||||
nativeDispatchMetadataResultSchema,
|
||||
inspectNativeDispatchMetadata,
|
||||
} from "../domain/objcSwiftMetadata.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "./EnhancedToolTypes.js";
|
||||
} from "../../domain/native/objcSwiftMetadata.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "../EnhancedToolTypes.js";
|
||||
/** Prefer validated binary metadata, otherwise preserve the bound provider's symbol-only coverage. */
|
||||
export const inspectNativeDispatch = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
+10
-10
@@ -1,22 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "./EnhancedToolTypes.js";
|
||||
import type { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
import { interfaceBuilderAnalysisSchema } from "../domain/interfaceBuilderGraph.js";
|
||||
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "../EnhancedToolTypes.js";
|
||||
import type { enhancedInputSchemas } from "../../contracts/enhancedInputs.js";
|
||||
import { interfaceBuilderAnalysisSchema } from "../../domain/apple/interfaceBuilderGraph.js";
|
||||
import {
|
||||
nativeInvestigationGraphSchema,
|
||||
nativeInvestigationTraceSchema,
|
||||
traceNativeInvestigationGraph,
|
||||
joinInterfaceBuilderDispatch,
|
||||
} from "../domain/nativeInvestigationGraph.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../domain/objcSwiftMetadata.js";
|
||||
} from "../../domain/native/nativeInvestigationGraph.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../../domain/native/objcSwiftMetadata.js";
|
||||
import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
|
||||
type Input = z.output<typeof enhancedInputSchemas.trace_native_ui_action>;
|
||||
/** Build a UI-to-code trace from the active app and selected native provider. */
|
||||
+6
-6
@@ -3,12 +3,12 @@ import { traceNativeValues } from "./NativeValueTrace.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisOperationPort,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
|
||||
import { functionDossierSchema } from "../domain/hopperValues.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { ok, err } from "../domain/result.js";
|
||||
} from "../AnalysisProvider.js";
|
||||
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
|
||||
import { functionDossierSchema } from "../../domain/hopperValues.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { ok, err } from "../../domain/result.js";
|
||||
|
||||
const subject = {
|
||||
path: "/fixture",
|
||||
@@ -1,17 +1,17 @@
|
||||
import { functionDossierSchema } from "../domain/hopperValues.js";
|
||||
import { nativeCallTargetsSchema } from "../domain/nativeInstruction.js";
|
||||
import { functionDossierSchema } from "../../domain/hopperValues.js";
|
||||
import { nativeCallTargetsSchema } from "../../domain/native/nativeInstruction.js";
|
||||
import {
|
||||
nativeValueTraceInputSchema,
|
||||
nativeValueTraceSchema,
|
||||
} from "../domain/nativeValueTrace.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
} from "../../domain/native/nativeValueTrace.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "./EnhancedToolTypes.js";
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "../EnhancedToolTypes.js";
|
||||
|
||||
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
|
||||
export const traceNativeValues = async (
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
type ProviderIdentity,
|
||||
type ExecutionOptions,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js";
|
||||
import { inspectBundleKeyedArchive } from "./apple/KeyedArchiveReader.js";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { inventoryArtifact } from "../application/ArtifactInventory.js";
|
||||
import { extractArtifact } from "../application/ArtifactExtraction.js";
|
||||
import { analyzeInterfaceBuilderBundle } from "../application/InterfaceBuilderAnalysis.js";
|
||||
import { analyzeAppleAssetCatalogs } from "../application/AppleAssetCatalogAnalysis.js";
|
||||
import { analyzeInterfaceBuilderBundle } from "./apple/InterfaceBuilderAnalysis.js";
|
||||
import { analyzeAppleAssetCatalogs } from "./apple/AppleAssetCatalogAnalysis.js";
|
||||
import {
|
||||
ARTIFACT_ANALYSIS_OPERATIONS,
|
||||
artifactInventoryInputSchema,
|
||||
@@ -24,7 +24,7 @@ import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.
|
||||
import { ArtifactOperationError } from "../domain/artifactOperationError.js";
|
||||
import { type AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { interfaceBuilderLimitsSchema } from "../domain/interfaceBuilderGraph.js";
|
||||
import { interfaceBuilderLimitsSchema } from "../domain/apple/interfaceBuilderGraph.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { sep } from "node:path";
|
||||
import { constants, createReadStream, type Stats } from "node:fs";
|
||||
import { lstat, open, realpath, type FileHandle } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { extractFile, listPackage, statFile, uncache } from "@electron/asar";
|
||||
import { getRawHeader, listPackage, statFile, uncache } from "@electron/asar";
|
||||
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
} from "./ArtifactReader.js";
|
||||
import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
||||
|
||||
/**
|
||||
* Official Electron ASAR adapter. Individual files remain caller-bounded.
|
||||
* Official Electron ASAR adapter with range-streamed member reads.
|
||||
*
|
||||
* An entry marked `unpacked` is metadata, not an integrity exemption: Electron
|
||||
* stores its bytes beside the archive in `<archive>.unpacked`, and callers must
|
||||
@@ -19,17 +21,33 @@ import {
|
||||
*/
|
||||
export class AsarArtifactReader implements ArtifactReader {
|
||||
readonly format = "asar" as const;
|
||||
#archiveSize: number | undefined;
|
||||
#headerSize: number | undefined;
|
||||
readonly #entries = new Map<string, AsarEntryState>();
|
||||
|
||||
constructor(private readonly path: string) {}
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
private readonly openUnpackedFile: OpenAsarUnpackedFile = open,
|
||||
) {}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
let paths: string[];
|
||||
try {
|
||||
// ASAR caches headers by path even after the archive has been replaced.
|
||||
this.#resetArchiveState();
|
||||
this.#entries.clear();
|
||||
uncache(this.path);
|
||||
paths = listPackage(this.path, { isPack: false }).sort((left, right) =>
|
||||
left.localeCompare(right, "en"),
|
||||
);
|
||||
this.#headerSize = getRawHeader(this.path).headerSize;
|
||||
const archiveMetadata = await lstat(this.path);
|
||||
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"ASAR container is not a regular file",
|
||||
);
|
||||
this.#archiveSize = archiveMetadata.size;
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, "inventory", cause);
|
||||
}
|
||||
@@ -56,7 +74,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
: "link" in metadata
|
||||
? "symlink"
|
||||
: "file";
|
||||
yield {
|
||||
const entry: ArtifactEntry = {
|
||||
path,
|
||||
kind,
|
||||
declaredSize: "size" in metadata ? metadata.size : null,
|
||||
@@ -74,42 +92,121 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
limitations:
|
||||
kind === "symlink"
|
||||
? ["ASAR symlink target was not followed or disclosed."]
|
||||
: [
|
||||
"Official ASAR extraction buffers each individually bounded file.",
|
||||
],
|
||||
: [],
|
||||
adapterKey: providerPath,
|
||||
};
|
||||
if (kind === "file" && isAsarFileMetadata(metadata))
|
||||
this.#entries.set(providerPath, {
|
||||
metadata,
|
||||
entry: copyArtifactEntry(entry),
|
||||
});
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
|
||||
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
||||
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
||||
abortIfNeeded(signal);
|
||||
if (entry.kind !== "file")
|
||||
return Promise.reject(
|
||||
new ArtifactReaderFailure("format", "ASAR entry is not a regular file"),
|
||||
const state = this.#entries.get(entry.adapterKey);
|
||||
if (state === undefined && entry.kind !== "file")
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"ASAR entry is not a regular file",
|
||||
);
|
||||
if (state === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR entry was not produced by this reader: ${entry.path}`,
|
||||
);
|
||||
if (!sameArtifactEntry(entry, state.entry))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR entry metadata changed since inventory: ${state.entry.path}`,
|
||||
);
|
||||
const { metadata } = state;
|
||||
const producedEntry = state.entry;
|
||||
if (producedEntry.unpacked) {
|
||||
const handle = await this.#openUnpackedEntry(producedEntry);
|
||||
try {
|
||||
const observed = await handle.stat();
|
||||
abortIfNeeded(signal);
|
||||
if (!observed.isFile())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`ASAR unpacked entry is not a regular file: ${producedEntry.path}`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw asarFailure(this.path, `read ${producedEntry.path}`, cause);
|
||||
}
|
||||
const source = handle.createReadStream({
|
||||
start: 0,
|
||||
autoClose: true,
|
||||
});
|
||||
return readValidatedAsarEntry(
|
||||
source,
|
||||
undefined,
|
||||
producedEntry.path,
|
||||
this.path,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
const archiveSize = this.#archiveSize;
|
||||
const headerSize = this.#headerSize;
|
||||
const offset =
|
||||
metadata.offset === undefined
|
||||
? undefined
|
||||
: parseArchiveOffset(metadata.offset);
|
||||
const start =
|
||||
offset === undefined || headerSize === undefined
|
||||
? undefined
|
||||
: 8 + headerSize + offset;
|
||||
if (
|
||||
archiveSize === undefined ||
|
||||
start === undefined ||
|
||||
!Number.isSafeInteger(start) ||
|
||||
metadata.size < 0 ||
|
||||
!Number.isSafeInteger(metadata.size) ||
|
||||
start > archiveSize ||
|
||||
metadata.size > archiveSize - start
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`ASAR entry range is outside its container: ${producedEntry.path}`,
|
||||
);
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
return Promise.resolve(
|
||||
Readable.from(extractFile(this.path, entry.adapterKey, false)),
|
||||
const openedHandle = await open(
|
||||
this.path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
handle = openedHandle;
|
||||
const observed = await openedHandle.stat();
|
||||
abortIfNeeded(signal);
|
||||
if (!observed.isFile() || observed.size !== archiveSize)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR container changed before read: ${producedEntry.path}`,
|
||||
);
|
||||
if (metadata.size === 0) {
|
||||
await closeAsarHandle(() => openedHandle.close(), signal);
|
||||
handle = undefined;
|
||||
return Readable.from([]);
|
||||
}
|
||||
const source = openedHandle.createReadStream({
|
||||
start,
|
||||
end: start + metadata.size - 1,
|
||||
autoClose: true,
|
||||
});
|
||||
return readValidatedAsarEntry(
|
||||
source,
|
||||
metadata.size,
|
||||
producedEntry.path,
|
||||
this.path,
|
||||
signal,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (entry.unpacked && isMissingFile(cause))
|
||||
return Promise.reject(
|
||||
new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`ASAR unpacked entry bytes are unavailable: ${entry.path}`,
|
||||
{ cause },
|
||||
{
|
||||
logicalPath: entry.path,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
calculatedSha256: null,
|
||||
unpacked: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
return Promise.reject(
|
||||
asarFailure(this.path, `read ${entry.path}`, cause),
|
||||
);
|
||||
await handle?.close().catch(() => undefined);
|
||||
throw asarFailure(this.path, `read ${producedEntry.path}`, cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,14 +218,154 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
|
||||
close(): Promise<void> {
|
||||
uncache(this.path);
|
||||
this.#resetArchiveState();
|
||||
this.#entries.clear();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
provenance(): readonly [] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async #openUnpackedEntry(entry: ArtifactEntry): Promise<UnpackedFileHandle> {
|
||||
const unpackedRoot = `${this.path}.unpacked`;
|
||||
try {
|
||||
const rootMetadata = await lstat(unpackedRoot);
|
||||
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"ASAR unpacked companion is not a regular directory",
|
||||
);
|
||||
const canonicalRoot = await realpath(unpackedRoot);
|
||||
const candidate = join(unpackedRoot, entry.adapterKey);
|
||||
const canonical = await realpath(candidate);
|
||||
const relativePath = relative(canonicalRoot, canonical);
|
||||
if (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativePath)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`ASAR unpacked entry escaped its companion directory: ${entry.path}`,
|
||||
);
|
||||
const pathMetadata = await lstat(candidate);
|
||||
if (!pathMetadata.isFile() || pathMetadata.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`ASAR unpacked entry is not a regular file: ${entry.path}`,
|
||||
);
|
||||
const handle = await this.openUnpackedFile(
|
||||
canonical,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
const openedMetadata = await handle
|
||||
.stat()
|
||||
.catch(async (cause: unknown) => {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw cause;
|
||||
});
|
||||
if (
|
||||
!openedMetadata.isFile() ||
|
||||
openedMetadata.dev !== pathMetadata.dev ||
|
||||
openedMetadata.ino !== pathMetadata.ino
|
||||
) {
|
||||
await handle.close();
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR unpacked entry changed before read: ${entry.path}`,
|
||||
{},
|
||||
{
|
||||
logicalPath: entry.path,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
calculatedSha256: null,
|
||||
unpacked: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
return handle;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
if (entry.unpacked && isMissingFile(cause))
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`ASAR unpacked entry bytes are unavailable: ${entry.path}`,
|
||||
{ cause },
|
||||
{
|
||||
logicalPath: entry.path,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
calculatedSha256: null,
|
||||
unpacked: true,
|
||||
},
|
||||
);
|
||||
throw asarFailure(this.path, `read ${entry.path}`, cause);
|
||||
}
|
||||
}
|
||||
|
||||
#resetArchiveState(): void {
|
||||
this.#archiveSize = undefined;
|
||||
this.#headerSize = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type AsarFileMetadata = Extract<ReturnType<typeof statFile>, { size: number }>;
|
||||
type AsarEntryState = {
|
||||
readonly metadata: AsarFileMetadata;
|
||||
readonly entry: ArtifactEntry;
|
||||
};
|
||||
type UnpackedFileHandle = {
|
||||
stat(): Promise<Stats>;
|
||||
close(): Promise<void>;
|
||||
createReadStream: FileHandle["createReadStream"];
|
||||
};
|
||||
type OpenAsarUnpackedFile = (
|
||||
path: string,
|
||||
flags: number,
|
||||
) => Promise<UnpackedFileHandle>;
|
||||
|
||||
const copyArtifactEntry = (entry: ArtifactEntry): ArtifactEntry => ({
|
||||
...entry,
|
||||
limitations: [...entry.limitations],
|
||||
...(entry.sourceIdentity === undefined
|
||||
? {}
|
||||
: { sourceIdentity: { ...entry.sourceIdentity } }),
|
||||
});
|
||||
|
||||
const sameArtifactEntry = (
|
||||
entry: ArtifactEntry,
|
||||
produced: ArtifactEntry,
|
||||
): boolean =>
|
||||
entry.path === produced.path &&
|
||||
entry.kind === produced.kind &&
|
||||
entry.declaredSize === produced.declaredSize &&
|
||||
entry.compressedSize === produced.compressedSize &&
|
||||
entry.executable === produced.executable &&
|
||||
entry.encrypted === produced.encrypted &&
|
||||
entry.byteOffset === produced.byteOffset &&
|
||||
entry.declaredSha256 === produced.declaredSha256 &&
|
||||
entry.unpacked === produced.unpacked &&
|
||||
entry.adapterKey === produced.adapterKey &&
|
||||
entry.limitations.length === produced.limitations.length &&
|
||||
entry.limitations.every(
|
||||
(limitation, index) => limitation === produced.limitations[index],
|
||||
) &&
|
||||
entry.sourceIdentity?.device === produced.sourceIdentity?.device &&
|
||||
entry.sourceIdentity?.inode === produced.sourceIdentity?.inode;
|
||||
|
||||
const isAsarFileMetadata = (
|
||||
metadata: ReturnType<typeof statFile>,
|
||||
): metadata is AsarFileMetadata =>
|
||||
"size" in metadata &&
|
||||
typeof metadata.size === "number" &&
|
||||
!("files" in metadata) &&
|
||||
!("link" in metadata);
|
||||
|
||||
const parseArchiveOffset = (value: string): number | undefined => {
|
||||
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
|
||||
const offset = Number(value);
|
||||
return Number.isSafeInteger(offset) ? offset : undefined;
|
||||
};
|
||||
|
||||
const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled");
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
/** Close an ASAR file handle before honoring cancellation of an empty member. */
|
||||
export const closeAsarHandle = async (
|
||||
close: () => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await close();
|
||||
abortIfNeeded(signal);
|
||||
};
|
||||
|
||||
/** Validate and contextualize bytes read from one ASAR member source. */
|
||||
export const readValidatedAsarEntry = (
|
||||
source: Readable,
|
||||
expectedBytes: number | undefined,
|
||||
entryPath: string,
|
||||
archivePath: string,
|
||||
signal?: AbortSignal,
|
||||
): Readable => {
|
||||
const output = Readable.from(
|
||||
(async function* () {
|
||||
let observedBytes = 0;
|
||||
const iterator = source[Symbol.asyncIterator]();
|
||||
try {
|
||||
while (true) {
|
||||
abortIfNeeded(signal);
|
||||
const next = await iterator.next();
|
||||
if (next.done) break;
|
||||
const chunk: unknown = next.value;
|
||||
if (!Buffer.isBuffer(chunk) && !(chunk instanceof Uint8Array))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`ASAR entry did not return bytes: ${entryPath}`,
|
||||
);
|
||||
observedBytes += Buffer.isBuffer(chunk)
|
||||
? chunk.length
|
||||
: chunk.byteLength;
|
||||
if (expectedBytes !== undefined && observedBytes > expectedBytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR entry exceeded its declared size: ${entryPath}`,
|
||||
);
|
||||
yield chunk;
|
||||
}
|
||||
if (expectedBytes !== undefined && observedBytes !== expectedBytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ASAR entry size disagrees with its header: ${entryPath}`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(archivePath, `read ${entryPath}`, cause);
|
||||
} finally {
|
||||
try {
|
||||
await iterator.return?.();
|
||||
} catch {
|
||||
// Preserve the read failure; source destruction below owns cleanup.
|
||||
}
|
||||
if (!source.destroyed) source.destroy();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
const closeSource = (): void => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (!source.destroyed) source.destroy();
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
closeSource();
|
||||
output.destroy(
|
||||
new ArtifactReaderFailure("cancelled", "ASAR operation cancelled"),
|
||||
);
|
||||
};
|
||||
output.once("close", closeSource);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) onAbort();
|
||||
return output;
|
||||
};
|
||||
|
||||
const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled");
|
||||
};
|
||||
|
||||
const asarFailure = (
|
||||
path: string,
|
||||
operation: string,
|
||||
cause: unknown,
|
||||
): ArtifactReaderFailure => {
|
||||
if (cause instanceof ArtifactReaderFailure) return cause;
|
||||
if (isFilesystemFailure(cause))
|
||||
return new ArtifactReaderFailure(
|
||||
"io",
|
||||
`Could not ${operation} ASAR at ${path}: ${cause.message}`,
|
||||
{ cause },
|
||||
);
|
||||
return new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Malformed ASAR during ${operation}: ${path}`,
|
||||
{ cause },
|
||||
);
|
||||
};
|
||||
|
||||
const isFilesystemFailure = (
|
||||
cause: unknown,
|
||||
): cause is NodeJS.ErrnoException & Error =>
|
||||
cause instanceof Error &&
|
||||
"errno" in cause &&
|
||||
typeof cause.errno === "number" &&
|
||||
"syscall" in cause &&
|
||||
typeof cause.syscall === "string";
|
||||
@@ -13,34 +13,48 @@ import {
|
||||
export class DirectoryArtifactReader implements ArtifactReader {
|
||||
readonly format = "directory" as const;
|
||||
readonly #rootPromise: Promise<string>;
|
||||
readonly #rootPath: string;
|
||||
|
||||
constructor(root: string) {
|
||||
this.#rootPath = root;
|
||||
this.#rootPromise = realpath(root);
|
||||
}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
const root = await this.#rootPromise;
|
||||
const root = await this.#rootPromise.catch((cause: unknown) => {
|
||||
throw directoryIoFailure("resolve artifact root", this.#rootPath, cause);
|
||||
});
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
abortIfNeeded(signal);
|
||||
const directory = pending.pop();
|
||||
if (directory === undefined) break;
|
||||
await assertContainedDirectory(root, directory);
|
||||
await withDirectoryIoContext("inspect directory", directory, () =>
|
||||
assertContainedDirectory(root, directory),
|
||||
);
|
||||
const children: string[] = [];
|
||||
const handle = await opendir(directory);
|
||||
const handle = await opendir(directory).catch((cause: unknown) => {
|
||||
throw directoryIoFailure("open directory", directory, cause);
|
||||
});
|
||||
try {
|
||||
for await (const child of handle) children.push(child.name);
|
||||
} catch (cause: unknown) {
|
||||
throw directoryIoFailure("read directory", directory, cause);
|
||||
} finally {
|
||||
// best-effort cleanup: directory-handle close must not mask traversal.
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
await assertContainedDirectory(root, directory);
|
||||
await withDirectoryIoContext("inspect directory", directory, () =>
|
||||
assertContainedDirectory(root, directory),
|
||||
);
|
||||
children.sort((left, right) => left.localeCompare(right, "en"));
|
||||
const directories: string[] = [];
|
||||
for (const name of children) {
|
||||
abortIfNeeded(signal);
|
||||
const absolute = join(directory, name);
|
||||
const metadata = await lstat(absolute);
|
||||
const metadata = await lstat(absolute).catch((cause: unknown) => {
|
||||
throw directoryIoFailure("inspect entry", absolute, cause);
|
||||
});
|
||||
const path = relative(root, absolute).split(sep).join("/");
|
||||
const kind = metadata.isSymbolicLink()
|
||||
? "symlink"
|
||||
@@ -86,9 +100,17 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
const handle = await open(
|
||||
entry.adapterKey,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
).catch((cause: unknown) => {
|
||||
throw directoryIoFailure("open entry", entry.adapterKey, cause);
|
||||
});
|
||||
try {
|
||||
const observed = await handle.stat();
|
||||
const observed = await handle.stat().catch((cause: unknown) => {
|
||||
throw directoryIoFailure(
|
||||
"inspect opened entry",
|
||||
entry.adapterKey,
|
||||
cause,
|
||||
);
|
||||
});
|
||||
if (
|
||||
!observed.isFile() ||
|
||||
entry.sourceIdentity === undefined ||
|
||||
@@ -117,6 +139,38 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
}
|
||||
}
|
||||
|
||||
const directoryIoFailure = (
|
||||
operation: string,
|
||||
path: string,
|
||||
cause: unknown,
|
||||
): ArtifactReaderFailure => {
|
||||
if (cause instanceof ArtifactReaderFailure) return cause;
|
||||
const code =
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
typeof cause.code === "string"
|
||||
? ` (${cause.code})`
|
||||
: "";
|
||||
return new ArtifactReaderFailure(
|
||||
"io",
|
||||
`Could not ${operation} at ${path}${code}`,
|
||||
{ cause },
|
||||
);
|
||||
};
|
||||
|
||||
const withDirectoryIoContext = async <T>(
|
||||
operation: string,
|
||||
path: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await action();
|
||||
} catch (cause: unknown) {
|
||||
throw directoryIoFailure(operation, path, cause);
|
||||
}
|
||||
};
|
||||
|
||||
const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
|
||||
@@ -37,13 +37,18 @@ export interface NativeDmgHost {
|
||||
arguments_: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
options?: { readonly timeoutMs: number },
|
||||
): Promise<{ readonly stdout: string; readonly exitCode: number }>;
|
||||
): Promise<{
|
||||
readonly stdout: string;
|
||||
readonly stderr?: string;
|
||||
readonly exitCode: number;
|
||||
readonly cause?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
const systemHost: NativeDmgHost = {
|
||||
async run(arguments_, signal, options) {
|
||||
try {
|
||||
const { stdout } = await execFileOutput(
|
||||
const { stdout, stderr } = await execFileOutput(
|
||||
"/usr/bin/hdiutil",
|
||||
[...arguments_],
|
||||
{
|
||||
@@ -51,13 +56,23 @@ const systemHost: NativeDmgHost = {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
return { stdout, exitCode: 0 };
|
||||
return { stdout, stderr, exitCode: 0 };
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && cause.name === "AbortError")
|
||||
throw new ArtifactReaderFailure("cancelled", "DMG operation cancelled");
|
||||
throw new ArtifactReaderFailure("format", "hdiutil rejected the DMG", {
|
||||
cause,
|
||||
});
|
||||
const exitCode = processExitCode(cause);
|
||||
if (exitCode !== undefined)
|
||||
return {
|
||||
stdout: processOutput(cause, "stdout"),
|
||||
stderr: processOutput(cause, "stderr"),
|
||||
exitCode,
|
||||
cause,
|
||||
};
|
||||
throw new ArtifactReaderFailure(
|
||||
commandFailureReason(cause, arguments_[0]),
|
||||
`hdiutil ${arguments_[0] ?? "operation"} failed: ${describeCommandFailure(cause, arguments_)}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -250,15 +265,157 @@ const runChecked = async (
|
||||
signal?: AbortSignal,
|
||||
options?: { readonly timeoutMs: number },
|
||||
): Promise<{ readonly stdout: string; readonly exitCode: 0 }> => {
|
||||
const result = await host.run(arguments_, signal, options);
|
||||
if (result.exitCode !== 0)
|
||||
let result: Awaited<ReturnType<NativeDmgHost["run"]>>;
|
||||
try {
|
||||
result = await host.run(arguments_, signal, options);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
if (cause instanceof Error && cause.name === "AbortError")
|
||||
throw new ArtifactReaderFailure("cancelled", "DMG operation cancelled", {
|
||||
cause,
|
||||
});
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`hdiutil ${arguments_[0] ?? "operation"} failed with a non-zero exit code`,
|
||||
commandFailureReason(cause, arguments_[0]),
|
||||
`hdiutil ${arguments_[0] ?? "operation"} failed: ${describeCommandFailure(
|
||||
cause,
|
||||
arguments_,
|
||||
)}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
const failure = {
|
||||
command: "/usr/bin/hdiutil",
|
||||
exitCode: result.exitCode,
|
||||
...(result.cause === undefined
|
||||
? {}
|
||||
: { code: processExitCode(result.cause) }),
|
||||
stdout: result.stdout,
|
||||
...(result.stderr === undefined ? {} : { stderr: result.stderr }),
|
||||
};
|
||||
const reason = commandFailureReason(failure, arguments_[0]);
|
||||
const details = describeCommandFailure(failure, arguments_);
|
||||
const unknownVerifyFailure =
|
||||
arguments_[0] === "verify" && reason === "unavailable";
|
||||
throw new ArtifactReaderFailure(
|
||||
reason,
|
||||
unknownVerifyFailure
|
||||
? `hdiutil verify failed; captured diagnostics do not establish the failure cause: ${details}`
|
||||
: `hdiutil ${arguments_[0] ?? "operation"} failed: ${details}`,
|
||||
result.cause === undefined ? undefined : { cause: result.cause },
|
||||
);
|
||||
}
|
||||
return { stdout: result.stdout, exitCode: 0 };
|
||||
};
|
||||
|
||||
const describeCommandFailure = (
|
||||
cause: unknown,
|
||||
arguments_: readonly string[],
|
||||
): string => {
|
||||
const fields: Record<string, string | number | readonly string[]> = {
|
||||
command: "/usr/bin/hdiutil",
|
||||
arguments: [...arguments_],
|
||||
};
|
||||
if (typeof cause === "object" && cause !== null) {
|
||||
for (const key of [
|
||||
"code",
|
||||
"exitCode",
|
||||
"errno",
|
||||
"syscall",
|
||||
"signal",
|
||||
"stdout",
|
||||
"stderr",
|
||||
] as const) {
|
||||
const value = Reflect.get(cause, key);
|
||||
if (typeof value === "string" || typeof value === "number")
|
||||
fields[key] = value;
|
||||
}
|
||||
const code = Reflect.get(cause, "code");
|
||||
if (typeof code === "number") fields.exitCode = code;
|
||||
}
|
||||
if (cause instanceof ArtifactReaderFailure) fields.message = cause.message;
|
||||
else if (cause instanceof Error) fields.message = cause.message;
|
||||
return JSON.stringify(fields);
|
||||
};
|
||||
|
||||
const processExitCode = (cause: unknown): number | undefined => {
|
||||
if (typeof cause !== "object" || cause === null) return undefined;
|
||||
const exitCode = Reflect.get(cause, "exitCode");
|
||||
if (typeof exitCode === "number") return exitCode;
|
||||
const code = Reflect.get(cause, "code");
|
||||
return typeof code === "number" ? code : undefined;
|
||||
};
|
||||
|
||||
const processOutput = (cause: unknown, field: "stdout" | "stderr"): string => {
|
||||
if (typeof cause !== "object" || cause === null) return "";
|
||||
const value = Reflect.get(cause, field);
|
||||
return typeof value === "string" ? value : "";
|
||||
};
|
||||
|
||||
const commandFailureReason = (
|
||||
cause: unknown,
|
||||
operation: string | undefined,
|
||||
): ArtifactReaderFailure["reason"] => {
|
||||
if (typeof cause !== "object" || cause === null) return "unavailable";
|
||||
const code = Reflect.get(cause, "code");
|
||||
const exitCode = Reflect.get(cause, "exitCode");
|
||||
if (typeof code === "number" || typeof exitCode === "number")
|
||||
return operation === "verify" ? verifyFailureReason(cause) : "unavailable";
|
||||
if (code === "ENOENT") {
|
||||
const syscall = Reflect.get(cause, "syscall");
|
||||
return typeof syscall === "string" && syscall.startsWith("spawn")
|
||||
? "unavailable"
|
||||
: "io";
|
||||
}
|
||||
if (
|
||||
code === "EACCES" ||
|
||||
code === "EPERM" ||
|
||||
code === "EIO" ||
|
||||
code === "ENOTDIR" ||
|
||||
code === "EISDIR" ||
|
||||
code === "ENODEV" ||
|
||||
code === "EROFS" ||
|
||||
code === "EMFILE" ||
|
||||
code === "ENFILE"
|
||||
)
|
||||
return "io";
|
||||
return "unavailable";
|
||||
};
|
||||
|
||||
const verifyFailureReason = (
|
||||
cause: unknown,
|
||||
): ArtifactReaderFailure["reason"] => {
|
||||
const diagnostic = verifyFailureDiagnostic(
|
||||
processOutput(cause, "stdout"),
|
||||
processOutput(cause, "stderr"),
|
||||
);
|
||||
switch (diagnostic) {
|
||||
case "image not recognized":
|
||||
return "format";
|
||||
case "invalid checksum":
|
||||
case "image data corrupted":
|
||||
return "integrity";
|
||||
case "No such file or directory":
|
||||
case "Permission denied":
|
||||
case "Input/output error":
|
||||
return "io";
|
||||
default:
|
||||
return "unavailable";
|
||||
}
|
||||
};
|
||||
|
||||
const verifyFailureDiagnostic = (
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): string | undefined => {
|
||||
const prefix = "hdiutil: verify failed - ";
|
||||
return `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith(prefix))
|
||||
?.slice(prefix.length);
|
||||
};
|
||||
|
||||
const command = (
|
||||
arguments_: readonly string[],
|
||||
effects: ArtifactCommand["effects"],
|
||||
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
|
||||
import {
|
||||
appleAssetCatalogInputSchema,
|
||||
parseAppleAssetCatalogRecords,
|
||||
projectAppleAssetCatalogPage,
|
||||
} from "../domain/appleAssetCatalog.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
} from "../../domain/apple/appleAssetCatalog.js";
|
||||
import { execFileOutput } from "../../process/ExecFileOutput.js";
|
||||
|
||||
const ASSETUTIL = "/usr/bin/assetutil";
|
||||
const MAX_CATALOG_BYTES = 64 * 1024 * 1024;
|
||||
@@ -105,7 +105,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
|
||||
const inspectAssetCatalogEntry = async (input: {
|
||||
readonly reader: DirectoryArtifactReader;
|
||||
readonly entry: import("../artifacts/ArtifactReader.js").ArtifactEntry;
|
||||
readonly entry: import("../ArtifactReader.js").ArtifactEntry;
|
||||
readonly runAssetUtil: (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -237,7 +237,7 @@ const runAssetUtil = async (
|
||||
|
||||
const digestBounded = async (
|
||||
reader: DirectoryArtifactReader,
|
||||
entry: import("../artifacts/ArtifactReader.js").ArtifactEntry,
|
||||
entry: import("../ArtifactReader.js").ArtifactEntry,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
if (entry.declaredSize === null || entry.declaredSize > MAX_CATALOG_BYTES)
|
||||
+7
-10
@@ -1,28 +1,25 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseBinary } from "plist";
|
||||
|
||||
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
|
||||
import { projectPlistValue } from "../domain/plistValue.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { projectPlistValue } from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
} from "../domain/propertyListKeys.js";
|
||||
import {
|
||||
decodeNibArchive,
|
||||
type NibArchiveDocument,
|
||||
} from "../artifacts/NibArchive.js";
|
||||
import type { ArtifactEntry } from "../artifacts/ArtifactReader.js";
|
||||
} from "../../domain/propertyListKeys.js";
|
||||
import { decodeNibArchive, type NibArchiveDocument } from "./NibArchive.js";
|
||||
import type { ArtifactEntry } from "../ArtifactReader.js";
|
||||
import {
|
||||
buildInterfaceBuilderAnalysis,
|
||||
interfaceBuilderLimitsSchema,
|
||||
type InterfaceBuilderDocumentInput,
|
||||
} from "../domain/interfaceBuilderGraph.js";
|
||||
} from "../../domain/apple/interfaceBuilderGraph.js";
|
||||
import {
|
||||
mergeNibHierarchies,
|
||||
projectNibViewHierarchy,
|
||||
type NibHierarchyNode,
|
||||
} from "./NibViewHierarchy.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
|
||||
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user