Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10caef99e7 | ||
|
|
fd65e278be | ||
|
|
5996d8160f | ||
|
|
c938f97fb2 | ||
|
|
c45d912c91 | ||
|
|
ce748be2f3 | ||
|
|
173da755cd | ||
|
|
1eca600515 | ||
|
|
ae9a7f34e1 | ||
|
|
96d1f0f3fb | ||
|
|
beca34f522 | ||
|
|
48a683ac8c | ||
|
|
a3daf8a07b | ||
|
|
9680da76c8 | ||
|
|
46f51ee6cc | ||
|
|
c22d7cd756 | ||
|
|
4c79127fcb | ||
|
|
c8e85b962a | ||
|
|
fc0dbe7f9d | ||
|
|
23f72141d5 | ||
|
|
5d12a0b4b6 | ||
|
|
9958754f6c | ||
|
|
a4b8e9ccee | ||
|
|
c6503edfc4 | ||
|
|
5dd2ce3125 | ||
|
|
811bd9f635 | ||
|
|
9e019ad27d | ||
|
|
117bd23dd5 | ||
|
|
6637161e02 | ||
|
|
dea770d5d9 | ||
|
|
48b92e1be1 | ||
|
|
003707f712 | ||
|
|
dd4bfed614 | ||
|
|
84ec40f1af | ||
|
|
5eb296649d | ||
|
|
cae7620b77 | ||
|
|
c44d17fd3c | ||
|
|
59d33a2733 | ||
|
|
11268fe241 | ||
|
|
e46bed73f1 | ||
|
|
ece15ed6a1 | ||
|
|
97dc6581f4 | ||
|
|
cdc41fc86c | ||
|
|
9c14999efd | ||
|
|
352c46aa75 | ||
|
|
91830f1e0e | ||
|
|
df04f99df0 | ||
|
|
d2e16b2a49 | ||
|
|
928b4d5fe4 | ||
|
|
4ea24f9767 | ||
|
|
2a61deafc6 | ||
|
|
5f8af4ee5f | ||
|
|
e0bd31c18c | ||
|
|
8e6f37e2a0 | ||
|
|
43738bec57 | ||
|
|
03ab521db3 | ||
|
|
0ca70e121c | ||
|
|
f63674cac0 | ||
|
|
d3a55720fc | ||
|
|
36132d3e17 | ||
|
|
1ca5fd38b4 | ||
|
|
84cf7791a8 | ||
|
|
ec4f4ddc58 | ||
|
|
8823c55b0c | ||
|
|
2f3dee3fcf | ||
|
|
d853744969 | ||
|
|
fcfa5b7e88 | ||
|
|
a8b50ea147 | ||
|
|
eeeb4ca379 | ||
|
|
f974353c32 | ||
|
|
cd2ad1f240 | ||
|
|
f90382756d | ||
|
|
68ecdcd7a1 | ||
|
|
1dd6c2dff9 | ||
|
|
c95315d2ce |
@@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json
|
||||
Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`,
|
||||
`cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`,
|
||||
`copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`,
|
||||
`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
`hermes`, `qoder`, `droid`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a
|
||||
scope. `grok_bot` uses a chat registration step rather than local `mcp.json`.
|
||||
For unsupported clients, use version-pinned manual stdio registration or CLI.
|
||||
|
||||
|
||||
@@ -224,10 +224,13 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
|
||||
|
||||
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
|
||||
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
|
||||
`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
that address. Hopper and IDA refuse this interpretation; Ghidra uses
|
||||
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
|
||||
and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
|
||||
and Cortex-M variants are unsupported. `6502` selects NMOS 6502; the entry and
|
||||
complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge
|
||||
containers, bank switching, and mapper semantics are unsupported. Windows
|
||||
Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
|
||||
|
||||
### Function annotations in Ghidra
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
steps:
|
||||
- name: Download portable build outputs from this workflow run
|
||||
if: inputs.build_cache_artifact != ''
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: ${{ inputs.build_cache_artifact }}
|
||||
path: .turbo/cache/
|
||||
@@ -39,14 +39,14 @@ runs:
|
||||
' >> "$GITHUB_OUTPUT"
|
||||
- name: Restore portable build outputs
|
||||
if: inputs.build_cache_artifact == '' && inputs.save != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: ${{ steps.plan.outputs.key }}
|
||||
restore-keys: ${{ steps.plan.outputs.prefix }}
|
||||
- name: Restore and save portable build outputs
|
||||
if: inputs.build_cache_artifact == '' && inputs.save == 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: ${{ steps.plan.outputs.key }}
|
||||
|
||||
@@ -20,11 +20,11 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Consume only API data and execute batch code from the trusted default branch.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Record queue, execution, and matched step timing
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Upload CI timing artifacts
|
||||
if: always() && hashFiles('.cache/ci-job-timings/*') != ''
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ci-job-timings-batch
|
||||
path: .cache/ci-job-timings/
|
||||
|
||||
+75
-49
@@ -63,11 +63,11 @@ jobs:
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
test_selection: ${{ steps.plan.outputs.test_selection }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ inputs.source_sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Bind qualification to the caller revision
|
||||
@@ -118,8 +118,8 @@ jobs:
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
run: test "${CHANGES_RESULT}" = "success"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
run: npm run format:check
|
||||
- name: Upload complete static diagnostics
|
||||
if: always() && needs.changes.outputs.code == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: static-check-reports
|
||||
path: .cache/static-checks/
|
||||
@@ -157,13 +157,13 @@ jobs:
|
||||
matrix:
|
||||
shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: .
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
|
||||
env:
|
||||
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: vitest-report-${{ strategy.job-index }}
|
||||
@@ -208,8 +208,8 @@ jobs:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
@@ -231,13 +231,13 @@ jobs:
|
||||
- name: Download previous timing report
|
||||
if: steps.history.outputs.run-id != ''
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: test-timings
|
||||
run-id: ${{ steps.history.outputs.run-id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: .cache/ci-test-history
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: |
|
||||
@@ -254,7 +254,7 @@ jobs:
|
||||
# test:prepare caches only portable JS, skill, and catalog outputs.
|
||||
# Native package jobs still run their build and prepack checks; Turbo
|
||||
# restores matching outputs or rebuilds when their inputs differ.
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
@@ -280,16 +280,16 @@ jobs:
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
run: test "${CHANGES_RESULT}" = "success"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- if: needs.changes.outputs.code == 'true' && success()
|
||||
run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
with:
|
||||
path: .vitest/blob
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
|
||||
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: node scripts/ci/test-report.mjs
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
|
||||
with:
|
||||
name: test-results
|
||||
@@ -345,7 +345,7 @@ jobs:
|
||||
retention-days: 30
|
||||
- name: Publish successful timing history
|
||||
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: test-timings
|
||||
path: .cache/ci-test-history/test-results.json
|
||||
@@ -366,7 +366,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Verify fixtures from a clean checkout
|
||||
run: npm run verify:fixtures
|
||||
|
||||
@@ -377,8 +377,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Prepare cross compiler
|
||||
@@ -395,12 +395,27 @@ jobs:
|
||||
- run: npm run build:windows-native
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- run: node scripts/build-windows-native.mjs --inspection-fixture
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-native-inspection-fixture
|
||||
path: .cache/windows-native-inspection-fixture/
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
# The process boundary observations are independent of the addon: they
|
||||
# report the caller's and child's own token, environment and breakaway
|
||||
# results from a separate executable. Cross-build it in the lane that
|
||||
# already has MinGW so the Windows job can observe them too.
|
||||
- name: Build Windows process boundary fixture
|
||||
run: |
|
||||
mkdir -p .cache/windows-process-boundary
|
||||
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
|
||||
-o .cache/windows-process-boundary/process-boundary.exe \
|
||||
tests/fixtures/windows/processBoundary.cc -ladvapi32
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-process-boundary-fixture
|
||||
path: .cache/windows-process-boundary/
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
@@ -423,8 +438,8 @@ jobs:
|
||||
NODE_OPTIONS: "--max-old-space-size=1024"
|
||||
UV_THREADPOOL_SIZE: "2"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
@@ -438,11 +453,11 @@ jobs:
|
||||
assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE);
|
||||
console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));"
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
@@ -463,7 +478,7 @@ jobs:
|
||||
run: npm run verify:inspector
|
||||
- name: Retain the exact qualified release package
|
||||
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
@@ -479,13 +494,13 @@ jobs:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
@@ -527,8 +542,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
@@ -539,7 +554,7 @@ jobs:
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm run verify:readiness | tee readiness-summary.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: reconstruction-readiness
|
||||
@@ -556,18 +571,18 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- run: npm run build:cached
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
@@ -596,7 +611,9 @@ jobs:
|
||||
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
|
||||
tests/boundary/filesystem/setupUnavailableProvider.test.ts
|
||||
tests/boundary/filesystem/windowsNpxRegistration.test.ts
|
||||
tests/boundary/android/workspaceGuardianRetry.test.ts
|
||||
tests/acceptance/setup/homeDirectoryFallback.test.ts
|
||||
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
|
||||
tests/boundary/filesystem/webScriptManifestPaths.test.ts
|
||||
tests/boundary/process/ProcessEvidence.host.test.ts
|
||||
tests/acceptance/webModuleCli.test.ts
|
||||
@@ -621,8 +638,17 @@ jobs:
|
||||
src/application/Update.test.ts
|
||||
tests/process-global/updateMaintenance.test.ts
|
||||
tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts
|
||||
- run: npm run verify:windows-native
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-process-boundary-fixture
|
||||
path: .cache/windows-process-boundary/
|
||||
# Passing the fixture adds the breakaway, caller/child token, environment
|
||||
# semantics and ancestor reparse observations, which the lane skips
|
||||
# entirely when it is absent.
|
||||
- run: >-
|
||||
npm run verify:windows-native -- .
|
||||
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-inspection-fixture
|
||||
path: .cache/windows-native-inspection-fixture/
|
||||
@@ -641,7 +667,7 @@ jobs:
|
||||
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
|
||||
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
|
||||
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.19.0
|
||||
- name: Verify native path boundaries on Node 22
|
||||
@@ -651,7 +677,7 @@ jobs:
|
||||
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
|
||||
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
|
||||
tests/boundary/process/sourceMapUncHosts.test.ts
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always()
|
||||
with:
|
||||
name: windows-capabilities
|
||||
@@ -665,8 +691,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
@@ -674,7 +700,7 @@ jobs:
|
||||
- run: npm run docs:check
|
||||
- name: Generation leaves authored files unchanged
|
||||
run: git diff --exit-code
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: generated-docs
|
||||
path: |
|
||||
@@ -781,8 +807,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Require every selected lane to pass
|
||||
@@ -793,7 +819,7 @@ jobs:
|
||||
run: node scripts/ci/required.mjs
|
||||
- name: Upload full qualification receipt artifact
|
||||
if: needs.changes.outputs.tier == 'full' && success()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ${{ steps.gate.outputs.receipt_name }}
|
||||
path: ${{ steps.gate.outputs.receipt_path }}
|
||||
|
||||
@@ -19,16 +19,16 @@ jobs:
|
||||
DOTNET_PROCESSOR_COUNT: "1"
|
||||
COMPlus_GCHeapHardLimit: "30000000"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- uses: actions/setup-dotnet@v4
|
||||
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: "8.0.416"
|
||||
- name: Build benign fixture and independent byte oracle
|
||||
run: node scripts/generate-nativeaot-fixtures.mjs
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: nativeaot-win-x64-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: |
|
||||
|
||||
@@ -19,8 +19,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -28,8 +28,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -21,8 +21,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -22,8 +22,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
@@ -43,3 +43,5 @@ jobs:
|
||||
echo "REA_VERIFY_SOLC_MODULE=${evm_fixture_root}/node_modules/solc/index.js" >> "${GITHUB_ENV}"
|
||||
- name: Verify source-owned compiled bytecode through public CLI and MCP
|
||||
run: npm run verify:evm:interface
|
||||
- name: Verify content-hash-free and CBOR-free owned compiler artifacts
|
||||
run: npm run verify:solidity:metadata
|
||||
|
||||
@@ -16,8 +16,8 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Prepare cross compiler
|
||||
@@ -31,11 +31,25 @@ jobs:
|
||||
sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
|
||||
- run: npm run build:windows-native
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: real-ghidra-windows-native
|
||||
path: native/windows/build/
|
||||
if-no-files-found: error
|
||||
# The boundary observations need a separate Windows executable, which the
|
||||
# native lane skips when it is absent. Cross-build it here, where MinGW is
|
||||
# already installed, so the proof run observes them too.
|
||||
- name: Build Windows process boundary fixture
|
||||
run: |
|
||||
mkdir -p .cache/windows-process-boundary
|
||||
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
|
||||
-o .cache/windows-process-boundary/process-boundary.exe \
|
||||
tests/fixtures/windows/processBoundary.cc -ladvapi32
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: real-ghidra-windows-process-boundary
|
||||
path: .cache/windows-process-boundary/
|
||||
if-no-files-found: error
|
||||
|
||||
verify:
|
||||
name: Real Ghidra 12.1.4 Windows x64 PE proof
|
||||
@@ -45,17 +59,23 @@ jobs:
|
||||
runs-on: [self-hosted, Windows, x64, ghidra-12-1-2]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: real-ghidra-windows-native
|
||||
path: native/windows/build/
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- run: npm run verify:windows-native
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: real-ghidra-windows-process-boundary
|
||||
path: .cache/windows-process-boundary/
|
||||
- run: >-
|
||||
npm run verify:windows-native -- .
|
||||
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
|
||||
- name: Verify all admitted Windows Ghidra operations
|
||||
env:
|
||||
REA_ANALYSIS_PROVIDER: ghidra
|
||||
@@ -88,7 +108,7 @@ jobs:
|
||||
env:
|
||||
REA_ANALYSIS_PROVIDER: ghidra
|
||||
run: npm run verify:ghidra:windows:package -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-package-proof.log
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always()
|
||||
with:
|
||||
name: windows-ghidra-proof
|
||||
|
||||
@@ -44,12 +44,12 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
cache: false
|
||||
|
||||
@@ -15,8 +15,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
@@ -36,8 +36,8 @@ jobs:
|
||||
runs-on: [self-hosted, Linux, hopper]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -15,8 +15,8 @@ jobs:
|
||||
runs-on: [self-hosted, macOS, hopper]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -23,8 +23,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -43,8 +43,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -36,8 +36,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Download the pinned Termux app and verify its release checksum
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
- name: Build and capture a page inside Termux
|
||||
uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
with:
|
||||
api-level: 30
|
||||
arch: x86_64
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
script: bash scripts/verify/termux/emulator.sh
|
||||
- name: Upload Termux and Android diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: termux-browser-diagnostics
|
||||
path: ${{ runner.temp }}/rea-termux/diagnostics
|
||||
|
||||
@@ -22,8 +22,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -17,8 +17,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -17,8 +17,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
echo "frozen=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
if: steps.editorial.outputs.frozen != 'true'
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -70,13 +70,13 @@ jobs:
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
steps:
|
||||
- name: Check out reviewed release merge
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
|
||||
--source "$SOURCE_SHA" --pr "$RELEASE_PR"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -191,14 +191,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out release controller and Git history
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for checkpoint validation
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
node scripts/release-notes.mjs publication --repository "$GH_REPO" \
|
||||
--source "$SOURCE_SHA"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
|
||||
id: release
|
||||
with:
|
||||
# A dedicated credential starts PR CI automatically; the built-in
|
||||
@@ -255,7 +255,7 @@ jobs:
|
||||
|
||||
- name: Check out release pull request
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
|
||||
fetch-depth: 0
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
|
||||
- name: Set up Node.js for generated documentation
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
@@ -314,12 +314,12 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
@@ -328,7 +328,7 @@ jobs:
|
||||
- run: npm ci
|
||||
|
||||
- name: Download the exact qualified package
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
@@ -392,7 +392,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -22,14 +22,14 @@ jobs:
|
||||
# The repository-pinned Node 24 version also runs the complete test suite.
|
||||
node: ["22.x", "24.15.0", "24.x", "26.x"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
check-latest: true
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: .
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Python for website assets
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
@@ -47,9 +47,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Python for website assets
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
@@ -74,9 +74,9 @@ jobs:
|
||||
)
|
||||
PY
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
|
||||
- name: Upload the website
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
|
||||
with:
|
||||
path: website/_site
|
||||
|
||||
@@ -95,15 +95,15 @@ jobs:
|
||||
steps:
|
||||
- name: Check out publishing configuration
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Set up Node.js for Wrangler
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Download the verified website
|
||||
if: inputs.target == 'both'
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: github-pages
|
||||
path: ${{ runner.temp }}/website-archive
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
tar -xf "$RUNNER_TEMP/website-archive/artifact.tar" -C "$RUNNER_TEMP/rea-website-public"
|
||||
- name: Publish the website to Cloudflare
|
||||
if: inputs.target == 'both'
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
command: deploy --cwd website --env production --assets "${{ runner.temp }}/rea-website-public"
|
||||
- name: Publish to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
- name: Verify the selected published revisions
|
||||
env:
|
||||
PAGES_URL: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
@@ -142,6 +142,21 @@ caches deterministic builds and static checks across Git worktrees. After a pack
|
||||
Repository invariants live in [AGENTS.md](AGENTS.md). Keep provider-specific
|
||||
protocols in adapters and shared CLI/MCP workflows in the application layer.
|
||||
|
||||
### Windows source checkouts
|
||||
|
||||
The published npm package includes the Windows x64 native addon and its
|
||||
manifest. A source checkout does not: `native/windows/build/` is ignored by
|
||||
Git, and neither `npm ci` nor `npm run build:cached` (or `npm run build`)
|
||||
produces `rea-windows-x64.node` or `manifest.json` in that directory.
|
||||
Windows workflows and tests that require native controls need these artifacts;
|
||||
without them, those controls are unavailable even after a successful runtime build.
|
||||
|
||||
Follow the [Windows native build guide](native/windows/README.md#build) before
|
||||
running those workflows from source. The artifact build requires Linux,
|
||||
MinGW-w64, and `tar`; the build script does not run on Windows. See that guide
|
||||
for the build and artifact-verification commands and the separate real Windows
|
||||
verification prerequisites. Keep the generated addon and manifest uncommitted.
|
||||
|
||||
## Documentation website
|
||||
|
||||
The VitePress site uses the Markdown files in `docs/`. Run `npm run docs:dev`
|
||||
|
||||
@@ -322,7 +322,7 @@ Report vulnerabilities through [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Star history
|
||||
|
||||
🎉 **170,000 GitHub stars — thank you!**
|
||||
🎉 **180,000 GitHub stars — thank you!**
|
||||
|
||||
Thanks to everyone using REA, reporting bugs, requesting features, testing builds, and contributing fixes.
|
||||
|
||||
|
||||
@@ -3,8 +3,14 @@ import com.google.gson.*;
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.*;
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.*;
|
||||
import jadx.api.*;
|
||||
import jadx.api.plugins.input.data.ICodeReader;
|
||||
import jadx.api.plugins.input.data.IMethodRef;
|
||||
import jadx.api.plugins.input.insns.InsnIndexType;
|
||||
import jadx.api.plugins.input.insns.Opcode;
|
||||
import jadx.core.dex.info.MethodInfo;
|
||||
import jadx.core.dex.nodes.*;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -79,11 +85,17 @@ class ReaJadxBridge {
|
||||
ordered.sort(Comparator.comparing(MethodNode::getAlias));
|
||||
List<MethodSnapshot> methods = ordered.stream().map(method -> {
|
||||
String name = method.getAlias();
|
||||
String shortId = method.getMethodInfo().getShortId();
|
||||
String rawName = method.getMethodInfo().getName();
|
||||
if (!shortId.startsWith(rawName + "("))
|
||||
throw new IllegalStateException("JADX method ID has no raw-name prefix: " + shortId);
|
||||
String descriptor = shortId.substring(rawName.length());
|
||||
String signature = name + "(" + method.getArgTypes().stream()
|
||||
.map(Object::toString).collect(Collectors.joining(", "))
|
||||
+ "): " + method.getReturnType().toString();
|
||||
return new MethodSnapshot(name, method.getMethodInfo().getShortId(), Map.of(
|
||||
return new MethodSnapshot(name, shortId, Map.of(
|
||||
"name", name, "signature", signature,
|
||||
"dex_descriptor", descriptor,
|
||||
"is_constructor", method.isConstructor(), "def_pos", 0));
|
||||
}).toList();
|
||||
List<Map<String, Object>> fields = node.getFields().stream()
|
||||
@@ -140,6 +152,57 @@ class ReaJadxBridge {
|
||||
"smali", true, List.of("static_initializer_disassembly"));
|
||||
}
|
||||
|
||||
// The local transport admits 8 MiB frames. Reserve 1 KiB for the fixed
|
||||
// JSON-RPC/CallToolResult envelope and generated numeric request id, and
|
||||
// account for JSON text being escaped again inside TextContent.
|
||||
private static final int MAX_XREF_TEXT_BYTES = 8 * 1024 * 1024 - 1024;
|
||||
private static int escapedJsonBytes(Object value) {
|
||||
return JSON.toJson(JSON.toJson(value)).getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
private static final class InvokeBudget {
|
||||
long remaining;
|
||||
InvokeBudget(long remaining) { this.remaining = Math.max(0, remaining); }
|
||||
boolean admit(Map<String, Object> site) {
|
||||
// Check a conservative two-layer escape bound before serialization.
|
||||
long bound = 256;
|
||||
for (Object value : site.values())
|
||||
if (value instanceof String text) bound += 12L * text.length();
|
||||
if (bound > remaining) return false;
|
||||
long bytes = escapedJsonBytes(site) - 2L + 1L;
|
||||
if (bytes > remaining) return false;
|
||||
remaining -= bytes;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
private record DirectInvokes(List<Map<String, Object>> sites, boolean exhaustive) {}
|
||||
|
||||
/** Raw DEX invokes within one JADX using method; offsets are code units from its code start. */
|
||||
private static DirectInvokes directInvokes(JavaMethod source, MethodInfo target, InvokeBudget budget) {
|
||||
MethodNode node = source.getMethodNode();
|
||||
ICodeReader code = node.getCodeReader();
|
||||
if (code == null) return null;
|
||||
List<Map<String, Object>> sites = new ArrayList<>();
|
||||
boolean[] exhaustive = {true};
|
||||
code.visitInstructions(insn -> {
|
||||
insn.decode();
|
||||
if (insn.getIndexType() != InsnIndexType.METHOD_REF) return;
|
||||
Opcode opcode = insn.getOpcode();
|
||||
if (!opcode.name().startsWith("INVOKE_")) return;
|
||||
IMethodRef ref = insn.getIndexAsMethod();
|
||||
ref.load();
|
||||
if (!MethodInfo.fromRef(node.root(), ref).getRawFullId().equals(target.getRawFullId())) return;
|
||||
Map<String, Object> site = Map.of(
|
||||
"dex_entry", node.getInputFileName(),
|
||||
"source_raw_method_id", node.getMethodInfo().getRawFullId(),
|
||||
"dex_code_unit_offset", insn.getOffset(),
|
||||
"dex_method_index", insn.getIndex(),
|
||||
"opcode", opcode.name());
|
||||
if (budget.admit(site)) sites.add(site);
|
||||
else exhaustive[0] = false;
|
||||
});
|
||||
return new DirectInvokes(sites, exhaustive[0]);
|
||||
}
|
||||
|
||||
private CallToolResult handle(String name, JsonObject args) {
|
||||
if (name.equals("rea_jvm_status")) return json(Map.of(
|
||||
"engine_reported_version", BuildInfo.VERSION,
|
||||
@@ -173,17 +236,52 @@ class ReaJadxBridge {
|
||||
}
|
||||
List<JavaNode> uses;
|
||||
String target = string(args, "class_name");
|
||||
MethodSnapshot selectedMethod = null;
|
||||
int overloadIndex = 0;
|
||||
if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn();
|
||||
else {
|
||||
List<MethodSnapshot> overloads = methods(cls, args);
|
||||
if (overloads.size() != 1) throw new InputFailure("method_name", "Method references require a unique overload");
|
||||
target += "." + overloads.get(0).name();
|
||||
uses = method(cls, overloads.get(0)).getUseIn();
|
||||
if (overloads.size() != 1 && !args.containsKey("overload_index"))
|
||||
throw new InputFailure("overload_index", "Select one of " + overloads.size() + " method overloads");
|
||||
overloadIndex = integer(args, "overload_index", 0);
|
||||
if (overloadIndex >= overloads.size())
|
||||
throw new InputFailure("overload_index", "overload_index out of range: " + overloadIndex);
|
||||
selectedMethod = overloads.get(overloadIndex);
|
||||
target += "." + selectedMethod.name();
|
||||
uses = method(cls, selectedMethod).getUseIn();
|
||||
}
|
||||
// REA consumes all references without materializing source line information.
|
||||
List<Map<String, Object>> items = uses.stream().map(node -> current.describeUsage(node, false)).toList();
|
||||
return json(Map.of("target", target, "total", uses.size(), "count", items.size(),
|
||||
"resolve_line", false, "items", items));
|
||||
MethodInfo targetInfo = selectedMethod == null ? null : method(cls, selectedMethod).getMethodNode().getMethodInfo();
|
||||
List<Map<String, Object>> items = uses.stream().map(node -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>(current.describeUsage(node, false));
|
||||
if (targetInfo != null && node instanceof JavaMethod) {
|
||||
item.put("direct_invokes", List.of());
|
||||
item.put("direct_invokes_exhaustive", false);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
Map<String, Object> result = new LinkedHashMap<>(Map.of("target", target,
|
||||
"total", uses.size(), "count", items.size(), "resolve_line", false, "items", items));
|
||||
if (selectedMethod != null) {
|
||||
result.put("overload_index", overloadIndex);
|
||||
result.put("reported_signature", selectedMethod.summary().get("signature"));
|
||||
}
|
||||
if (targetInfo != null) {
|
||||
InvokeBudget budget = new InvokeBudget(MAX_XREF_TEXT_BYTES - escapedJsonBytes(result));
|
||||
for (int index = 0; index < uses.size(); index++) {
|
||||
if (!(uses.get(index) instanceof JavaMethod source)) continue;
|
||||
DirectInvokes invokes = directInvokes(source, targetInfo, budget);
|
||||
Map<String, Object> item = items.get(index);
|
||||
if (invokes == null) {
|
||||
item.remove("direct_invokes");
|
||||
item.remove("direct_invokes_exhaustive");
|
||||
} else {
|
||||
item.put("direct_invokes", invokes.sites());
|
||||
item.put("direct_invokes_exhaustive", invokes.exhaustive());
|
||||
}
|
||||
}
|
||||
}
|
||||
return json(result);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
@@ -217,7 +315,7 @@ class ReaJadxBridge {
|
||||
List<String> fields = switch (name) {
|
||||
case "list_classes" -> List.of("offset", "limit");
|
||||
case "get_method_body" -> List.of("class_name", "method_name", "overload_index");
|
||||
case "get_xrefs_to_method" -> List.of("class_name", "method_name");
|
||||
case "get_xrefs_to_method" -> List.of("class_name", "method_name", "overload_index");
|
||||
default -> List.of("class_name");
|
||||
};
|
||||
Map<String, kotlinx.serialization.json.JsonElement> properties = new LinkedHashMap<>();
|
||||
@@ -226,7 +324,7 @@ class ReaJadxBridge {
|
||||
case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit.";
|
||||
case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation.";
|
||||
case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization.";
|
||||
default -> "Complete static references to an exact class or unique method; source lines are unresolved.";
|
||||
default -> "Complete static using-node inventory for an exact class or selected method overload; one node may contain multiple invoke instructions, and source lines are unresolved.";
|
||||
};
|
||||
Tool narrowed = new Tool(name, new ToolSchema(null, new JsonObject(properties),
|
||||
tool.getInputSchema().getRequired(), null), description, null, null, null, null, null, null);
|
||||
|
||||
@@ -13,6 +13,8 @@ import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
@@ -22,8 +24,11 @@ import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Iterator;
|
||||
@@ -82,6 +87,7 @@ import ghidra.program.model.address.SegmentedAddress;
|
||||
import ghidra.program.model.reloc.Relocation;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import ghidra.program.model.symbol.Namespace;
|
||||
import ghidra.program.model.symbol.FlowType;
|
||||
import ghidra.program.model.symbol.RefType;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.symbol.ReferenceIterator;
|
||||
@@ -100,6 +106,9 @@ import ghidra.program.model.pcode.PcodeBlock;
|
||||
import ghidra.program.model.pcode.PcodeBlockBasic;
|
||||
import ghidra.program.model.pcode.Varnode;
|
||||
import ghidra.program.model.pcode.VarnodeAST;
|
||||
import ghidra.program.util.ContextEvaluatorAdapter;
|
||||
import ghidra.program.util.SymbolicPropogator;
|
||||
import ghidra.program.util.VarnodeContext;
|
||||
|
||||
public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private static final int MAX_HIGH_PCODE_OPS = 3000;
|
||||
@@ -804,7 +813,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return functionObservation(result, boundaryLimitations);
|
||||
}
|
||||
|
||||
private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception {
|
||||
private JsonObject procedureCalls(JsonObject params, boolean callers) throws Exception {
|
||||
requireKeys(params, Set.of("document", "procedure"));
|
||||
requireDocument(params);
|
||||
Function function = resolveProcedure(requireString(params, "procedure"));
|
||||
@@ -817,7 +826,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
for (Function item : ordered) {
|
||||
result.add(canonicalAddress(item.getEntryPoint()));
|
||||
}
|
||||
return result;
|
||||
return functionObservation(
|
||||
result,
|
||||
noReturnCallLimitations(
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject procedureInfo(JsonObject params) throws Exception {
|
||||
@@ -1319,6 +1333,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.add("outgoing_references", outgoing);
|
||||
result.add("referenced_strings", referencedStrings);
|
||||
result.add("referenced_names", referencedNames);
|
||||
boolean swift = swiftProgram();
|
||||
boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:");
|
||||
SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null;
|
||||
if (swiftLiterals != null) {
|
||||
result.add("swift_string_literals", swiftLiterals.literals());
|
||||
result.add("swift_string_literal_omissions", swiftLiterals.omissions());
|
||||
}
|
||||
result.add("basic_blocks", blocks);
|
||||
result.add(
|
||||
"native_api",
|
||||
@@ -1338,6 +1359,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
limitations.add(
|
||||
"Synthetic Ghidra entry-point references without actionable memory sources are omitted."
|
||||
);
|
||||
if (swift && aarch64) {
|
||||
limitations.add(
|
||||
"Swift String literals are decoded from constant register pairs at calls, returns and paired stores; literals stored one word at a time, chosen by conditional selects, passed through unresolved jumps, or built at runtime are not reported."
|
||||
);
|
||||
if (swiftLiterals.omissions().size() > 0) {
|
||||
limitations.add(
|
||||
"Swift String literal records listed in swift_string_literal_omissions could not be retained or decoded within this dossier's remaining heap budget; later symbolic flows remain unexamined."
|
||||
);
|
||||
}
|
||||
} else if (swift) {
|
||||
limitations.add(
|
||||
"Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture."
|
||||
);
|
||||
}
|
||||
List<String> boundaryLimitations = noReturnCallLimitations(scan.instructions);
|
||||
for (String limitation : boundaryLimitations) {
|
||||
limitations.add(limitation);
|
||||
@@ -3391,6 +3426,349 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean swiftProgram() {
|
||||
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
|
||||
String name = block.getName();
|
||||
if (name.startsWith("__swift5_") || name.startsWith("swift5_")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swift passes a String as two words: small literals carry their bytes in the words and
|
||||
* immortal large literals carry their count and a biased pointer to the bytes. Ghidra keeps
|
||||
* neither as a data reference, so decode constant pairs where Swift consumes them: argument
|
||||
* and return registers at calls, tail calls and returns, and both registers of one STP.
|
||||
* The canonical empty string is a small literal with a zero count.
|
||||
*/
|
||||
private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception {
|
||||
Register[] registers = new Register[31];
|
||||
for (int index = 0; index < registers.length; index++) {
|
||||
registers[index] = currentProgram.getRegister("x" + index);
|
||||
}
|
||||
TreeMap<String, JsonObject> observed = new TreeMap<>();
|
||||
TreeMap<String, JsonObject> omitted = new TreeMap<>();
|
||||
SwiftLiteralBudget budget = new SwiftLiteralBudget();
|
||||
Map<String, Boolean> selectedValues = new HashMap<>();
|
||||
SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false);
|
||||
propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() {
|
||||
@Override
|
||||
public boolean evaluateContextBefore(VarnodeContext context, Instruction instruction) {
|
||||
// Returning true ends only the current flow, so every queued flow checks the shared stop.
|
||||
if (budget.stopped()) return true;
|
||||
for (Register[] pair : swiftStringPairs(function, instruction, registers)) {
|
||||
Long word = registerConstant(context, pair[0]);
|
||||
Long object = registerConstant(context, pair[1]);
|
||||
if (word == null || object == null) continue;
|
||||
SwiftLiteralLayout layout = swiftLiteralLayout(word, object);
|
||||
if (layout == null) continue;
|
||||
// A selected word can combine with a path-dependent ordinary move. Cache the
|
||||
// provenance question per site/register, charging retained cache state to the
|
||||
// same dossier budget before allocating it.
|
||||
boolean selected = false;
|
||||
boolean provenanceOverCapacity = false;
|
||||
for (Register register : pair) {
|
||||
String provenanceKey = canonicalAddress(instruction.getAddress()) + "\u0000" +
|
||||
register.getBaseRegister().getName();
|
||||
Boolean value = selectedValues.get(provenanceKey);
|
||||
if (value == null) {
|
||||
if (!budget.takeState()) {
|
||||
provenanceOverCapacity = true;
|
||||
break;
|
||||
}
|
||||
value = selectedValue(function, instruction, register);
|
||||
selectedValues.put(provenanceKey, value);
|
||||
}
|
||||
if (value) {
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected) continue;
|
||||
// Forks can reach one site with different constants, so the words are part of the key.
|
||||
String key = canonicalAddress(instruction.getAddress()) + "\u0000" + pair[0].getName() +
|
||||
"\u0000" + pair[1].getName() + "\u0000" + Long.toUnsignedString(word, 16) +
|
||||
"\u0000" + Long.toUnsignedString(object, 16);
|
||||
if (observed.containsKey(key) || omitted.containsKey(key)) continue;
|
||||
// Every record draws on the budget; the first one that does not fit is kept as an
|
||||
// omission and decoding stops, so later sites are never examined.
|
||||
String value = layout.smallValue();
|
||||
boolean overCapacity = provenanceOverCapacity || !budget.take(layout.byteLength());
|
||||
if (!overCapacity && value == null) {
|
||||
try {
|
||||
value = swiftLargeLiteralText(layout);
|
||||
} catch (OutOfMemoryError exhausted) {
|
||||
overCapacity = true;
|
||||
}
|
||||
}
|
||||
if (value == null && !overCapacity) continue;
|
||||
JsonObject item = overCapacity
|
||||
? swiftStringOmission(layout, word, object)
|
||||
: swiftStringItem(value, layout, word, object);
|
||||
item.addProperty("source_address", canonicalAddress(instruction.getAddress()));
|
||||
JsonArray pairRegisters = new JsonArray();
|
||||
pairRegisters.add(pair[0].getName());
|
||||
pairRegisters.add(pair[1].getName());
|
||||
item.add("registers", pairRegisters);
|
||||
(overCapacity ? omitted : observed).put(key, item);
|
||||
if (overCapacity) {
|
||||
budget.stop();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, false, monitor);
|
||||
JsonArray literals = new JsonArray();
|
||||
observed.values().forEach(literals::add);
|
||||
JsonArray omissions = new JsonArray();
|
||||
omitted.values().forEach(omissions::add);
|
||||
return new SwiftStringLiteralDecode(literals, omissions);
|
||||
}
|
||||
|
||||
private static List<Register[]> swiftStringPairs(
|
||||
Function function,
|
||||
Instruction instruction,
|
||||
Register[] registers
|
||||
) {
|
||||
List<Register[]> pairs = new ArrayList<>();
|
||||
if (instruction.getMnemonicString().equals("stp")) {
|
||||
Register first = instruction.getRegister(0);
|
||||
Register second = instruction.getRegister(1);
|
||||
if (first != null && second != null && first.getBitLength() == 64 && second.getBitLength() == 64) {
|
||||
pairs.add(new Register[] {first, second});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
FlowType flow = instruction.getFlowType();
|
||||
boolean returns = false;
|
||||
for (PcodeOp operation : instruction.getPcode()) {
|
||||
if (operation.getOpcode() == PcodeOp.RETURN) returns = true;
|
||||
}
|
||||
// A jump is a proven tail call only when it has resolved targets and all leave the
|
||||
// body. A targetless computed jump can still dispatch inside the function.
|
||||
Address[] targets = instruction.getFlows();
|
||||
boolean tailCall = flow.isJump() && !flow.isConditional() && targets.length > 0 &&
|
||||
Arrays.stream(targets).allMatch(target -> !function.getBody().contains(target));
|
||||
if (!flow.isCall() && !returns && !tailCall) return pairs;
|
||||
// A String occupies two consecutive registers among x0-x7, or x0 and x1 when returned.
|
||||
int lastFirstRegister = returns ? 0 : 6;
|
||||
for (int index = 0; index <= lastFirstRegister; index++) {
|
||||
pairs.add(new Register[] {registers[index], registers[index + 1]});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private static Long registerConstant(VarnodeContext context, Register register) {
|
||||
Varnode value = context.getRegisterVarnodeValue(register);
|
||||
return value != null && context.isConstant(value) ? value.getOffset() : null;
|
||||
}
|
||||
|
||||
// Search every predecessor path for the reaching definitions, following register copies,
|
||||
// since a select copied into an argument register or defined before a branch forks just as
|
||||
// independently as one written there directly.
|
||||
private boolean selectedValue(Function function, Instruction use, Register register) {
|
||||
ArrayDeque<Instruction> instructions = new ArrayDeque<>();
|
||||
ArrayDeque<Register> targets = new ArrayDeque<>();
|
||||
Set<String> visited = new HashSet<>();
|
||||
for (Instruction predecessor : predecessors(function, use)) {
|
||||
instructions.push(predecessor);
|
||||
targets.push(register.getBaseRegister());
|
||||
}
|
||||
while (!instructions.isEmpty()) {
|
||||
Instruction instruction = instructions.pop();
|
||||
Register target = targets.pop();
|
||||
if (!visited.add(canonicalAddress(instruction.getAddress()) + "\u0000" + target.getName())) continue;
|
||||
boolean defines = false;
|
||||
for (Object result : instruction.getResultObjects()) {
|
||||
if (result instanceof Register written && written.getBaseRegister().equals(target)) defines = true;
|
||||
}
|
||||
Register next = target;
|
||||
if (defines) {
|
||||
PcodeOp[] operations = instruction.getPcode();
|
||||
for (PcodeOp operation : operations) {
|
||||
if (operation.getOpcode() == PcodeOp.CBRANCH && operation.getInput(0).isConstant()) return true;
|
||||
}
|
||||
Register source = copiedRegister(operations);
|
||||
if (source == null) continue;
|
||||
next = source.getBaseRegister();
|
||||
}
|
||||
for (Instruction predecessor : predecessors(function, instruction)) {
|
||||
instructions.push(predecessor);
|
||||
targets.push(next);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Instruction> predecessors(Function function, Instruction instruction) {
|
||||
List<Instruction> result = new ArrayList<>();
|
||||
Address from = instruction.getFallFrom();
|
||||
if (from != null) {
|
||||
Instruction previous = currentProgram.getListing().getInstructionAt(from);
|
||||
if (previous != null) result.add(previous);
|
||||
}
|
||||
for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(instruction.getAddress())) {
|
||||
if (!reference.getReferenceType().isFlow() || reference.getReferenceType().isCall()) continue;
|
||||
if (!function.getBody().contains(reference.getFromAddress())) continue;
|
||||
Instruction source = currentProgram.getListing().getInstructionAt(reference.getFromAddress());
|
||||
if (source != null) result.add(source);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Register copiedRegister(PcodeOp[] operations) {
|
||||
if (operations.length != 1) return null;
|
||||
int opcode = operations[0].getOpcode();
|
||||
Varnode input = operations[0].getInput(0);
|
||||
if ((opcode != PcodeOp.COPY && opcode != PcodeOp.INT_ZEXT) || input == null || !input.isRegister()) return null;
|
||||
return currentProgram.getRegister(input);
|
||||
}
|
||||
|
||||
/** Validate the ABI layout of a pair; a small literal is decoded here, a large one only located. */
|
||||
private SwiftLiteralLayout swiftLiteralLayout(long word, long object) {
|
||||
int discriminator = (int) (object >>> 60);
|
||||
if (discriminator == 0xE || discriminator == 0xA) {
|
||||
int count = (int) ((object >>> 56) & 0xF);
|
||||
byte[] bytes = new byte[15];
|
||||
for (int index = 0; index < 8; index++) bytes[index] = (byte) (word >>> (8 * index));
|
||||
for (int index = 0; index < 7; index++) bytes[8 + index] = (byte) (object >>> (8 * index));
|
||||
for (int index = count; index < bytes.length; index++) {
|
||||
if (bytes[index] != 0) return null;
|
||||
}
|
||||
// Count zero is the canonical empty string (object 0xE000000000000000, word 0).
|
||||
String value = swiftStringText(bytes, count, discriminator == 0xE);
|
||||
return value == null ? null : new SwiftLiteralLayout("small", null, count, true, value);
|
||||
}
|
||||
int flags = (int) (word >>> 60);
|
||||
long count = word & 0xFFFFFFFFFFFFL;
|
||||
// Immortal literals: ASCII and NFC or neither, tail allocated, with a pointer biased by 32.
|
||||
if ((object >>> 56) != 0x80 || (flags != 0xD && flags != 0x1) || ((word >>> 48) & 0xFFF) != 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress((object & 0x00FFFFFFFFFFFFFFL) + 0x20);
|
||||
MemoryBlock block = currentProgram.getMemory().getBlock(address);
|
||||
// The bytes and their terminator must lie in one initialized, non-executable block,
|
||||
// and Java arrays bound one read.
|
||||
if (block == null || !block.isInitialized() || block.isExecute() ||
|
||||
count >= block.getEnd().subtract(address) + 1 || count >= Integer.MAX_VALUE) {
|
||||
return null;
|
||||
}
|
||||
return new SwiftLiteralLayout("large", address, count, flags == 0xD, null);
|
||||
} catch (Exception unaddressable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String swiftLargeLiteralText(SwiftLiteralLayout layout) {
|
||||
int count = (int) layout.byteLength();
|
||||
byte[] bytes = new byte[count + 1];
|
||||
try {
|
||||
if (currentProgram.getMemory().getBytes(layout.address(), bytes) != bytes.length) return null;
|
||||
} catch (Exception unreadable) {
|
||||
return null;
|
||||
}
|
||||
return bytes[count] == 0 ? swiftStringText(bytes, count, layout.ascii()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every literal record of one dossier draws on half of the heap that was free when decoding
|
||||
* began. Text is charged at the worst case per UTF-8 byte: two bytes of UTF-16 text, then up
|
||||
* to six escaped JSON characters ("\\u0000") held as UTF-16 in Gson's growing writer buffer
|
||||
* (up to twice its length), again in the returned String, and once more as UTF-8 on the
|
||||
* socket. Each record also pays a fixed cost for its map key, JSON members, addresses, words
|
||||
* and their serialized form, so many empty or small literals are bounded too. Retained
|
||||
* provenance-cache entries draw on that same fixed-state allowance.
|
||||
*/
|
||||
private static final class SwiftLiteralBudget {
|
||||
private static final long BYTES_PER_LITERAL_BYTE = 2 + 6 * 2 * 2 + 6 * 2 + 6;
|
||||
private static final long BYTES_PER_RECORD = 4096;
|
||||
private long remaining;
|
||||
private boolean stopped;
|
||||
|
||||
SwiftLiteralBudget() {
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2);
|
||||
}
|
||||
|
||||
boolean takeState() {
|
||||
if (remaining < BYTES_PER_RECORD) return false;
|
||||
remaining -= BYTES_PER_RECORD;
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean take(long bytes) {
|
||||
if (remaining < BYTES_PER_RECORD ||
|
||||
bytes > (remaining - BYTES_PER_RECORD) / BYTES_PER_LITERAL_BYTE) {
|
||||
return false;
|
||||
}
|
||||
remaining -= BYTES_PER_RECORD + bytes * BYTES_PER_LITERAL_BYTE;
|
||||
return true;
|
||||
}
|
||||
|
||||
void stop() {
|
||||
stopped = true;
|
||||
}
|
||||
|
||||
boolean stopped() {
|
||||
return stopped;
|
||||
}
|
||||
}
|
||||
|
||||
// Swift String values may contain any scalar, including controls; JSON escapes them.
|
||||
private static String swiftStringText(byte[] bytes, int length, boolean ascii) {
|
||||
if (length < 0 || length > bytes.length) return null;
|
||||
for (int index = 0; index < length; index++) {
|
||||
if (ascii && bytes[index] < 0) return null;
|
||||
}
|
||||
try {
|
||||
return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes, 0, length)).toString();
|
||||
} catch (CharacterCodingException invalid) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject swiftStringOmission(SwiftLiteralLayout layout, long word, long object) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.addProperty("representation", layout.representation());
|
||||
if (layout.address() == null) {
|
||||
item.add("address", JsonNull.INSTANCE);
|
||||
} else {
|
||||
item.addProperty("address", canonicalAddress(layout.address()));
|
||||
}
|
||||
item.addProperty("byte_length", layout.byteLength());
|
||||
item.addProperty("reason", "capacity");
|
||||
item.add("words", swiftStringWords(word, object));
|
||||
return item;
|
||||
}
|
||||
|
||||
private static JsonObject swiftStringItem(
|
||||
String value,
|
||||
SwiftLiteralLayout layout,
|
||||
long word,
|
||||
long object
|
||||
) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.addProperty("value", value);
|
||||
item.addProperty("representation", layout.representation());
|
||||
if (layout.address() == null) {
|
||||
item.add("address", JsonNull.INSTANCE);
|
||||
} else {
|
||||
item.addProperty("address", canonicalAddress(layout.address()));
|
||||
}
|
||||
item.add("words", swiftStringWords(word, object));
|
||||
return item;
|
||||
}
|
||||
|
||||
private static JsonArray swiftStringWords(long word, long object) {
|
||||
JsonArray words = new JsonArray();
|
||||
words.add("0x" + Long.toUnsignedString(word, 16));
|
||||
words.add("0x" + Long.toUnsignedString(object, 16));
|
||||
return words;
|
||||
}
|
||||
|
||||
private static String exclusiveAddress(Address inclusive) {
|
||||
Address next = inclusive.next();
|
||||
if (next != null && next.getAddressSpace().equals(inclusive.getAddressSpace())) {
|
||||
@@ -3930,6 +4308,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private record AddressNameRequest(String inputAddress, Address address, String name) {}
|
||||
private record FunctionEntry(Function function, InventoryItem item) {}
|
||||
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations) {}
|
||||
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
|
||||
private record SwiftLiteralLayout(
|
||||
String representation,
|
||||
Address address,
|
||||
long byteLength,
|
||||
boolean ascii,
|
||||
String smallValue
|
||||
) {}
|
||||
private record InstructionScan(List<Instruction> instructions, boolean truncated) {}
|
||||
private record SessionDescriptor(
|
||||
String transport,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.model.mem.MemoryBlock;
|
||||
import ghidra.program.model.mem.MemoryBlockSourceInfo;
|
||||
|
||||
/** Seed the caller-supplied raw-binary entry before default auto-analysis. */
|
||||
public final class ReaGhidraPrepareRaw extends GhidraScript {
|
||||
@@ -21,8 +23,19 @@ public final class ReaGhidraPrepareRaw extends GhidraScript {
|
||||
);
|
||||
}
|
||||
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex);
|
||||
Address start = currentProgram.getMinAddress();
|
||||
if (entry == null || !entry.equals(start)) {
|
||||
MemoryBlock block = entry == null ? null : currentProgram.getMemory().getBlock(entry);
|
||||
boolean fileStart = false;
|
||||
if (block != null) {
|
||||
// Processor-defined zero page/stack blocks can precede the imported file.
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (source.getFileBytes().isPresent() && source.getFileBytesOffset() == 0 &&
|
||||
entry.equals(source.getMinAddress())) {
|
||||
fileStart = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fileStart) {
|
||||
throw new IllegalArgumentException(
|
||||
"Raw binary import must map file offset 0 at the caller-supplied entry"
|
||||
);
|
||||
|
||||
+39
-13
@@ -86,6 +86,7 @@ rea inspect-android-method /path/Example.apk example.MainActivity onCreate
|
||||
rea inspect-android-method /path/Example.apk example.MainActivity select --overload-index 1
|
||||
rea trace-android-references /path/Example.apk example.MainActivity
|
||||
rea trace-android-references /path/Example.apk example.MainActivity --method-name onCreate
|
||||
rea trace-android-references /path/Example.apk example.MainActivity --method-name select --overload-index 1
|
||||
```
|
||||
|
||||
Equivalent MCP methods are `inspect_android_package`, `search_android_classes`,
|
||||
@@ -106,9 +107,10 @@ Every request includes `path`. Class/method operations use `class_name`,
|
||||
```
|
||||
|
||||
Use `inspect_android_class` to choose an overload. REA rejects an ambiguous
|
||||
method request rather than silently selecting the first match. The engine's
|
||||
method reference operation cannot select overloads, so REA reports that boundary
|
||||
as unsupported when multiple same-name methods exist.
|
||||
method request rather than silently selecting the first match. Method reference
|
||||
queries accept the same `overload_index`; the bridge resolves the selected
|
||||
method node and the result reports its index and displayed signature. These
|
||||
indices are tied to the current artifact and engine, not stable across builds.
|
||||
|
||||
The engine's smali fallback also joins same-name overloads; REA rejects that
|
||||
fallback for an overloaded method instead of attributing all bodies to one
|
||||
@@ -137,14 +139,28 @@ the same source byte limit and explicit truncation reporting.
|
||||
full reported display types, fields and inner class names. Synthetic members
|
||||
that code generation hides can appear in the inventory; it stays consistent
|
||||
after method decompilation within the retained session.
|
||||
Exact DEX descriptors are unknown. Overload indices are tied to this artifact
|
||||
and engine, not stable identities across different builds.
|
||||
Exact DEX prototypes are reported from frozen JADX method metadata as
|
||||
`dex_descriptor`, for example `(I)V`. Display names can be JADX aliases;
|
||||
the descriptor alone is not a complete raw DEX method ID. Overload indices
|
||||
are tied to this artifact and engine, not stable identities across builds.
|
||||
- Method inspection reports Java or smali, fallback markers and complete/partial
|
||||
returned text. Decompiled source is a derived representation. A complete text
|
||||
result does not establish complete semantics or a byte-identical reconstruction.
|
||||
- Reference results are incoming static relationships between provider nodes.
|
||||
Source lines and DEX instruction offsets remain unknown. They are not observed
|
||||
runtime calls.
|
||||
`reference_unit=using_node` means `total` counts containing JADX nodes, not
|
||||
individual invoke instructions; two calls from one method can appear as one
|
||||
entry. `coverage=complete` covers this node inventory only. For a method
|
||||
reference whose using node has raw code, `direct_invokes` lists matching DEX
|
||||
invoke instructions with their DEX entry, raw source method ID, code-unit
|
||||
offset, method index and opcode. An empty array means no direct invoke was
|
||||
matched in that node; `null` means raw code was unavailable or the using node
|
||||
was not a method. This list does not establish complete call-site coverage
|
||||
outside JADX's using-node inventory or through reflection and dynamic loading.
|
||||
A virtual or interface invoke also does not establish its runtime implementation.
|
||||
Source lines and the containing node's own `dex_offset` remain unknown.
|
||||
Method-reference results also carry the selected `dex_descriptor`;
|
||||
class-reference results report `null`.
|
||||
These relationships are not observed runtime calls.
|
||||
- Original requests, APK SHA-256, actual engine JAR SHA-256 and raw producer
|
||||
responses remain in Evidence. Source revision is populated only for bytes
|
||||
matching the audited release JAR.
|
||||
@@ -210,9 +226,11 @@ REA never writes to the original APK or launches target code.
|
||||
|
||||
On Windows, stdin writes preserve stream backpressure and run outside the Node
|
||||
event loop. Cancellation terminates the retained job, including descendants,
|
||||
before workspace cleanup. An abrupt CLI/server process termination closes the
|
||||
job and stops Java, but cannot run JavaScript workspace cleanup; temporary
|
||||
workspace files can remain after that forced exit.
|
||||
before workspace cleanup. On Windows, a detached REA helper owns the private
|
||||
workspace's native directory handle. An abrupt CLI/server owner termination
|
||||
closes the Java job and the helper's input pipe; the helper then removes the
|
||||
workspace by handle. The real Windows lane checks both effects. If the helper
|
||||
also dies or the host loses power before it finishes, private files can remain.
|
||||
|
||||
If Java cannot start the bridge, REA reports the selected executable and retained
|
||||
startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use
|
||||
@@ -275,12 +293,20 @@ The original APK and host Java configuration remain unchanged.
|
||||
|
||||
The same lane runs on Windows x64 with the bundled native controls and a full
|
||||
JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java
|
||||
exit after abrupt CLI owner termination. Windows forced termination is checked
|
||||
separately from the POSIX CLI SIGTERM handler; it does not claim a structured
|
||||
cancellation result or workspace removal after the owner is forcibly killed.
|
||||
exit and workspace removal after abrupt CLI owner termination. Windows forced
|
||||
termination is checked separately from the POSIX CLI SIGTERM handler. A
|
||||
synthetic two-owner test confirms that one owner's exit does not remove the
|
||||
other owner's workspace. The lane does not claim a structured cancellation
|
||||
result or power-loss recovery.
|
||||
|
||||
The download script verifies fixed SHA-256 values, reuses matching files and
|
||||
refuses to overwrite a different existing file. APK/JAR files live under ignored
|
||||
`_reference/` and are excluded from package contents. The lane exercises actual
|
||||
REA CLI and MCP results with the real engine, separately from synthetic protocol
|
||||
regressions. It needs no Gradle build, emulator, Android SDK or Ghidra.
|
||||
|
||||
Direct invoke sites retain exact DEX code-unit offsets separately from each
|
||||
containing node's unknown `dex_offset`. `direct_invokes_exhaustive: false`
|
||||
means instruction rows were bounded by the local bridge frame budget; `null`
|
||||
means the producer did not establish instruction coverage. Using-node totals
|
||||
and coverage do not imply complete instruction evidence.
|
||||
|
||||
@@ -98,8 +98,10 @@ section, symbol, mitigation or linkage views. The default `complete` detail and
|
||||
CLI output are unchanged.
|
||||
|
||||
Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics
|
||||
and 30-second owned command deadline. The reply budget applies to the decoder
|
||||
record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The
|
||||
and 30-second owned command deadline. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the
|
||||
input bound alone, up to 1 GiB; the reply and deadline bounds are unchanged. The
|
||||
reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds
|
||||
copies and encoding overhead. The
|
||||
pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need
|
||||
a caller-configured `StdioClientTransport({ maxBufferSize: ... })` and a matching
|
||||
server `REA_MCP_MAX_RESPONSE_BYTES`. See [MCP delivery budgets](mcp-contracts.md#tool-results).
|
||||
|
||||
@@ -80,6 +80,27 @@ google-chrome \
|
||||
|
||||
REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface.
|
||||
|
||||
Other Chromium-family browsers, such as Chromium or Brave, work the same way
|
||||
with the same two flags. On macOS, run the executable inside the application
|
||||
bundle, for example
|
||||
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"` or
|
||||
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"`. Fedora installs
|
||||
Chromium as `chromium-browser`.
|
||||
|
||||
REA needs the browser's HTTP discovery endpoint on a loopback port. A browser
|
||||
driven over `--remote-debugging-pipe` or a private channel, for example by an
|
||||
automation SDK, exposes no such endpoint, so `list_browser_targets` has nothing
|
||||
to attach to. Connection failures report `endpoint_unreachable`; a reachable
|
||||
HTTP service that returns a non-200 or non-JSON discovery response reports
|
||||
`invalid_endpoint_response`.
|
||||
Reproduce that flow in a Chromium-family browser started with
|
||||
`--remote-debugging-port`.
|
||||
|
||||
In a minimal Linux container, headed Chromium under Xvfb can keep running while
|
||||
its DevTools port never opens. Install the GLES, EGL, and Mesa DRI libraries
|
||||
(on Fedora: `libglvnd-gles`, `mesa-libEGL`, and `mesa-dri-drivers`), then
|
||||
restart the browser.
|
||||
|
||||
On Linux, a reachable but locked desktop keyring can stall Chrome before its
|
||||
first navigation. For a dedicated investigation profile, add
|
||||
`--password-store=basic` to bypass that keyring. Empty or placeholder frame URLs
|
||||
@@ -207,7 +228,7 @@ commands fail closed. A correlated reply's session ID must match the command's
|
||||
selected session. An optional method is unavailable only when the browser
|
||||
rejects it as unknown; other command rejections retain their reported failure.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete.
|
||||
|
||||
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
|
||||
|
||||
|
||||
+3
-1
@@ -174,7 +174,9 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
|
||||
rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json
|
||||
```
|
||||
|
||||
An exact CLI cache hit is read before starting a provider process.
|
||||
An exact CLI cache hit is normally read before starting a provider process.
|
||||
On Windows, Ghidra first admits the selected source through its native snapshot
|
||||
owner, so replay can start that provider.
|
||||
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
|
||||
for its matching target; an MCP provider may still start before returning a
|
||||
cached result. `close_binary` accepts `snapshot_path` and optional
|
||||
|
||||
@@ -33,6 +33,71 @@ schema constraints; REA still validates complete canonical inputs. Full-profile
|
||||
`gpt-5.4` metadata is also verified locally. Live model API acceptance and
|
||||
Windows remain unverified; see [native client verification](testing.md).
|
||||
|
||||
## Claude Desktop
|
||||
|
||||
Setup registers `mcpServers.rea` in `claude_desktop_config.json`: under
|
||||
`~/Library/Application Support/Claude` on macOS, under
|
||||
`$XDG_CONFIG_HOME/Claude` (default `~/.config/Claude`) on Linux, and on Windows
|
||||
in the file the installed app reads:
|
||||
|
||||
- Without Claude Desktop's MSIX package storage, setup uses
|
||||
`%APPDATA%\Claude\claude_desktop_config.json`.
|
||||
- The MSIX build (package family `Claude_pzs8sxrjxfjjc`, installed under
|
||||
`C:\Program Files\WindowsApps\Claude_<version>_x64__pzs8sxrjxfjjc`)
|
||||
virtualizes AppData writes. Once it has saved its configuration, it reads
|
||||
the package-private copy
|
||||
`%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json`
|
||||
and ignores the `%APPDATA%` file. Setup, doctor and uninstall use that
|
||||
private copy when it exists. Before the package has saved one, they use the
|
||||
`%APPDATA%` file it still reads; with neither file, setup creates the
|
||||
private copy.
|
||||
- If `%LOCALAPPDATA%\Packages` holds a `Claude_<publisher ID>` package from
|
||||
another publisher, or cannot be read, REA cannot establish which file Claude
|
||||
Desktop reads. Setup, doctor and uninstall report that instead of falling
|
||||
back to `%APPDATA%`. Doctor then reports `claude_desktop` as invalid, and
|
||||
uninstall stops before removing anything for any client.
|
||||
|
||||
Quit Claude Desktop fully before approving setup; closing its window is not
|
||||
enough. A running Claude Desktop can rewrite its configuration from memory and
|
||||
drop the registration. This was observed with the Windows MSIX build. Start
|
||||
Claude Desktop again after setup. If the package saves its private copy after
|
||||
setup planned to write the `%APPDATA%` file, setup makes no change and asks you
|
||||
to rerun it.
|
||||
|
||||
A process that the MSIX app started, such as a terminal in Claude Desktop's
|
||||
Code tab, sees the package's private files at their `%APPDATA%` paths even
|
||||
though it reports no package identity. Setup compares one of those files at
|
||||
both paths. When it finds that redirected view, the app is running, so setup
|
||||
and uninstall refuse to change Claude Desktop's configuration; rerun them from
|
||||
a terminal that Claude Desktop did not start. If the package has no private
|
||||
file to compare, setup and uninstall cannot rule that out and also refuse; open
|
||||
Claude Desktop once if it has never run, quit it fully, then rerun. Doctor
|
||||
reads the same file from either kind of process.
|
||||
|
||||
The same process also writes everything under `%APPDATA%` and
|
||||
`%LOCALAPPDATA%` into the package's private storage, where other apps never
|
||||
read it. From that redirected view, setup and uninstall refuse to change other
|
||||
clients' files there (VS Code, Trae, Devin and Hermes) and skill directories
|
||||
there. `npm install -g` run from such a process installs into the package's
|
||||
`LocalCache\Roaming\npm`, which other processes cannot see at the `%APPDATA%`
|
||||
path. Setup refuses to register a launcher stored in any MSIX package's
|
||||
private storage. Install REA from an ordinary terminal and rerun setup there.
|
||||
|
||||
Uninstall removes REA's entry from both the private copy and the `%APPDATA%`
|
||||
file, including registrations that earlier releases wrote to `%APPDATA%`.
|
||||
|
||||
REA recognizes only the MSIX package. A non-MSIX Claude Desktop installed
|
||||
beside it, which would read the `%APPDATA%` file, is not detected.
|
||||
|
||||
The package identity, both file locations, the redirected view, and these
|
||||
setup and doctor results were observed on Windows 11 with the MSIX build
|
||||
2.31226.1.0, using read-only `doctor` and `setup --dry-run` runs both inside
|
||||
and outside the app. On that host, setup run from an ordinary terminal with
|
||||
the app quit wrote the package-private copy; after the app restarted, the
|
||||
entry was still present, doctor reported it aligned, and Claude Desktop's MCP
|
||||
log showed REA starting and answering `tools/list`. Uninstall against the real
|
||||
app is unverified.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code normally defers MCP tool definitions through tool search. It
|
||||
@@ -165,6 +230,14 @@ For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes
|
||||
from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup
|
||||
installs its skill copy there.
|
||||
|
||||
## Factory Droid
|
||||
|
||||
For [Factory Droid](https://docs.factory.com/harness/mcp), setup writes
|
||||
`mcpServers.rea` into the user-level `~/.factory/mcp.json`. Entries carry
|
||||
`command`, `args`, and `env`. Droid discovers personal skills from
|
||||
`~/.factory/skills` ([skills](https://docs.factory.com/harness/skills)), so
|
||||
setup installs its skill copy there.
|
||||
|
||||
## Trae
|
||||
|
||||
For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes
|
||||
@@ -209,8 +282,15 @@ replaces) the REA entry in the home patch `$DSH_HOME/cordis.patch.yml`
|
||||
patches and comments. `rea doctor --client dsh` checks that same entry id.
|
||||
`rea uninstall` removes it by scanning supported clients; uninstall takes no
|
||||
`--client` flag. The home patch applies to every Harness profile, so the
|
||||
registration is written once and no per-profile edit is needed. The written
|
||||
entry is:
|
||||
registration is written once and no per-profile edit is needed.
|
||||
|
||||
Setup, doctor and uninstall resolve `DSH_HOME` and `DSH_AGENTS_HOME` as Harness
|
||||
does: unset, empty or whitespace-only values use `~/.dsh` and `~/.agents`,
|
||||
respectively. Bare `~`, `~/` and `~\` expand to the user home; other relative
|
||||
values resolve against the command's working directory. Nonblank values retain
|
||||
their original spaces, and the agents root is independent of the Harness home.
|
||||
Run REA and Harness from the same working directory when using relative overrides.
|
||||
The written entry is:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
|
||||
@@ -18,14 +18,26 @@ pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full
|
||||
result that its client offloads, and Pi exercises default codemode execution.
|
||||
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
|
||||
Receipts retain client versions, result digests, host coverage and owned-process
|
||||
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
|
||||
prove live model-provider or native Windows compatibility. Qwen, Pi and Hermes
|
||||
cleanup. These lanes use a deterministic loopback model and do not prove live
|
||||
model-provider compatibility. Pi and Hermes remain POSIX-only. Qwen, Pi and Hermes
|
||||
bind both `HOME` and `USERPROFILE` to the disposable account. Qwen and Pi discover
|
||||
the shared personal skill through their default search paths without an explicit
|
||||
skill setting; Hermes discovers it through the selected `HERMES_HOME`.
|
||||
Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of
|
||||
a named sticky profile with `HERMES_HOME` still pointing to its root.
|
||||
|
||||
The Qwen lane also supports native Windows (verified with Qwen Code 0.25.1 and
|
||||
Node 24.19.0 for both `chat` and `call`). With Node 24.18.0, the same `call`
|
||||
journey reached full Evidence readback but the client aborted during exit with
|
||||
`UV_HANDLE_CLOSING`; that run remains a failure, not a compatibility claim.
|
||||
Select the npm install's
|
||||
`node_modules/@qwen-code/qwen-code/cli-entry.js` with `REA_VERIFY_QWEN_COMMAND`,
|
||||
because `execFile` cannot launch the `qwen.cmd` shim directly. A selected `.js`,
|
||||
`.mjs` or `.cjs` entry point runs under the verifier's Node. Windows path checks
|
||||
use native separators; persisted output is read through Qwen's real shell tool
|
||||
with a shell-independent encoded Node payload. Windows receipts report process
|
||||
lineage as unavailable when the verifier has no retained native Job Object lease.
|
||||
|
||||
`verify:gemini-client` requires an installed Gemini CLI (verified with
|
||||
`@google/gemini-cli@0.63.0` on POSIX and native Windows); select it with
|
||||
`REA_VERIFY_GEMINI_COMMAND`. A selected `.js`, `.mjs` or `.cjs` entry point runs
|
||||
|
||||
@@ -39,6 +39,18 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md)
|
||||
- Upstream also returns terminal metadata during selector recovery. Its reported
|
||||
values remain in `raw_result`; integer precision and non-text-key coverage are
|
||||
unknown in the JS binding. Full bytecode retains the original encoded metadata.
|
||||
- `bytecode.metadata` independently interprets a plausible terminal CBOR root
|
||||
map from the selected bytes. Integers use decimal strings; byte strings use hex,
|
||||
and scalar keys retain their types. The recorded offset/length addresses the
|
||||
original bytecode, so summary delivery does not copy an opaque CBOR payload.
|
||||
Nested containers have partial scalar coverage; unsupported or malformed
|
||||
candidates report `not-decoded` with a diagnostic. Decoding is bounded to 64
|
||||
container levels and the terminal payload's encoded size.
|
||||
- An unavailable trailer does not prove metadata absence or Solidity origin.
|
||||
Compiler-version bytes/text are metadata declarations, not verified compiler,
|
||||
source-file or deployed-contract identity. Historical omission parses as a null
|
||||
unknown facet without changing the original Evidence record. Metadata/source
|
||||
URLs are not fetched.
|
||||
- Selectors, argument strings and state mutability are **inferences**. Function
|
||||
names/signatures are not looked up. Optimized code may produce different
|
||||
mutability candidates; recovered types are not a verified source ABI.
|
||||
@@ -125,3 +137,15 @@ The real verification lane traces representative raw/hex CLI workflows and check
|
||||
that only declared Node/prlimit launchers and process ownership inspection run.
|
||||
Socket traces permit local stdio socket pairs and reject attempted network requests.
|
||||
These observations verify those fixtures; they are not a general network sandbox.
|
||||
|
||||
For the independent metadata parser, `npm run verify:solidity:metadata` uses an
|
||||
existing caller-selected solc 0.8.30 module:
|
||||
|
||||
```sh
|
||||
REA_VERIFY_SOLC_MODULE=/absolute/path/to/solc/index.js npm run verify:solidity:metadata
|
||||
```
|
||||
|
||||
It compiles the owned fixture with unoptimized, viaIR, content-hash-free and
|
||||
CBOR-free settings, verifies the observed trailer facts and reports local decode
|
||||
timings. No contract is executed. This lane does not establish the complete
|
||||
Linux EVM provider's CLI/MCP, resource-limit or cleanup behavior.
|
||||
|
||||
+31
-7
@@ -22,6 +22,28 @@ lane. MIPS16 shared images have the separate bounded profile below; other shared
|
||||
or compressed images remain unsupported. Their family may be identified, but provider selection returns the
|
||||
specific unsupported constraint rather than guessing a compatible profile.
|
||||
|
||||
A second admission covers recordless legacy firmware images
|
||||
([#1620](https://github.com/morluto/rea/issues/1620)): ELF32 `ET_EXEC` headers
|
||||
declaring `EF_MIPS_ARCH_32` or `EF_MIPS_ARCH_32R2` with an unspecified ABI
|
||||
field, `EF_MIPS_ABI2` clear, and an _inspected_ absence of any ABI flags
|
||||
record (a target whose record was merely not observed stays refused). For
|
||||
these images o32 is an analysis assumption derived from the ELF32/ET_EXEC
|
||||
structure, recorded as `mips_abi_basis` in the profile and stated as a
|
||||
limitation in Evidence, never a declared fact; floating-point ABI semantics
|
||||
stay unverified. Ghidra's own header auto-selection picks a 64-bit language
|
||||
for exactly these files, so the lane pins `-processor MIPS:<LE|BE>:32:default`
|
||||
through a `provider-pinned-v1` profile selection and checks the
|
||||
bridge-reported `language_id` after import. A caller-selected
|
||||
`REA_GHIDRA_LANGUAGE_ID`/`REA_GHIDRA_COMPILER_SPEC_ID` still wins over the
|
||||
pin; combining the pinned profile with an environment override is a typed
|
||||
refusal. A header that declares an ABI field but carries no record is still
|
||||
refused. Two further boundaries apply: an inspection that could not examine
|
||||
every ABI declaration source (for example a carved ELF whose section table
|
||||
extends past the captured bytes without a program-header record) does not
|
||||
establish the recordless admission, and an `EF_MIPS_ARCH_32` header that
|
||||
_declares_ an ABI is refused rather than relabeled, because the declared-ABI
|
||||
lane is verified for `EF_MIPS_ARCH_32R2` headers only.
|
||||
|
||||
Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their
|
||||
absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and
|
||||
soft-float targets. Target resolution also reads the 24-byte ABI flags record
|
||||
@@ -40,12 +62,14 @@ absent or supply a default ABI.
|
||||
The field layout follows
|
||||
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
|
||||
|
||||
Provider admission requires record version 0, MIPS32r2, 32-bit GPRs, no CPR2,
|
||||
no ISA extension, ASE or unknown general flags, and one of two floating-point
|
||||
declarations: 32-bit CPR1 with a double-precision or FPXX ABI, or no CPR1 with
|
||||
the soft-float ABI. Other combinations, including single-float, FP64 and
|
||||
unspecified ABIs, are refused. Missing records remain unknown rather than
|
||||
defaulting to a compatible ABI, and this lane refuses them. Unknown record
|
||||
Provider admission on the declared-ABI lane above requires record version 0,
|
||||
MIPS32r2, 32-bit GPRs, no CPR2, no ISA extension, ASE or unknown general
|
||||
flags, and one of two floating-point declarations: 32-bit CPR1 with a
|
||||
double-precision or FPXX ABI, or no CPR1 with the soft-float ABI. Other
|
||||
combinations, including single-float, FP64 and unspecified ABIs, are refused.
|
||||
Missing records remain unknown rather than defaulting to a compatible ABI, and
|
||||
the declared-ABI lane refuses them; the recordless legacy admission above is
|
||||
the only path that admits a recordless image. Unknown record
|
||||
values are retained for diagnosis but do not gain provider support. These checks
|
||||
bind the declared interpretation; the fixtures do not establish full
|
||||
floating-point behavioral conformance.
|
||||
@@ -111,7 +135,7 @@ MIPS header and ABI record facts are committed in the analysis profile so
|
||||
interpretation changes invalidate profile-bound snapshots. The support-lane
|
||||
identity is versioned independently of file identity. Evidence, lifecycle
|
||||
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
|
||||
do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets.
|
||||
|
||||
### Soft-float prototype
|
||||
|
||||
|
||||
@@ -131,9 +131,10 @@ overview merely because its source artifact is unchanged.
|
||||
|
||||
Function extents and instruction completeness refer to Ghidra's analyzed body.
|
||||
An inferred no-return call can terminate that body before subsequent bytes.
|
||||
Pseudocode, assembly, procedure info, instruction windows, and dossiers report
|
||||
observed terminal call sites in Evidence limitations, including resolved callee
|
||||
names, addresses, and FunctionManager flags when available. Instruction windows
|
||||
Pseudocode, assembly, procedure info, instruction windows, callee and caller
|
||||
reads, and dossiers report observed terminal call sites in Evidence limitations,
|
||||
including resolved callee names, addresses, and FunctionManager flags when
|
||||
available. Instruction windows
|
||||
and dossiers also retain these warnings in their result limitations. An
|
||||
unresolved callee or unestablished terminal-flow basis remains explicit. These
|
||||
observations qualify the analysis; they do not prove that a no-return flag is
|
||||
|
||||
@@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The
|
||||
target also retains the raw ELF declarations that decide a lane: class, byte
|
||||
order, `e_type`, and `e_flags`. Recognition does not mean every provider or
|
||||
RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets
|
||||
are unchanged, and caller-supplied headerless raw-binary entry still accepts only
|
||||
its existing four families.
|
||||
are unchanged. Headerless inputs use the separate [raw-binary admission
|
||||
boundary](raw-binary.md); raw RISC-V images are not supported.
|
||||
|
||||
The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that
|
||||
declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`,
|
||||
|
||||
@@ -4,6 +4,15 @@ REA adapts an existing IDA MCP server into its native analysis tools. Existing I
|
||||
|
||||
Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP_CONFIG` to that file, then select `ida` once through `REA_ANALYSIS_PROVIDER`, the CLI's `--provider ida`, or MCP's `open_binary.provider_id`. No provider startup occurs during discovery.
|
||||
|
||||
## Supported upstream versions
|
||||
|
||||
Each mode accepts one upstream tool profile, checked against `tools/list` before any analysis runs. Upstream's `main` branch has since moved to 2.0.0, which renamed the GUI tools and dropped `idb_close`, so it serves neither profile. Installing from that branch fails at startup with the missing tool names in the error diagnostics.
|
||||
|
||||
| Mode | Upstream profile | Known working |
|
||||
| -------- | ------------------- | ------------------------------------------------------ |
|
||||
| attached | legacy GUI tools | 1.4.0 |
|
||||
| headless | database supervisor | builds serving `idb_open`, `idb_list`, and `idb_close` |
|
||||
|
||||
## Analyze an already-open GUI target
|
||||
|
||||
The attached profile supports upstream **1.4.0 legacy tools**, including `get_metadata`, `list_functions`, and `decompile_function`. Copy the upstream registration's `command`, `args`, and optional `env` into a JSON file. An existing `mcpServers` object with an `ida-pro-mcp` entry is also accepted. For example:
|
||||
@@ -88,6 +97,8 @@ REA copies the admitted binary into a private temporary directory, checks its di
|
||||
|
||||
One REA session owns one database. Stdio registrations default `IDA_MCP_MAX_WORKERS` to `1`; the explicit upstream `--max-workers 1` also bounds a supervisor. Database size and engine memory depend on the target and upstream; REA does not impose an engine memory limit. The original binary is never executed or overwritten. Optional `workspaceRoot` selects an existing absolute temporary-directory parent on the REA host. Ensure it has space for the input copy and IDA working files.
|
||||
|
||||
When a completed `idb_open` response reports success or is otherwise ambiguous, but no active owned private worker can be verified, REA retains the private workspace even if `idb_list` appears empty. A later close can retry verification if the original owned worker becomes discoverable. A completed explicit `idb_open` rejection retains the existing no-worker cleanup path. This is a local admission/ownership safeguard and **does not** provide a process-termination receipt or change the legacy reaped-worker protocol.
|
||||
|
||||
Headless cleanup uses the upstream database lifecycle. Abruptly terminating REA, its proxy, or the supervisor does not establish worker release. Prefer `close_binary` for MCP sessions; after an interrupted process, inspect upstream session inventory and retained workspace diagnostics before removing working files. This adapter does not claim Windows Job Object containment for the upstream detached workers.
|
||||
|
||||
## Connect an agent once
|
||||
@@ -126,6 +137,8 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <cl
|
||||
|
||||
All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
|
||||
|
||||
The modern headless profile also supports `resolve_containing_procedure` and `list_documents`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `list_documents` returns the single admitted program path, so `inspect_address_context` and `trace_feature` need no explicit document. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. The legacy and native profiles do not declare these operations.
|
||||
|
||||
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern and native profiles do not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
|
||||
|
||||
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. `binary_overview`, whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and requested GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
|
||||
|
||||
@@ -20,7 +20,9 @@ npx rea-agents setup
|
||||
Choose the agents to configure, review the proposed paths and changes, and
|
||||
approve the plan. npm's package-download approval and REA's setup approval are
|
||||
separate. Setup asks before changing agent configuration or installing Hopper.
|
||||
Restart the agents named in the completion message.
|
||||
Restart the agents named in the completion message. Quit Claude Desktop fully
|
||||
before approving, because a running app can overwrite the registration; on
|
||||
Windows, see [where Claude Desktop reads its configuration](client-configuration.md#claude-desktop).
|
||||
|
||||
For the latest published release, use `npx rea-agents@latest setup`.
|
||||
[Package-runner and global-installation options](#package-runners-and-global-installation)
|
||||
@@ -283,6 +285,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
|
||||
| Pi | `pi` |
|
||||
| Hermes | `hermes` |
|
||||
| Qoder | `qoder` |
|
||||
| Factory Droid | `droid` |
|
||||
| Trae | `trae` |
|
||||
| ZCode | `zcode` |
|
||||
| DeepSeek Harness | `dsh` |
|
||||
@@ -512,7 +515,10 @@ and `set_addresses_names` name a function entry or any other mapped address
|
||||
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
|
||||
require Hopper; Windows P0 remains read-only.
|
||||
|
||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
|
||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs, plus explicitly
|
||||
selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied
|
||||
entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain
|
||||
unsupported on Windows.
|
||||
The npm package bundles native Job Object ownership, protected private runtime
|
||||
DACLs, and handle-based path admission; no separate addon installation is needed.
|
||||
See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and
|
||||
|
||||
@@ -208,6 +208,25 @@ arrays, and later explicit data properties and methods replace earlier object
|
||||
spread references. A known array prefix does not expose values from a following
|
||||
spread. Accessors, custom or uncertain iteration, unknown computed keys, and
|
||||
unknown spread lengths retain conservative uncertainty.
|
||||
Getter-local aliases of `this`, including direct destructuring and assignments,
|
||||
retain their receiver when a returned shared reference is written. These writes
|
||||
invalidate the earlier literal instead of claiming the getter executed or the
|
||||
new value is known. Straight-line alias reads retain their capture order;
|
||||
uncertain control flow remains conservative. A binding outside the getter or
|
||||
`this` in a nested ordinary function is not attributed to the getter's receiver.
|
||||
Private reads keep receiver projections separate from the private value's path.
|
||||
Receiver projections follow known data fields initialized from `this` or earlier
|
||||
self-referencing fields; static fields retain their declaring class identity.
|
||||
An alias cycle that repeatedly selects deeper children retains escape uncertainty
|
||||
at its recovered roots rather than claiming a fixed number of loop iterations.
|
||||
|
||||
Writes through an object getter's returned receiver retain that uncertainty
|
||||
when the object is an array element, nested inside mixed object/array
|
||||
containers, or assigned to a previously declared identifier. Saving the getter
|
||||
result in an alias does not make its old field values immutable. Known array
|
||||
indices include holes; an element after a spread has only a minimum possible
|
||||
index. Unrelated properties and known array prefixes remain precise, and a
|
||||
getter read without a write does not invalidate the receiver's fields.
|
||||
Nested callable returns are not assigned to their parent callable. Projected
|
||||
graph observations carry source ranges but never source text.
|
||||
|
||||
|
||||
@@ -147,6 +147,28 @@ opposite observation's selection unknown, and application graphs report their
|
||||
selected scope as partial. Neither makes an absence claim about the whole
|
||||
assembly. Older member Evidence without `selection` remains readable.
|
||||
|
||||
### Edge target declaring types
|
||||
|
||||
`member_refs[].parent_name`, `call_edges[].target_declaring_type`, and
|
||||
`field_accesses[].field_declaring_type` name the type that declares each
|
||||
target. Referenced spec signatures validate encoded type coordinates against
|
||||
the metadata tables, including nested type arguments and custom modifiers.
|
||||
TypeRef resolution scopes are checked transitively with cycle detection;
|
||||
readable names survive malformed scopes as partial evidence.
|
||||
TypeDef and TypeRef names use the namespace-qualified `full_name`
|
||||
form, and nested types keep bare names. A generic-instantiation TypeSpec
|
||||
parent names its generic type, such as ``System.Collections.Generic.List`1``.
|
||||
Other well-formed TypeSpec forms and ModuleRef or MethodDef parents stay
|
||||
`null`. A `method-spec` call edge reports the name and declaring type of the
|
||||
generic method it instantiates. A field access is named only by a field or a
|
||||
field-signature MemberRef. TypeSpec and MethodSpec blobs are decoded with the
|
||||
member-signature grammar, and a MethodSpec must instantiate a generic method
|
||||
with as many type arguments as it declares. Malformed or unsupported rows and
|
||||
blobs, and arity mismatches, make coverage partial. A readable MethodSpec
|
||||
method column that names a method, or a TypeRef whose resolution scope is
|
||||
invalid, keeps its name.
|
||||
Older member Evidence without these fields reads them as `null`: unknown.
|
||||
|
||||
## Analysis objective
|
||||
|
||||
Start by identifying the artifact with `inspect_managed_artifact`, then inspect
|
||||
|
||||
@@ -476,8 +476,11 @@ the tool fails with a `debugger-attach-denied` reason.
|
||||
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
|
||||
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
|
||||
the matching native decompiler. Experimental Windows x64 P0 admits native
|
||||
x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership,
|
||||
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
|
||||
x86 and x86-64 PE applications and DLLs, plus explicitly selected
|
||||
[raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address,
|
||||
on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows.
|
||||
The boundary uses bundled Job Object ownership, protected runtime DACLs, and
|
||||
handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
|
||||
[issue #527](https://github.com/morluto/rea/issues/527).
|
||||
On Linux and macOS, `annotate_native_function` atomically edits a function name
|
||||
and entry comments in the ephemeral database, returning refreshed analysis
|
||||
|
||||
+53
-10
@@ -25,10 +25,12 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina
|
||||
}
|
||||
```
|
||||
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a
|
||||
hexadecimal address of the form `0x…`. The complete file is mapped starting at
|
||||
that address, and Ghidra seeds an `entry` function there before auto-analysis.
|
||||
ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned.
|
||||
ARM and AArch64 entries must be 4-byte aligned; x86 and 6502 entries may be byte-aligned.
|
||||
For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`;
|
||||
images that cross the 16-bit boundary are rejected before provider startup.
|
||||
`open_binary` and an open `binary_session` return the canonical `entry_point`
|
||||
for raw targets so callers can verify the selected mapping directly.
|
||||
Omitted `format` keeps header-based detection; architecture and entry without
|
||||
@@ -37,12 +39,13 @@ Omitted `format` keeps header-based detection; architecture and entry without
|
||||
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
|
||||
generic languages and compiler specifications:
|
||||
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | ------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | -------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
| 6502 | `6502:LE:16:default` | `default` |
|
||||
|
||||
Compiler specifications select analysis conventions; they do not identify the
|
||||
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
|
||||
@@ -57,11 +60,51 @@ The mapped start is independent of Ghidra's program image base, which remains
|
||||
`0x0` for BinaryLoader. Ghidra may report additional language-defined entry
|
||||
points, including unmapped ARM vectors. Load-image verification requires the
|
||||
caller-supplied entry and preserves those other entries as observations.
|
||||
For 6502, BinaryLoader maps the file before adding its language-defined zero-page
|
||||
and stack blocks. Conflicting default blocks are skipped; low-address files stay
|
||||
in the default address space rather than an overlay. Non-conflicting uninitialized
|
||||
blocks remain observations, not file-backed bytes.
|
||||
Independent raw verification streams the complete private snapshot digest and
|
||||
extent with bounded memory and supports cancellation during the read.
|
||||
It also requires the source-backed block to be initialized, loaded, and
|
||||
non-overlay in the bound language's default address space. Contradictory mapping
|
||||
observations are retained with `mismatch`, even when the source hashes agree.
|
||||
|
||||
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
|
||||
native PE applications and DLLs only; raw-binary targets are unsupported.
|
||||
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 admits native PE
|
||||
applications/DLLs and explicitly selected raw 6502 images. Other raw CPU families
|
||||
remain outside the Windows boundary. Native path admission, private DACLs, Job
|
||||
Object ownership, immutable snapshots, and the no-mutation policy still apply.
|
||||
|
||||
## NMOS 6502
|
||||
|
||||
```bash
|
||||
rea function /absolute/path/to/image.bin 0x8001 --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
|
||||
rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
|
||||
```
|
||||
|
||||
Select only headerless NMOS 6502 code with a known entry at file offset 0.
|
||||
The built-in Ghidra processor supplies static instructions, functions,
|
||||
pseudocode, and references. Evidence preserves `architecture: "6502"`, the
|
||||
selected language, entry, file digest, and verified source mapping. Register and
|
||||
calling conventions are analysis conventions, not a recovered source ABI.
|
||||
|
||||
This lane does not interpret iNES headers, cartridge mappers, bank switching,
|
||||
or other console-specific memory layouts. 65C02 and 65816 programs need different
|
||||
processor semantics and are not admitted as those variants. REA does not infer
|
||||
the CPU from the file suffix, guess the reset vector, or emulate the target.
|
||||
|
||||
`npm run verify:ghidra:6502` assembles and links the source-owned fixture with
|
||||
`ca65` and `ld65` (tested from cc65 tag `V2.19`, whose tools report `V2.18`).
|
||||
Set `REA_CA65` and `REA_LD65` to explicit executable paths if needed. The lane
|
||||
checks real CLI/MCP load-image parity, mapped bytes, decoded calls, decompilation,
|
||||
invalid mappings, unchanged source bytes and runtime cleanup.
|
||||
Low-memory checks include a file spanning the zero-page/stack boundary at `0xff`
|
||||
and a complete 64-KiB image at `0x0`, with verified default-space mappings through
|
||||
both CLI and MCP. On Windows, the lane also checks that a caller-selected
|
||||
junction path is rejected at snapshot admission through CLI and MCP, with
|
||||
unchanged source bytes and complete runtime cleanup. Windows requires the
|
||||
matching native addon; the POSIX provider path has not been exercised by the
|
||||
Windows run.
|
||||
|
||||
## Limitations
|
||||
|
||||
|
||||
@@ -72,9 +72,13 @@ debugger context is `observed`.
|
||||
|
||||
Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per
|
||||
stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling
|
||||
and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained;
|
||||
hard limits are not raised. Complete oversized results fail explicitly. These
|
||||
process limits do not constitute a sandbox for caller-supplied engine code.
|
||||
and 30 seconds CPU soft ceiling. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the input
|
||||
bound alone, up to 1 GiB, and returns `provider_unavailable` when it is not a
|
||||
decimal integer in range; the reply, diagnostic and deadline bounds stay fixed, so a
|
||||
large core that outruns them still fails explicitly instead of partially. Tighter
|
||||
inherited soft/hard limits are retained; hard limits are not raised. Complete
|
||||
oversized results fail explicitly. These process limits do not constitute a sandbox
|
||||
for caller-supplied engine code.
|
||||
|
||||
Resource exit statuses need matching private failure-branch markers. Unmarked
|
||||
launcher failures retain their actual status/output and unknown resource cause.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Implementation plan
|
||||
|
||||
1. Use cborg 6.1.3 (Apache-2.0) as a pinned format decoder, with a bounded
|
||||
tokenizer at its owning adapter boundary. Decode maps as Map, preserve integer
|
||||
precision and strictly validate emitted text bytes before conversion.
|
||||
2. Add the portable metadata-result schema in src/domain/evm/. Use exact byte
|
||||
ranges into the already retained bytecode rather than copying an opaque CBOR
|
||||
hex payload into every selected summary.
|
||||
3. Add src/evm/SolidityMetadata.ts to interpret the terminal length/root-map
|
||||
candidate. Return unavailable, decoded, partial or not-decoded facts; keep
|
||||
unsupported nested values explicit instead of recursively expanding them.
|
||||
4. Compute the facts in EvmoleInterfaceWorker from its selected bytes. The host
|
||||
provider validates ranges/identity and projects the additive metadata field.
|
||||
Preserve raw engine output without rewriting its unknown-precision metadata.
|
||||
5. Ensure the canonical EVM bytecode shape carries the optional field into complete
|
||||
and summary Evidence. Include decoding profile, coverage and limitations.
|
||||
6. Add producer, worker, historical contract and public MCP boundary coverage.
|
||||
Compile an owned Solidity fixture with a pinned caller-supplied solc module,
|
||||
and verify its CBOR compiler version. Run relevant static/architecture checks.
|
||||
7. Extend the existing real Linux CLI/MCP verifier with independent metadata
|
||||
assertions. Run the compiler-setting matrix in its CI lane and classify its
|
||||
verifier as EVM-owned, with a production CI-planner regression.
|
||||
|
||||
No new execution provider or target authority is introduced: a format parser is
|
||||
added inside the existing EVM worker. Linux x64 provider admission, target-free
|
||||
semantics, cleanup, limits and complete original Evidence remain unchanged.
|
||||
No metadata JSON or source URL is automatically acquired.
|
||||
|
||||
The parser bounds nesting to 64 and token/collection growth to the selected
|
||||
terminal payload's byte count (the format's terminal length is at most 65535).
|
||||
Every data item consumes encoded bytes; broad declared collection lengths cannot
|
||||
justify allocation without that lower bound. Expanded results still pass the
|
||||
worker's existing 16 MiB output check.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Solidity terminal metadata inspection
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/2388
|
||||
|
||||
## Outcome
|
||||
|
||||
The existing EVM interface operation should retain independently decoded terminal
|
||||
CBOR candidate facts from the exact selected bytes instead of relying on precision
|
||||
unknowns in the engine's reported metadata. This increment covers trailer ranges,
|
||||
bounded decoding, exact integer/non-text scalar key evidence and compiler-version
|
||||
representations.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Preserve the existing carrier/decoded identities and original full bytecode.
|
||||
- Report a plausible terminal root-map candidate separately from proof of Solidity
|
||||
origin. Missing or non-map trailers remain unavailable, not proven absent.
|
||||
- Interpret scalar root-map keys/values losslessly: integers as decimal strings,
|
||||
bytes as hex, strings, booleans and null. Preserve undecoded/unsupported values
|
||||
through the original bytecode plus exact candidate offset/length.
|
||||
- Report partial and not-decoded coverage, parser/profile failures and unsupported
|
||||
CBOR forms explicitly. Do not silently coerce unknown keys into object properties.
|
||||
- Interpret admitted solc release/prerelease representations only from observed
|
||||
entries; preserve ambiguity if duplicate compiler-version keys occur.
|
||||
- Keep the additive field compatible with historical Evidence and retain
|
||||
it through selected summary delivery and public CLI/MCP.
|
||||
Historical omission parses as a null unknown facet without mutating the source.
|
||||
- Bound nesting before decoder recursion and collection lengths before allocation.
|
||||
Keep parsing inside the existing owned worker and its resource/output budgets.
|
||||
- Do not fetch metadata or source content, contact RPC or execute contract code.
|
||||
|
||||
## Acceptance and limits
|
||||
|
||||
Owned compiler fixtures and malformed/synthetic trailers cover integers beyond
|
||||
Number precision, non-text keys, nested/uninterpreted values, invalid UTF-8,
|
||||
invalid lengths, unsupported tags and nesting bounds. Public boundary tests
|
||||
preserve the field and older omission. A real pinned solc artifact verifies actual
|
||||
metadata facts; the existing Linux-only EVM host profile remains unchanged.
|
||||
|
||||
## Deferred issue criteria
|
||||
|
||||
Binding a supplied metadata JSON to IPFS/Swarm commitments is a later independent
|
||||
increment of #2388. This PR must reference rather than close the whole issue.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Define single-feature requirements and implementation plan before code.
|
||||
- [x] Add the pinned decoder and bounded owner boundary.
|
||||
- [x] Implement lossless scalar metadata facts and compiler-version evidence.
|
||||
- [x] Integrate worker/provider contracts and summary/historical compatibility.
|
||||
- [x] Verify producer, worker and public boundary behavior with owned fixtures.
|
||||
- [x] Run relevant repository checks and prepare a separately scoped PR.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Verification
|
||||
|
||||
After integrating upstream c462a4ebd, 47 tests passed
|
||||
across the parser, domain contract, application service, compiled worker and MCP
|
||||
boundary (five files). Compiled-worker goldens include BOM text, partial nested
|
||||
values, unavailable trailers, malformed UTF-8 and unsupported floats. Five
|
||||
upstream adapter tests also passed after its stricter integer validation landed.
|
||||
All six static tasks and architecture guards
|
||||
passed. After integration, test discovery found all 1004 files exactly once.
|
||||
The CI-planner selection passed all 85 tests, including the new EVM-verifier case.
|
||||
|
||||
The parser regressions cover exact large integers and typed keys, malformed UTF-8,
|
||||
duplicate keys, excessive nesting, unsupported tags/floats, length bounds and
|
||||
historical omission. A real defect in the decoder's default text handling removed
|
||||
an initial U+FEFF. The owning tokenizer now validates and decodes the retained
|
||||
text bytes with BOM preservation; independent fixtures check both values and
|
||||
compiler-key identity.
|
||||
|
||||
The owned fixture was compiled with caller-selected solc 0.8.30, using unoptimized,
|
||||
optimized via-IR, content-hash-free and CBOR-free settings. All four metadata
|
||||
checks passed again after integration. No contract was executed. Local first decode
|
||||
took approximately 0.81 ms or less. The originally reported approximately 2.92 ms
|
||||
for 100 warm iterations included equality assertions and is not a decode-only
|
||||
measurement. The verifier now measures parsing separately from those assertions;
|
||||
updated warm timings have not been collected. These historical figures describe
|
||||
these fixtures and this host, not a general workload.
|
||||
Peak process RSS
|
||||
included the compiler (299536 KiB); complete process-family CPU/memory and agent
|
||||
token costs were not measured.
|
||||
|
||||
Compiled worker tests exercise unchanged EVMole WASM on this macOS host. MCP tests
|
||||
use the production server and contracts with an injected provider seam. The full
|
||||
Linux-only provider CLI/MCP, resource-limit and cleanup lane remains unverified
|
||||
here. Metadata JSON commitment binding remains deferred under issue #2388.
|
||||
|
||||
The Linux CLI/MCP verifier asserts exact compiler bytes, declared version and
|
||||
candidate ranges for its existing owned raw/hex artifacts. Its CI lane additionally
|
||||
runs the new compiler-setting matrix. A regression using the actual CI planner
|
||||
checks that edits to the new verifier select the EVM lane.
|
||||
|
||||
The later merge/review correction adds decoded-summary and omitted historical
|
||||
metadata coverage. Tests and provider reruns for that correction were skipped at
|
||||
the maintainer's request; the earlier runs above do not validate the changed head.
|
||||
+13
-1
@@ -682,7 +682,9 @@ use `--second-procedure rea_fixture_subtract --second-marker='- 13'`.
|
||||
The lane invokes the production CLI dispatcher and connects the pinned MCP
|
||||
client SDK to the production REA server. It verifies function Evidence and
|
||||
CLI/MCP parity, inventory/search, pseudocode, instructions, xrefs, malformed
|
||||
input, original-input preservation, and lifecycle cleanup. For headless
|
||||
input, original-input preservation, and lifecycle cleanup. The headless
|
||||
profile also checks containing procedures, the admitted program as the
|
||||
document, address context, and feature tracing. For headless
|
||||
analysis it confirms that the owned database IDs disappear from upstream
|
||||
discovery and private workspaces are removed. For attached analysis it confirms
|
||||
the existing GUI target remains reachable with the same input identity.
|
||||
@@ -732,6 +734,16 @@ truncation constraint, while leading-zero coordinates still resolve correctly.
|
||||
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
|
||||
runtime or PC-98 device execution.
|
||||
|
||||
`npm run verify:ghidra:6502` uses `ca65` and `ld65` to build the source-owned
|
||||
NMOS 6502 fixture from `tests/conformance/ghidra/6502.s` and `6502.cfg`. Set
|
||||
`REA_CA65` and `REA_LD65` for non-PATH installations (tested with cc65 `V2.19`).
|
||||
With an installed Ghidra 12.1.4 and full JDK, it verifies real CLI/stdio MCP
|
||||
mapping parity, exact source bytes, functions, a direct call reference,
|
||||
decompilation, 16-bit overflow rejection without replacing the active session,
|
||||
and runtime cleanup. Windows additionally requires the matching native addon;
|
||||
its verifier lineage remains explicitly unavailable outside the provider's
|
||||
retained Job Object, as in the other Windows lanes.
|
||||
|
||||
`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image
|
||||
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
|
||||
prerequisites, it checks preparation and bridge startup, verified load mappings,
|
||||
|
||||
@@ -17,14 +17,16 @@ additional permission flag or degraded mode is required.
|
||||
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
|
||||
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
|
||||
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
|
||||
- an explicit native, non-managed x86 or x86-64 PE application or DLL; and
|
||||
- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a
|
||||
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
|
||||
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
||||
|
||||
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
||||
PE load-image inspection returns an explicit unsupported attestation status.
|
||||
Independent verification covers admitted DOS MZ, explicit COM, and explicit
|
||||
[raw-binary imports](raw-binary.md) on their supported hosts. This does not
|
||||
extend Windows P0 admission beyond native PE. For x64 PE
|
||||
[raw-binary imports](raw-binary.md) on their supported hosts, including the
|
||||
admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
|
||||
to native PE and raw NMOS 6502 images. For x64 PE
|
||||
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||
@@ -35,6 +37,12 @@ state, annotations, other target architectures and formats, and Hopper-only
|
||||
operations remain unavailable through this provider. Managed PE/CLI inspection
|
||||
uses its separate execution-free provider.
|
||||
|
||||
Raw 6502 imports retain the same native filesystem and process ownership
|
||||
requirements. The complete image must fit in the 16-bit address space; no
|
||||
console-specific cartridge loader, CPU variant, or bank-switching model is
|
||||
implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65`
|
||||
to check the source-built fixture through real CLI and MCP workflows.
|
||||
|
||||
REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a
|
||||
compiler. The adapter uses the packaged Java `HeadlessScript`; Python and
|
||||
PyGhidra are not prerequisites. Users do not build the native addon.
|
||||
|
||||
@@ -94,6 +94,9 @@ def main():
|
||||
if undeclared:
|
||||
raise ValueError('Declare inherited provider settings explicitly in --providers: '
|
||||
+ ', '.join(undeclared) + '. Values are not logged.')
|
||||
source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ
|
||||
else Path.home() / '.codex')
|
||||
source_auth = source_auth_home / 'auth.json'
|
||||
args.output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||||
runtime_files, runtime_digest = runtime_fingerprint(repository)
|
||||
(args.output / 'runtime-files.json').write_text(json.dumps(runtime_files))
|
||||
@@ -103,7 +106,6 @@ def main():
|
||||
workspace = profile / 'workspace'
|
||||
for directory in [trial_codex_home, account, workspace]:
|
||||
directory.mkdir(mode=0o700)
|
||||
source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json'
|
||||
auth = trial_codex_home / 'auth.json'
|
||||
started = time.monotonic()
|
||||
timeout = False
|
||||
|
||||
@@ -71,7 +71,9 @@ class ContextCaptureTests(unittest.TestCase):
|
||||
self.assertNotIn(marker, text)
|
||||
self.assertIn('caller-selected evidence and paths', text)
|
||||
self.assertIn('original-response', text)
|
||||
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
|
||||
# Windows mode bits do not describe access control; this is a POSIX assertion.
|
||||
if os.name == 'posix':
|
||||
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -124,6 +126,7 @@ class ContextCaptureTests(unittest.TestCase):
|
||||
patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \
|
||||
patch.object(agent.subprocess, 'Popen', return_value=child), \
|
||||
patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \
|
||||
patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \
|
||||
patch('builtins.print'):
|
||||
with self.assertRaisesRegex(OSError, 'fixture capture failure'):
|
||||
agent.main()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Windows reconciliation of recorded POSIX paths
|
||||
|
||||
Follow-up to [#2422](https://github.com/morluto/rea/pull/2422), including
|
||||
@Aurantium-47's diagnosis of the host-dependent relative-path operation.
|
||||
|
||||
## Source pins and real data
|
||||
|
||||
The initial reproduction and passive producer used main
|
||||
fa4b8288655188426222773ead2ccbdaf76334fd. Before branching, main was updated
|
||||
to 8b181716aca2cb467be499cba226abfb5ae19141. The owning matcher and its
|
||||
affected reconciliation consumers were unchanged between those revisions.
|
||||
The newer baseline again reproduced both errors on the real Windows host.
|
||||
|
||||
Input: npm 11.16.0's published lib/utils/validate-lockfile.js, 1,023 bytes,
|
||||
SHA-256 2295e2dac1c5816e1eae2d417804960ebbef17b64a0a44db4f3483ce6e6d9526.
|
||||
The installed copy was independently checked against the exact module in the
|
||||
[official registry package](https://registry.npmjs.org/npm/11.16.0).
|
||||
The 2,945,850-byte tarball matched its published SHA-512 SRI before reading
|
||||
that member; no archive extraction or installation occurred, and the tarball
|
||||
was not retained.
|
||||
|
||||
Two actual Linux filesystem directories, Runtime/App and Runtime/app,
|
||||
held byte-identical module copies and had distinct directory identities.
|
||||
A controlled Node 24.18.0 process loaded the public module and ran its real
|
||||
function against 587 locked dependency records: compatible trees returned zero
|
||||
errors, and one controlled version mismatch returned one error. A passive REA
|
||||
Inspector capture recorded 114 loaded scripts, including the selected module.
|
||||
The provider did not evaluate, pause, resume or read source. The owned process
|
||||
exited normally. Function execution is established by the harness receipt;
|
||||
REA's passive script metadata establishes loaded status.
|
||||
|
||||
Production static analysis inspected both directories plus an independent
|
||||
Static/public-module copy. The latter prevents implicit root matching from
|
||||
hiding the explicit mapping result. Complete static/runtime Evidence was then
|
||||
consumed by the actual **Windows** CLI and SDK 2.3.1 stdio MCP server, using
|
||||
Windows 11 10.0.22631.0 and official Windows x64 Node 24.18.0.
|
||||
No Electron GUI, IDA or Ghidra engine was used.
|
||||
|
||||
## Correctness and provenance
|
||||
|
||||
| Windows reconciliation | Baseline | Candidate |
|
||||
| -------------------------------------- | ------------------------------ | ----------------------------------- |
|
||||
| Exact POSIX input root | Matched, artifact path | Same complete normalized result |
|
||||
| Case-mismatched POSIX input root | Incorrectly matched and loaded | Unknown; static load states unknown |
|
||||
| Exact explicit POSIX mapping | Matched, operator file mapping | Same complete normalized result |
|
||||
| Case-mismatched explicit POSIX mapping | Incorrectly matched and loaded | Unknown; static load states unknown |
|
||||
|
||||
All four CLI and same-connection MCP normalized results agree. The input
|
||||
Evidence, recorded runtime facts and caller-selected root spellings are
|
||||
preserved. Rejected location membership does not establish that a module was
|
||||
absent or unexecuted. The operation remains offline and performs no target or
|
||||
filesystem inspection.
|
||||
|
||||
The shared matcher now chooses posix.relative after identifying POSIX input;
|
||||
Windows coordinates continue to use win32.relative. Both location matching
|
||||
and active-layer scope use this owner. The obsolete host-default import is
|
||||
removed; no downstream repair or new path abstraction is introduced.
|
||||
|
||||
The related suite passed 102 tests: 15 workflow tests, 85 CI scope tests and
|
||||
two curated-path checks. Four simulated Electron Evidence controls cover
|
||||
POSIX mismatch/exact matching, Windows case-insensitive matching, the resulting
|
||||
asset load state and retained mapping spelling. They are service regressions,
|
||||
not real Electron-provider verification. The existing workflow test file is
|
||||
registered in both the Windows execution lane and its package-change scope.
|
||||
|
||||
## Delivery and resource observations
|
||||
|
||||
Probes ran serially at BelowNormal priority with one CPU and 512 MiB Node heap
|
||||
settings for CLI/server/client work. Heap settings do not bound aggregate memory.
|
||||
Four corrected CLI exit-time observations reported 7.343–15.827 seconds CPU and
|
||||
322,308–324,280 KiB peak working set, excluding the probe parent. Wall times
|
||||
ranged from 7.785 to 17.601 seconds. There is no paired resource baseline.
|
||||
|
||||
One complete MCP trial connected in 18,296 ms and discovered 189 tools in
|
||||
875 ms (2,588,336 serialized bytes). The four subsequent same-connection calls
|
||||
took 879, 320, 353 and 422 ms. Their serialized replies were 918,133 / 913,765 /
|
||||
918,571 / 914,171 bytes; inputs totalled 681,312 bytes. Discovery plus these
|
||||
inputs and replies totalled 6,934,288 bytes, excluding initialization, framing
|
||||
and notifications. This is delivery volume, not model token usage. Actual
|
||||
model context and total model task cost were not measured.
|
||||
|
||||
A process-generation sampler recorded 233 observations over 25,671 ms:
|
||||
15,797 ms aggregate observed CPU as a lower bound and a peak sampled working-set
|
||||
sum of 491,597,824 bytes (about 469 MiB). Exited-process tails can be missed;
|
||||
shared pages can be counted twice. This excludes the observer and is not an
|
||||
exact physical-memory peak. The server wrote four structured completion records
|
||||
to stderr; tool replies and normalized results passed the recorded checks.
|
||||
|
||||
Cold/warm caches and observer differences were not normalized. No latency,
|
||||
CPU, memory or model-token improvement is claimed. The fix adds no lookup I/O,
|
||||
engine invocation, retry or cache. Complete default replies remain available;
|
||||
only compact selected findings and hashes were sent to the investigating agent.
|
||||
|
||||
## Main integration replay
|
||||
|
||||
Runtime cb0d8a9fef2b907cff2ba27f1e017493e1173eb2, rebased onto main
|
||||
73dd8d6ed, again passed the 101 related tests and all four real Windows CLI/MCP
|
||||
cases using the unchanged older producer Evidence. Positive results and original
|
||||
runtime/layer facts remain identical. Typecheck, lint, dependency and metadata
|
||||
checks passed; all five changed files passed formatting independently.
|
||||
The full format gate at that main revision failed on the unchanged upstream
|
||||
GhidraDoctor.test.ts and GhidraSnapshotCache.test.ts files. The PR does not
|
||||
repair those unrelated baseline files. Resource figures above refer to the
|
||||
earlier candidate; the integration replay does not establish a speedup.
|
||||
|
||||
Review found that owning only the Windows test path would still skip that lane
|
||||
on a later implementation-only edit. The matcher implementation is now also
|
||||
registered in package scope. A source-only classifier case verifies that it
|
||||
selects both Windows package and JavaScript recovery lanes; 85 scope checks and
|
||||
two curated-path checks pass. Runtime code and the real-data checks are unchanged.
|
||||
|
||||
The original production fix merged as
|
||||
[1a1b67c88](https://github.com/morluto/rea/commit/1a1b67c88) while this follow-up
|
||||
was open. The follow-up was then rebased onto main fb28ec153 and contains only
|
||||
the stronger workflow assertions, Windows lane ownership, scope classification
|
||||
and this verification record. The updated 131 related tests, two representative
|
||||
Ghidra boundary tests, seven IDA input-thinning tests, 24 semantic-structure
|
||||
tests, 16 required-lane checks, 39 release-checkpoint tests and 11 dyld CLI
|
||||
tests passed after the rebase (230 total).
|
||||
Upstream a0317cf73 corrected the invalid FAT fixture and consolidated unrelated
|
||||
duplicated helpers; e05d3399f aligned the older parameter-provenance assertion
|
||||
with the upstream semantics. Those changes, the threshold change and the new
|
||||
CI qualification tiers are part of main, outside this follow-up's diff.
|
||||
Upstream 747e72991 restored release preflight coverage for reusable workflow
|
||||
jobs. The independently reviewed, fully green #2755 fixture correction merged
|
||||
as fb28ec153 and restored real x64 dyld CLI admission without altering the
|
||||
original same-named arm64e assertions.
|
||||
No production source is duplicated by the follow-up.
|
||||
@@ -2,7 +2,7 @@ REA npm dependency inventory
|
||||
Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.
|
||||
License identifiers are upstream lockfile declarations, not a legal assessment.
|
||||
Runtime includes transitive and optional platform packages; development includes build, test and website tooling.
|
||||
Locked package entries: 586; runtime: 174.
|
||||
Locked package entries: 587; runtime: 175.
|
||||
Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt.
|
||||
|
||||
@algolia/abtesting@1.25.0 | development | MIT
|
||||
@@ -1430,6 +1430,11 @@ canonicalize@3.0.0 | runtime | Apache-2.0
|
||||
Package: https://www.npmjs.com/package/canonicalize/v/3.0.0
|
||||
Archive: https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz
|
||||
Integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==
|
||||
cborg@6.1.3 | runtime | Apache-2.0
|
||||
Lockfile path: node_modules/cborg
|
||||
Package: https://www.npmjs.com/package/cborg/v/6.1.3
|
||||
Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz
|
||||
Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==
|
||||
ccount@2.0.1 | development | MIT
|
||||
Lockfile path: node_modules/ccount
|
||||
Package: https://www.npmjs.com/package/ccount/v/2.0.1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
REA third-party npm runtime copyright, license and NOTICE texts
|
||||
Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries.
|
||||
External engines are separately obtained; see THIRD_PARTY_NOTICES.md.
|
||||
Runtime dependency fingerprint (SHA-256): 5de9cf97e7c327da1892efeff30b40507e4c7f8e496b5d305b8016c162bc15e3
|
||||
Runtime dependency fingerprint (SHA-256): 63d5b55e027a0cfb3e77989ca337aca905f1652d10cfbfc4d4dd876d3ecd4f3a
|
||||
|
||||
===== @babel/helper-string-parser@8.0.0 =====
|
||||
Declared license: MIT
|
||||
@@ -2456,6 +2456,234 @@ Scope: npm runtime dependency, including optional platform packages.
|
||||
limitations under the License.
|
||||
|
||||
|
||||
===== cborg@6.1.3 =====
|
||||
Declared license: Apache-2.0
|
||||
Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz
|
||||
Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==
|
||||
Scope: npm runtime dependency, including optional platform packages.
|
||||
|
||||
--- package/LICENSE ---
|
||||
|
||||
Copyright 2020 Rod Vagg
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
--- Apache-2.0 full license (licenses/Apache-2.0.txt) ---
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
===== clean-git-ref@2.0.1 =====
|
||||
Declared license: Apache-2.0
|
||||
Archive: https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz
|
||||
|
||||
@@ -90,8 +90,10 @@ without this lane reports Windows native controls as unavailable.
|
||||
root and ancestor leases for retry; a partially removed runtime accepts only
|
||||
cleanup, and its registry entry remains until removal succeeds. Normal close, cancellation,
|
||||
startup failure, and timeout release provider resources. Abrupt owner death
|
||||
kills the owned job; it can leave private runtime files for later operator
|
||||
cleanup and does not claim transactional deletion after a crash.
|
||||
kills the owned job. The Android provider additionally uses a detached helper
|
||||
that owns its private runtime directory and cleans it when the owner pipe
|
||||
closes. Other providers can leave private runtime files after owner death;
|
||||
helper death and host power loss are not transactional deletion.
|
||||
|
||||
Windows failures retain their constraint, requested coordinate, Win32 code,
|
||||
and system message. Unsupported filesystems and reparse paths are distinct
|
||||
@@ -115,8 +117,10 @@ the supported device/UNC namespaces; existing admission checks still apply.
|
||||
|
||||
The bearer-token descriptor remains under its immutable private lease until
|
||||
`runtime_close` on Windows; POSIX removes the descriptor after the bridge reads
|
||||
it. Job ownership guarantees process termination on owner death, not deletion
|
||||
of private files after a crash. SUBST drive aliases and changing DOS-device
|
||||
it. Job ownership guarantees process termination on owner death. Android's
|
||||
detached workspace helper provides a separate cleanup owner for that provider,
|
||||
but simultaneous helper death or power loss can still leave private files.
|
||||
SUBST drive aliases and changing DOS-device
|
||||
namespaces are outside the verified P0 scope and are not detected as a separate
|
||||
alias policy. Mounted-folder paths that report a reparse tag are rejected by
|
||||
component admission; broader namespace variants remain unverified. Preserve
|
||||
@@ -163,9 +167,14 @@ V8 heap flags cannot bound (limit notification, peak commit at the limit, all
|
||||
members settled, with the notification present in the first native exit report
|
||||
and the wrapper's exit event), and a control under the same limit that
|
||||
completes normally.
|
||||
An optional independently built
|
||||
`tests/fixtures/windows/processBoundary.cc` executable adds breakaway and
|
||||
caller/child token observations and an in-place ancestor reparse attempt:
|
||||
A separately built `tests/fixtures/windows/processBoundary.cc` executable adds
|
||||
breakaway, caller/child token and environment semantics observations and an
|
||||
in-place ancestor reparse attempt. The lane skips all of them when the
|
||||
executable is absent, so both Windows workflows cross-build it in the same
|
||||
MinGW job that builds their artifact and pass it: the capability and package
|
||||
lane in `.github/workflows/ci.yml`, and the real Ghidra proof run in
|
||||
`.github/workflows/real-ghidra-windows.yml`. Build it locally with any Windows
|
||||
C++ compiler, or the same way they do, and pass its path:
|
||||
|
||||
```powershell
|
||||
node scripts/verify-windows-native.mjs . C:\fixtures\process-boundary.exe
|
||||
|
||||
Generated
+10
@@ -24,6 +24,7 @@
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"canonicalize": "3.0.0",
|
||||
"cborg": "6.1.3",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "9.0.0",
|
||||
"evmole": "0.9.3",
|
||||
@@ -4181,6 +4182,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cborg": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz",
|
||||
"integrity": "sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"cborg": "lib/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"bridge/android/ReaJadxBridge.java",
|
||||
"bridge/mitmproxy/capture.py",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/rea-android-workspace-guardian.mjs",
|
||||
"scripts/electron-active-hook.cjs",
|
||||
"scripts/electron-active-hook-boundaries.cjs",
|
||||
"scripts/hopper-demo-x11.py",
|
||||
@@ -174,6 +175,7 @@
|
||||
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
|
||||
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
|
||||
"verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs",
|
||||
"verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs",
|
||||
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
|
||||
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
|
||||
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
|
||||
@@ -269,6 +271,7 @@
|
||||
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
|
||||
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
|
||||
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
|
||||
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
||||
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
|
||||
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
||||
"compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/bundle-runtime.mjs",
|
||||
@@ -308,6 +311,7 @@
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"canonicalize": "3.0.0",
|
||||
"cborg": "6.1.3",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "9.0.0",
|
||||
"evmole": "0.9.3",
|
||||
|
||||
@@ -55,7 +55,12 @@ export const SCOPES = {
|
||||
"tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts",
|
||||
"tests/boundary/filesystem/setupUnavailableProvider.test.ts",
|
||||
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
|
||||
"tests/boundary/android/workspaceGuardianRetry.test.ts",
|
||||
"src/android/JadxWorkspaceRoot.ts",
|
||||
"scripts/rea-android-workspace-guardian.mjs",
|
||||
"tests/acceptance/setup/homeDirectoryFallback.test.ts",
|
||||
"tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts",
|
||||
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
|
||||
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
|
||||
"tests/boundary/process/ProcessEvidence.host.test.ts",
|
||||
"tests/acceptance/webModuleCli.test.ts",
|
||||
@@ -216,6 +221,7 @@ export const SCOPES = {
|
||||
"src/artifacts/readStableArtifact*.ts",
|
||||
"src/artifacts/ArtifactReader.ts",
|
||||
"scripts/verify-evm-interface.mjs",
|
||||
"scripts/verify-solidity-metadata.mjs",
|
||||
"scripts/fixtures/evm-interface.sol",
|
||||
"third_party/evmole/**",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function assertFeatureTrace(
|
||||
trace: unknown,
|
||||
procedure: { readonly address: string },
|
||||
): void;
|
||||
|
||||
export function findMappedNonFunction(
|
||||
strings: readonly { readonly address: string }[],
|
||||
resolveAt: (address: string) => Promise<unknown>,
|
||||
): Promise<string | undefined>;
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const NEGATIVE_REASONS = new Set(["not_in_procedure", "outside_segments"]);
|
||||
|
||||
/**
|
||||
* Assert the `trace_feature` result of the real IDA lane for one selected
|
||||
* procedure. The query is a substring search, so the trace may also match other
|
||||
* procedures and strings; each reference is checked against its own matched
|
||||
* target, and a source outside every function (a data reference) is valid.
|
||||
*/
|
||||
export function assertFeatureTrace(trace, procedure) {
|
||||
assert.ok(
|
||||
trace.matches.some(
|
||||
({ type, address }) =>
|
||||
type === "procedure" && address === procedure.address,
|
||||
),
|
||||
"The trace omitted the selected procedure's match.",
|
||||
);
|
||||
assert.equal(typeof trace.truncated, "boolean");
|
||||
const matched = new Set(trace.matches.map(({ address }) => address));
|
||||
for (const reference of trace.references) {
|
||||
assert.ok(
|
||||
matched.has(reference.target_address),
|
||||
`A reference targets ${reference.target_address}, which is not a match.`,
|
||||
);
|
||||
const { containing_procedure: containing } = reference;
|
||||
if (containing.found)
|
||||
assert.equal(typeof containing.procedure.address, "string");
|
||||
else assert.ok(NEGATIVE_REASONS.has(containing.reason));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a string that IDA maps outside every function. A string inside a
|
||||
* function chunk (inline data in code) is valid and skipped, so the strings are
|
||||
* tried in order. Any other answer must be "not in a procedure": IDA lists only
|
||||
* mapped strings, so an unmapped answer is a containment defect and fails the
|
||||
* lane. Returns the address, or undefined when every string is inside a function.
|
||||
*/
|
||||
export async function findMappedNonFunction(strings, resolveAt) {
|
||||
for (const { address } of strings) {
|
||||
const resolved = await resolveAt(address);
|
||||
if (resolved.found) continue;
|
||||
assert.equal(
|
||||
resolved.reason,
|
||||
"not_in_procedure",
|
||||
`The listed string at ${address} resolved as ${resolved.reason}.`,
|
||||
);
|
||||
return address;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { WindowsPrivateRuntime } from "../dist/windows/WindowsPrivateRuntime.js";
|
||||
|
||||
if (process.platform !== "win32" || process.argv.length !== 3)
|
||||
throw new Error(
|
||||
"Android workspace guardian requires Windows and one parent directory",
|
||||
);
|
||||
|
||||
// This process owns the native directory handle. Detached process creation
|
||||
// keeps it alive when the REA owner is terminated without running JS cleanup.
|
||||
const runtime = WindowsPrivateRuntime.create(process.argv[2], "rea-android-");
|
||||
const ready = await new Promise((resolve) => {
|
||||
process.stdout.once("error", () => resolve(false));
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
path: runtime.observation.path,
|
||||
volumeSerial: runtime.observation.volumeSerial,
|
||||
fileId: runtime.observation.fileId,
|
||||
privateDacl: runtime.observation.privateDacl,
|
||||
}) + "\n",
|
||||
(error) => resolve(error == null),
|
||||
);
|
||||
});
|
||||
|
||||
// The owner holds the write end until close/death. A failed readiness pipe
|
||||
// also grants cleanup authority, including owner death during startup.
|
||||
if (ready) {
|
||||
for await (const _ of process.stdin) {
|
||||
// No commands are accepted; only EOF grants cleanup authority.
|
||||
}
|
||||
} else {
|
||||
process.stdin.destroy();
|
||||
}
|
||||
|
||||
// A failed native walk retains the directory identity lease for a later retry.
|
||||
// Keep this process alive so a transient sharing violation cannot orphan it.
|
||||
for (;;) {
|
||||
try {
|
||||
await runtime.close();
|
||||
process.exit(0);
|
||||
} catch {
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,26 @@ try {
|
||||
for (const mode of ["cli", "mcp"]) {
|
||||
const report = await inspect(mode, path, encoding);
|
||||
assert.equal(report.bytecode.hex, hex);
|
||||
assert.equal(report.bytecode.metadata.status, "decoded");
|
||||
assert.deepEqual(report.bytecode.metadata.compiler_version, {
|
||||
representation: "release-bytes",
|
||||
value: "0.8.30",
|
||||
});
|
||||
assert.equal(
|
||||
report.bytecode.metadata.bytecode_offset +
|
||||
report.bytecode.metadata.cbor_bytes +
|
||||
2,
|
||||
Buffer.from(hex, "hex").length,
|
||||
);
|
||||
assert.ok(
|
||||
report.bytecode.metadata.entries.some(
|
||||
({ key, value }) =>
|
||||
key.kind === "text" &&
|
||||
key.value === "solc" &&
|
||||
value.kind === "bytes" &&
|
||||
value.value === "00081e",
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.functions
|
||||
.filter(({ dispatch }) => dispatch === "abi")
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function verifyPackageEnvironment({
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
CODEX_HOME: codexDir,
|
||||
COPILOT_HOME: join(home, ".copilot"),
|
||||
|
||||
@@ -98,6 +98,7 @@ export async function verifyPackageUpdate({
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
CODEX_HOME: join(home, ".codex"),
|
||||
COPILOT_HOME: join(home, ".copilot"),
|
||||
|
||||
@@ -118,6 +118,7 @@ async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
CODEX_HOME: join(home, ".codex"),
|
||||
COPILOT_HOME: join(home, ".copilot"),
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { exec } from "./lib/verify-package-core.mjs";
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
toolContract,
|
||||
} from "../dist/contracts/toolContracts.js";
|
||||
|
||||
assert(
|
||||
process.platform !== "win32",
|
||||
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
|
||||
);
|
||||
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
|
||||
@@ -32,6 +28,13 @@ assert(
|
||||
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
|
||||
);
|
||||
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
|
||||
// npm exposes only qwen.cmd on Windows. Resolve a selected JavaScript entry
|
||||
// before the verifier switches to its disposable workspace.
|
||||
const entryPoint = /\.(c|m)?js$/iu.test(command) ? resolve(command) : undefined;
|
||||
const launch = (args) =>
|
||||
entryPoint === undefined
|
||||
? [command, args]
|
||||
: [process.execPath, [entryPoint, ...args]];
|
||||
const verifier = createVerifierRun();
|
||||
const profile = join(lab, "selected profile");
|
||||
const account = join(lab, "account");
|
||||
@@ -54,14 +57,18 @@ const baseEnvironment = {
|
||||
let clientVersion;
|
||||
try {
|
||||
clientVersion = (
|
||||
await exec(command, ["--version"], {
|
||||
await exec(...launch(["--version"]), {
|
||||
env: baseEnvironment,
|
||||
timeout: 30_000,
|
||||
})
|
||||
).stdout.trim();
|
||||
} catch (cause) {
|
||||
const windowsHint =
|
||||
process.platform === "win32"
|
||||
? " On Windows, select node_modules/@qwen-code/qwen-code/cli-entry.js."
|
||||
: "";
|
||||
throw new Error(
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND.${windowsHint} ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
const original = JSON.stringify(
|
||||
@@ -93,8 +100,8 @@ for (const step of [
|
||||
assert(
|
||||
result.plannedActions.every(
|
||||
(a) =>
|
||||
a.target.startsWith(profile + "/") ||
|
||||
a.target.startsWith(sharedSkills + "/"),
|
||||
a.target.startsWith(profile + sep) ||
|
||||
a.target.startsWith(sharedSkills + sep),
|
||||
),
|
||||
);
|
||||
await writeFile(
|
||||
@@ -163,7 +170,14 @@ const fixture = await createOpenAiModelFixture({
|
||||
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
|
||||
const messageText = (body.messages ?? [])
|
||||
.map((message) =>
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: (message.content ?? []).map((part) => part.text ?? "").join("\n"),
|
||||
)
|
||||
.join("\n");
|
||||
selectedSkillLoaded ||= messageText.includes(
|
||||
join(sharedSkills, "reverse-engineer-anything"),
|
||||
);
|
||||
const toolText = JSON.stringify(
|
||||
@@ -243,9 +257,7 @@ const fixture = await createOpenAiModelFixture({
|
||||
const invoke = tools.find((t) => t.name === "tool_call");
|
||||
const skill = tools.find((t) => t.name === "skill");
|
||||
const shell = tools.find((t) => t.name === "run_shell_command");
|
||||
const persisted = toolText.match(
|
||||
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
|
||||
);
|
||||
const persisted = messageText.match(/Full output saved to: ([^\r\n]+)/u);
|
||||
const call = (name, args) => ({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
@@ -268,13 +280,14 @@ const fixture = await createOpenAiModelFixture({
|
||||
artifactPath = persisted[1];
|
||||
assert(
|
||||
(await realpath(artifactPath)).startsWith(
|
||||
(await realpath(runtime)) + "/",
|
||||
(await realpath(runtime)) + sep,
|
||||
),
|
||||
);
|
||||
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
|
||||
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
// Keep paths and JSON quotes out of shell syntax on Windows and POSIX.
|
||||
const encodedScript = Buffer.from(script).toString("base64");
|
||||
message = call(shell.name, {
|
||||
command: `node -e ${quote(script)}`,
|
||||
command: `node -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`,
|
||||
description:
|
||||
"Read the complete saved REA Evidence and verify the fixture facts.",
|
||||
});
|
||||
@@ -329,7 +342,7 @@ const args = [
|
||||
let response;
|
||||
let failure;
|
||||
try {
|
||||
response = await exec(command, args, {
|
||||
response = await exec(...launch(args), {
|
||||
env,
|
||||
cwd: workspace,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} 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 { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
if (process.argv.length !== 2)
|
||||
throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs");
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
|
||||
);
|
||||
const exec = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const ca65 = process.env.REA_CA65 ?? "ca65";
|
||||
const ld65 = process.env.REA_LD65 ?? "ld65";
|
||||
const toolchain = {};
|
||||
for (const [name, command] of Object.entries({ ca65, ld65 })) {
|
||||
const version = await exec(command, ["--version"], { timeout: 10000 });
|
||||
toolchain[name] = (version.stdout + version.stderr).trim();
|
||||
}
|
||||
await mkdir(join(root, "build"), { recursive: true });
|
||||
const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const path = join(workspace, "image.bin");
|
||||
const object = join(workspace, "image.o");
|
||||
const entrypoint = join(root, "scripts", "rea.mjs");
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: runtime,
|
||||
TMP: runtime,
|
||||
TEMP: runtime,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-6502-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
let report;
|
||||
try {
|
||||
await exec(
|
||||
ca65,
|
||||
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await exec(
|
||||
ld65,
|
||||
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const bytes = await readFile(path);
|
||||
// Independently known opcodes and link addresses from the source fixture.
|
||||
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await client.connect(transport);
|
||||
const opened = await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x008001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(opened.architecture, "6502");
|
||||
assert.equal(opened.entry_point, "0x8001");
|
||||
const status = await call("binary_session");
|
||||
const image = evidence(await call("inspect_native_load_image"), sha256);
|
||||
assert.equal(
|
||||
image.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(image.normalized_result),
|
||||
);
|
||||
assert.equal(image.normalized_result.architecture, "6502");
|
||||
assert.equal(image.normalized_result.base_address, "0x8001");
|
||||
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
|
||||
assert.equal(image.normalized_result.compiler_spec_id, "default");
|
||||
assert.equal(image.normalized_result.file_bytes, bytes.length);
|
||||
const memory = evidence(
|
||||
await call("read_bytes", { address: "0x8001", length: bytes.length }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
||||
const procedures = evidence(await call("list_procedures"), sha256);
|
||||
for (const address of ["0x8001", "0x8010"])
|
||||
assert.ok(
|
||||
procedures.normalized_result.some(
|
||||
(procedure) => procedure.address === address,
|
||||
),
|
||||
);
|
||||
const instruction = evidence(
|
||||
await call("inspect_native_instruction", { address: "0x8003" }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
|
||||
assert.ok(
|
||||
instruction.normalized_result.references.some(
|
||||
(reference) => reference.call && reference.target_address === "0x8010",
|
||||
),
|
||||
);
|
||||
const pseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "0x8010" }),
|
||||
sha256,
|
||||
);
|
||||
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
|
||||
const filePseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
|
||||
assert.ok(
|
||||
filePseudo.locations.some(
|
||||
(location) => location.kind === "file-offset" && location.offset === 15,
|
||||
),
|
||||
);
|
||||
const rejected = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0xffff",
|
||||
},
|
||||
});
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
|
||||
const preserved = await call("binary_session");
|
||||
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
|
||||
assert.equal(preserved.entry_point, "0x8001");
|
||||
await call("close_binary");
|
||||
await client.ping();
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
path,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const cli = parseEvidence(JSON.parse(stdout));
|
||||
assert.deepEqual(cli.normalized_result, image.normalized_result);
|
||||
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
|
||||
assert.equal(cli.subject.digest.sha256, sha256);
|
||||
const fileCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decompile",
|
||||
path,
|
||||
"--file-offset=0xf",
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
|
||||
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
|
||||
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
|
||||
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
const lowMemory = [];
|
||||
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
|
||||
// that overlapping zero-page/stack definitions never overlay the selected image.
|
||||
for (const [entry, fullAddressSpace] of [
|
||||
[0xff, false],
|
||||
[0, true],
|
||||
]) {
|
||||
const lowPath = join(workspace, `low-${entry}.bin`);
|
||||
await exec(
|
||||
ld65,
|
||||
[
|
||||
"-C",
|
||||
join(root, "tests/conformance/ghidra/6502.cfg"),
|
||||
"--start-addr",
|
||||
String(entry),
|
||||
"-o",
|
||||
lowPath,
|
||||
object,
|
||||
],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const linked = await readFile(lowPath);
|
||||
const expectedCode = Buffer.from(
|
||||
"a907200000c90ad003a92a60a9006018690360",
|
||||
"hex",
|
||||
);
|
||||
expectedCode.writeUInt16LE(entry + 15, 3);
|
||||
assert.deepEqual(linked, expectedCode);
|
||||
const lowBytes = fullAddressSpace
|
||||
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
|
||||
: linked;
|
||||
await writeFile(lowPath, lowBytes);
|
||||
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
|
||||
const lowEntry = `0x${entry.toString(16)}`;
|
||||
const lowOpened = await call("open_binary", {
|
||||
path: lowPath,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: lowEntry,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(lowOpened.entry_point, lowEntry);
|
||||
const lowImage = evidence(
|
||||
await call("inspect_native_load_image"),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(
|
||||
lowImage.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(lowImage.normalized_result),
|
||||
);
|
||||
assert.equal(lowImage.normalized_result.base_address, lowEntry);
|
||||
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
|
||||
const fileMappings =
|
||||
lowImage.normalized_result.observations.mappings.filter(
|
||||
(mapping) => mapping.source_file_index !== null,
|
||||
);
|
||||
assert.equal(fileMappings.length, 1);
|
||||
assert.equal(
|
||||
fileMappings[0].address_space,
|
||||
lowImage.normalized_result.observations.default_address_space,
|
||||
);
|
||||
assert.equal(fileMappings[0].overlay, false);
|
||||
assert.equal(fileMappings[0].start, lowEntry);
|
||||
assert.equal(fileMappings[0].length, lowBytes.length);
|
||||
const lowRead = evidence(
|
||||
await call("read_bytes", { address: lowEntry, length: linked.length }),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
|
||||
const lowCall = evidence(
|
||||
await call("inspect_native_instruction", {
|
||||
address: `0x${(entry + 2).toString(16)}`,
|
||||
}),
|
||||
lowSha256,
|
||||
);
|
||||
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
|
||||
assert.ok(
|
||||
lowCall.normalized_result.references.some(
|
||||
(reference) =>
|
||||
reference.call &&
|
||||
reference.target_address === `0x${(entry + 15).toString(16)}`,
|
||||
),
|
||||
);
|
||||
await call("close_binary");
|
||||
const lowCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
lowPath,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
lowEntry,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
|
||||
assert.deepEqual(
|
||||
lowCliEvidence.normalized_result,
|
||||
lowImage.normalized_result,
|
||||
);
|
||||
assert.equal(
|
||||
lowCliEvidence.analysis_profile.digest,
|
||||
lowImage.analysis_profile.digest,
|
||||
);
|
||||
assert.deepEqual(await readFile(lowPath), lowBytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
lowMemory.push({
|
||||
entry: lowEntry,
|
||||
bytes: lowBytes.length,
|
||||
sha256: lowSha256,
|
||||
});
|
||||
}
|
||||
let windowsReparseAdmission = "not-applicable";
|
||||
if (process.platform === "win32") {
|
||||
const junction = join(workspace, "linked");
|
||||
await symlink(workspace, junction, "junction");
|
||||
try {
|
||||
const linkedPath = join(junction, "image.bin");
|
||||
// Reopening the same bytes through a junction must replace the direct session.
|
||||
await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x8001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
const directImage = evidence(
|
||||
await call("inspect_native_load_image"),
|
||||
sha256,
|
||||
);
|
||||
assert.equal(directImage.normalized_result.status, "verified");
|
||||
// Canonical file identity must not bypass admission of the new path.
|
||||
await call("open_binary", {
|
||||
path: linkedPath,
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
entry_point: "0x8001",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
// open_binary selects a lazy session; the first analysis admits the snapshot.
|
||||
const linkedImage = await client.callTool(
|
||||
{ name: "inspect_native_load_image", arguments: {} },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.equal(linkedImage.isError, true);
|
||||
assert.match(
|
||||
JSON.stringify(linkedImage),
|
||||
/Reparse path component is unsupported/u,
|
||||
);
|
||||
await call("close_binary");
|
||||
const snapshotPath = join(workspace, "direct-snapshot.json");
|
||||
const cliArgs = (selectedPath) => [
|
||||
entrypoint,
|
||||
"analyze",
|
||||
selectedPath,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"6502",
|
||||
"--entry-point",
|
||||
"0x8001",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--snapshot",
|
||||
snapshotPath,
|
||||
"--json",
|
||||
];
|
||||
const saved = await exec(process.execPath, cliArgs(path), {
|
||||
env,
|
||||
timeout: 240000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
assert.equal(
|
||||
evidence(JSON.parse(saved.stdout), sha256).operation,
|
||||
"binary_overview",
|
||||
);
|
||||
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
|
||||
assert.ok(
|
||||
savedSnapshot.workflow_entries.some(
|
||||
({ operation }) => operation === "binary_overview",
|
||||
),
|
||||
);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
// Replaying a matching snapshot must still reach native path admission.
|
||||
await assert.rejects(
|
||||
exec(process.execPath, cliArgs(linkedPath), {
|
||||
env,
|
||||
timeout: 240000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}),
|
||||
(cause) => {
|
||||
assert.match(
|
||||
cause.stdout + cause.stderr,
|
||||
/Reparse path component is unsupported/u,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
assert.deepEqual(await readdir(runtime), []);
|
||||
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
|
||||
} finally {
|
||||
await unlink(junction);
|
||||
}
|
||||
}
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
lane: "ghidra-6502",
|
||||
host: process.platform,
|
||||
toolchain,
|
||||
provider: image.provider,
|
||||
target: {
|
||||
sha256,
|
||||
bytes: bytes.length,
|
||||
architecture: "6502",
|
||||
entry: "0x8001",
|
||||
},
|
||||
cli_mcp_load_image_parity: true,
|
||||
instructions_and_call_reference: true,
|
||||
decompilation: true,
|
||||
file_offset_decompilation_cli_mcp: true,
|
||||
invalid_mapping_preserves_session: true,
|
||||
low_memory_cli_mcp_parity: lowMemory,
|
||||
windows_reparse_admission: windowsReparseAdmission,
|
||||
source_unchanged: true,
|
||||
runtime_cleanup: "complete",
|
||||
};
|
||||
} catch (cause) {
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
const verifierRun = await completeVerifierRun(run);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal(verifierRun.process_lineage.status, "verified");
|
||||
assert.deepEqual(verifierRun.process_lineage.descendants, []);
|
||||
}
|
||||
// Keep the workspace for diagnosis unless verification and cleanup both pass.
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
process.stdout.write(
|
||||
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
|
||||
);
|
||||
|
||||
async function call(name, args = {}) {
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
if (["open_binary", "binary_session", "close_binary"].includes(name))
|
||||
return requireMcpOperationResult(result, name);
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
function evidence(value, sha256) {
|
||||
const result = parseEvidence(value);
|
||||
assert.equal(result.subject.digest.sha256, sha256);
|
||||
assert.equal(result.subject.architecture, "6502");
|
||||
assert.equal(result.subject.format, "raw-binary");
|
||||
return result;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import {
|
||||
inspectMipsReadelf,
|
||||
inspectLegacyMipsReadelf,
|
||||
assertMipsLoadedImage,
|
||||
assertMipsProbe,
|
||||
assertMipsGlobal,
|
||||
@@ -122,6 +123,157 @@ async function verifySoftFloat(call, symbols, byteOrder) {
|
||||
const reports = [];
|
||||
let primaryFailure;
|
||||
try {
|
||||
if (!psp) {
|
||||
// Recordless legacy admission: ABI field 0, no ABI flags record, pinned
|
||||
// 32-bit language. Independent GNU readelf checks the header interpretation.
|
||||
for (const byteOrder of ["little", "big"]) {
|
||||
const fixture = await buildMipsFixture(workspace, byteOrder, "hard", {
|
||||
legacy: true,
|
||||
});
|
||||
const legacy = await inspectLegacyMipsReadelf(fixture.path, byteOrder);
|
||||
const resolved = await parseBinaryTarget(fixture.path);
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
assert.equal(resolved.value.mips.flags, 0x50000001);
|
||||
assert.equal(resolved.value.mips.abiFlags, null);
|
||||
assert.deepEqual(
|
||||
resolved.value.mips.limitations ?? [],
|
||||
legacy.limitations,
|
||||
"REA legacy ABI-record absence interpretation disagrees with GNU readelf",
|
||||
);
|
||||
assert.equal(resolved.value.sha256, fixture.sha256);
|
||||
const snapshotPath = join(workspace, `${byteOrder}-legacy.snapshot.json`);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "rea-real-mips-legacy-proof",
|
||||
version: "1",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
const call = async (name, args = {}) => {
|
||||
const reply = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
undefined,
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
return requireMcpOperationResult(reply, name);
|
||||
};
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const selected = await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(selected.architecture, "mips");
|
||||
assert.equal(selected.sha256, fixture.sha256);
|
||||
const image = await call("inspect_native_load_image");
|
||||
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
|
||||
// The pinned import language is the checked one, not an auto-selection.
|
||||
assert.equal(
|
||||
image.observations.language_id,
|
||||
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
|
||||
);
|
||||
const procedures = await call("list_procedures");
|
||||
const entry = procedures.find((p) => p.value === "rea_mips_entry");
|
||||
assert.ok(entry, "Entry function missing from legacy inventory");
|
||||
assert.equal(
|
||||
BigInt(entry.address),
|
||||
BigInt(legacy.symbols.rea_mips_entry),
|
||||
);
|
||||
const probe = await call("inspect_native_instruction", {
|
||||
address: legacy.symbols.rea_mips_probe,
|
||||
});
|
||||
const probeBranch = await call("inspect_native_instruction", {
|
||||
address: `0x${(BigInt(legacy.symbols.rea_mips_probe) + 4n).toString(16)}`,
|
||||
});
|
||||
assertMipsProbe(
|
||||
probe,
|
||||
probeBranch,
|
||||
byteOrder,
|
||||
legacy.symbols.rea_mips_probe,
|
||||
);
|
||||
const dossier = functionDossierSchema.parse(
|
||||
await call("analyze_function", { procedure: entry.address }),
|
||||
);
|
||||
assert.ok(dossier.pseudocode.trim().length > 0);
|
||||
const saved = await call("close_binary", {
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
assert.ok(saved.evidence_records > 0);
|
||||
const snapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(snapshotPath, "utf8")),
|
||||
);
|
||||
assert.equal(snapshot.target.architecture, "mips");
|
||||
const parameters = snapshot.binding.analysis_profile.parameters;
|
||||
assert.equal(
|
||||
parameters.language_id,
|
||||
`MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`,
|
||||
);
|
||||
assert.equal(parameters.language_selection, "provider-pinned-v1");
|
||||
assert.equal(
|
||||
parameters.mips_support_lane,
|
||||
"elf32-exec-arch32-legacy-pinned-v1",
|
||||
);
|
||||
assert.equal(parameters.mips_abi_basis, "inferred-o32-assumption-v1");
|
||||
assert.ok(
|
||||
snapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
item.includes("analysis assumption"),
|
||||
),
|
||||
),
|
||||
"Legacy o32 assumption limitation missing from retained Evidence",
|
||||
);
|
||||
assert.ok(
|
||||
snapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
item.includes("Floating-point ABI semantics are unverified"),
|
||||
),
|
||||
),
|
||||
"Legacy floating-point uncertainty missing from retained Evidence",
|
||||
);
|
||||
await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
assert.deepEqual(await call("list_procedures"), procedures);
|
||||
await call("close_binary");
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`MIPS legacy ${byteOrder} verification failed; MCP stderr: ${stderr}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
reports.push({
|
||||
...fixture,
|
||||
independent_reader: legacy,
|
||||
variant: "recordless-legacy-pinned",
|
||||
effective_language: `MIPS:${
|
||||
byteOrder === "little" ? "LE" : "BE"
|
||||
}:32:default`,
|
||||
procedures: "verified-through-snapshot",
|
||||
checks: [
|
||||
"recordless header admitted with inferred o32 assumption",
|
||||
"pinned 32-bit language checked after import",
|
||||
"legacy limitations retained in Evidence",
|
||||
"snapshot round-trip and profile parameters",
|
||||
],
|
||||
status: "passed",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [byteOrder, floatAbi] of psp
|
||||
? [["little", "hard"]]
|
||||
: [
|
||||
|
||||
@@ -45,8 +45,13 @@ const transport = new StdioClientTransport({
|
||||
const source = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/type-inventory.swift", import.meta.url),
|
||||
);
|
||||
const literalSource = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/string-literals.swift", import.meta.url),
|
||||
);
|
||||
const binary = join(root, "swift-inventory");
|
||||
const literalBinary = join(root, "swift-literals");
|
||||
const reports = [];
|
||||
let literalReport;
|
||||
try {
|
||||
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
|
||||
timeout: 60_000,
|
||||
@@ -54,6 +59,19 @@ try {
|
||||
await mkdir(join(root, "copied"));
|
||||
const copied = join(root, "copied", "swift-inventory");
|
||||
await copyFile(binary, copied);
|
||||
// Decoding covers AArch64 register pairs, so build that slice on any Mac host.
|
||||
await execute(
|
||||
"swiftc",
|
||||
[
|
||||
"-O",
|
||||
"-target",
|
||||
"arm64-apple-macosx13.0",
|
||||
literalSource,
|
||||
"-o",
|
||||
literalBinary,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await client.connect(transport);
|
||||
for (const [variant, path] of [
|
||||
["debug", binary],
|
||||
@@ -132,6 +150,7 @@ try {
|
||||
await call("close_binary", {});
|
||||
}
|
||||
}
|
||||
literalReport = await verifyStringLiterals();
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
@@ -143,6 +162,7 @@ console.log(
|
||||
ok: true,
|
||||
provider: "ghidra",
|
||||
fixtures: reports,
|
||||
swift_string_literals: literalReport,
|
||||
verifier_run: await completeVerifierRun(run),
|
||||
},
|
||||
null,
|
||||
@@ -159,3 +179,51 @@ async function call(name, arguments_) {
|
||||
toolContract(name).outputSchema.parse(response.structuredContent);
|
||||
return requireMcpOperationResult(response, name);
|
||||
}
|
||||
|
||||
// Literal text lives only in register constants, so it must come from the decoded pairs.
|
||||
async function verifyStringLiterals() {
|
||||
await call("open_binary", { path: literalBinary, provider_id: "ghidra" });
|
||||
try {
|
||||
const procedures = await call("list_procedures", {});
|
||||
const expected = {
|
||||
smallLiteral: [["SleepDisabled", "small"]],
|
||||
largeLiteral: [["The power setting could not be changed.", "large"]],
|
||||
unicodeLiterals: [
|
||||
["café", "small"],
|
||||
["Résumé of the naïve décor, written twice", "large"],
|
||||
],
|
||||
emptyAndControlLiterals: [
|
||||
["", "small"],
|
||||
["tab\there", "small"],
|
||||
],
|
||||
returnedLiteral: [["Holding awake", "small"]],
|
||||
};
|
||||
const decoded = {};
|
||||
for (const [name, literals] of Object.entries(expected)) {
|
||||
const procedure = procedures.find(({ value }) => value.includes(name));
|
||||
assert(procedure, `${name} is missing from the procedure inventory`);
|
||||
const dossier = await call("analyze_function", {
|
||||
procedure: procedure.address,
|
||||
});
|
||||
const observed = dossier.swift_string_literals;
|
||||
assert(Array.isArray(observed), `${name} recorded no Swift literals`);
|
||||
assert.deepEqual(dossier.swift_string_literal_omissions, []);
|
||||
assert.deepEqual(
|
||||
observed.map(({ value, representation }) => [value, representation]),
|
||||
literals,
|
||||
);
|
||||
for (const literal of observed) {
|
||||
assert.deepEqual(literal.registers, ["x0", "x1"]);
|
||||
assert.equal(
|
||||
literal.address === null,
|
||||
literal.representation === "small",
|
||||
`${literal.value} storage does not match its representation`,
|
||||
);
|
||||
}
|
||||
decoded[name] = observed.length;
|
||||
}
|
||||
return decoded;
|
||||
} finally {
|
||||
await call("close_binary", {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
assertFeatureTrace,
|
||||
findMappedNonFunction,
|
||||
} from "./lib/ida-feature-trace.mjs";
|
||||
import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs";
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
@@ -6,7 +10,7 @@ import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
@@ -261,6 +265,61 @@ const mcp = async (cliDossier) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (config.mode === "headless") {
|
||||
const documents = observe(await call("list_documents", {}));
|
||||
assert.equal(documents.length, 1);
|
||||
assert.equal(basename(documents[0]), basename(target));
|
||||
assert.ok(
|
||||
!workspaces.has(dirname(documents[0])),
|
||||
"list_documents reported the private workspace copy.",
|
||||
);
|
||||
const resolveAt = async (address) =>
|
||||
observe(await call("resolve_containing_procedure", { address }));
|
||||
const instructionAddresses = dossier.assembly
|
||||
.map((line) => /^(0x[0-9a-f]+)/iu.exec(line)?.[1])
|
||||
.filter((address) => address !== undefined);
|
||||
const queries = [
|
||||
dossier.procedure.address,
|
||||
...instructionAddresses.slice(1, 2),
|
||||
];
|
||||
for (const queried of queries) {
|
||||
const containing = await resolveAt(queried);
|
||||
assert.equal(containing.found, true);
|
||||
assert.equal(containing.procedure.address, dossier.procedure.address);
|
||||
assert.equal(containing.procedure.name, dossier.procedure.name);
|
||||
}
|
||||
const unmapped = await resolveAt("0xffffffffffffff00");
|
||||
assert.equal(unmapped.found, false);
|
||||
assert.equal(unmapped.reason, "outside_segments");
|
||||
const strings = observe(await call("list_strings", {}));
|
||||
const mappedString = await findMappedNonFunction(strings, resolveAt);
|
||||
if (mappedString === undefined)
|
||||
report.unverified.push(
|
||||
"Mapped non-function containment: no string of the target is mapped outside every function.",
|
||||
);
|
||||
const context = await client.callTool({
|
||||
name: "inspect_address_context",
|
||||
arguments: { address: dossier.procedure.address },
|
||||
});
|
||||
assert.notEqual(context.isError, true);
|
||||
const contextResult = context.structuredContent.result;
|
||||
assert.equal(contextResult.document, documents[0]);
|
||||
assert.equal(contextResult.procedure.state, "available");
|
||||
assert.equal(
|
||||
contextResult.procedure.value.procedure.address,
|
||||
dossier.procedure.address,
|
||||
);
|
||||
const traced = await client.callTool({
|
||||
name: "trace_feature",
|
||||
arguments: { query: dossier.procedure.name },
|
||||
});
|
||||
assert.notEqual(traced.isError, true);
|
||||
const trace = traced.structuredContent.normalized_result;
|
||||
assertFeatureTrace(trace, dossier.procedure);
|
||||
report.checks.push(
|
||||
"headless containing procedures, the admitted program as the document, address context, and the procedure match and references of feature tracing",
|
||||
);
|
||||
}
|
||||
if (values["expected-string"] === undefined)
|
||||
report.unverified.push(
|
||||
"String inventory/search correctness: no independent expected literal supplied.",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { inspectSolidityMetadata } from "../dist/evm/SolidityMetadata.js";
|
||||
|
||||
const compilerModule = process.env.REA_VERIFY_SOLC_MODULE;
|
||||
if (!isAbsolute(compilerModule ?? ""))
|
||||
throw new Error(
|
||||
"Select an absolute caller-installed solc 0.8.30 module with REA_VERIFY_SOLC_MODULE.",
|
||||
);
|
||||
const solc = (await import(pathToFileURL(compilerModule).href)).default;
|
||||
assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang");
|
||||
const source = await readFile(
|
||||
new URL("./fixtures/evm-interface.sol", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cases = [
|
||||
{ name: "unoptimized", optimizer: false, viaIR: false, metadata: {} },
|
||||
{ name: "viaIR", optimizer: true, viaIR: true, metadata: {} },
|
||||
{
|
||||
name: "without-content-hash",
|
||||
optimizer: false,
|
||||
viaIR: false,
|
||||
metadata: { bytecodeHash: "none" },
|
||||
},
|
||||
{
|
||||
name: "without-CBOR",
|
||||
optimizer: false,
|
||||
viaIR: false,
|
||||
metadata: { appendCBOR: false },
|
||||
},
|
||||
];
|
||||
const reports = [];
|
||||
for (const fixture of cases) {
|
||||
const compiled = JSON.parse(
|
||||
solc.compile(
|
||||
JSON.stringify({
|
||||
language: "Solidity",
|
||||
sources: { "fixture.sol": { content: source } },
|
||||
settings: {
|
||||
optimizer: { enabled: fixture.optimizer },
|
||||
viaIR: fixture.viaIR,
|
||||
metadata: fixture.metadata,
|
||||
outputSelection: { "*": { "*": ["evm.deployedBytecode.object"] } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!(compiled.errors ?? []).some((error) => error.severity === "error"),
|
||||
JSON.stringify(compiled.errors),
|
||||
);
|
||||
const bytes = Buffer.from(
|
||||
compiled.contracts["fixture.sol"].InterfaceFixture.evm.deployedBytecode
|
||||
.object,
|
||||
"hex",
|
||||
);
|
||||
const cpu = process.cpuUsage();
|
||||
const started = performance.now();
|
||||
const parsed = inspectSolidityMetadata(bytes);
|
||||
const firstMs = performance.now() - started;
|
||||
const firstCpu = process.cpuUsage(cpu);
|
||||
if (fixture.metadata.appendCBOR === false)
|
||||
assert.equal(parsed.status, "unavailable");
|
||||
else {
|
||||
assert.equal(parsed.status, "decoded");
|
||||
assert.deepEqual(parsed.compiler_version, {
|
||||
representation: "release-bytes",
|
||||
value: "0.8.30",
|
||||
});
|
||||
assert.equal(parsed.bytecode_offset + parsed.cbor_bytes + 2, bytes.length);
|
||||
if (fixture.metadata.bytecodeHash === "none")
|
||||
assert.deepEqual(
|
||||
parsed.entries.map(({ key }) => key),
|
||||
[{ kind: "text", value: "solc" }],
|
||||
);
|
||||
}
|
||||
const warmResults = new Array(100);
|
||||
const warmStart = performance.now();
|
||||
for (let index = 0; index < warmResults.length; index++)
|
||||
warmResults[index] = inspectSolidityMetadata(bytes);
|
||||
const warmMs = performance.now() - warmStart;
|
||||
for (const result of warmResults) assert.deepEqual(result, parsed);
|
||||
reports.push({
|
||||
fixture: fixture.name,
|
||||
bytes: bytes.length,
|
||||
status: parsed.status,
|
||||
first_decode_ms: firstMs,
|
||||
first_decode_cpu_us: firstCpu,
|
||||
warm_100_decode_ms: warmMs,
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
compiler_version: solc.version(),
|
||||
contract_execution: "not-performed",
|
||||
cases: reports,
|
||||
process_peak_rss_kib: process.resourceUsage().maxRSS,
|
||||
measurements:
|
||||
"In-process decode timings; peak RSS includes the compiler. End-to-end Linux provider/CLI costs and agent tokens were not measured.",
|
||||
}),
|
||||
);
|
||||
@@ -27,9 +27,9 @@ if (
|
||||
);
|
||||
const offset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : bytes.length;
|
||||
if (
|
||||
bytes.toString("ascii", 0, 2) !== "MZ" ||
|
||||
bytes.toString("latin1", 0, 2) !== "MZ" ||
|
||||
offset > bytes.length - 26 ||
|
||||
bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" ||
|
||||
bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" ||
|
||||
bytes.readUInt16LE(offset + 4) !== 0x8664 ||
|
||||
bytes.readUInt16LE(offset + 24) !== 0x20b
|
||||
)
|
||||
|
||||
@@ -52,8 +52,10 @@ try {
|
||||
"bridge/ghidra/ReaGhidraBridge.java",
|
||||
"native/windows/build/manifest.json",
|
||||
"native/windows/build/rea-windows-x64.node",
|
||||
"dist/android/JadxWorkspaceRoot.js",
|
||||
"dist/main.js",
|
||||
"dist/cli.js",
|
||||
"scripts/rea-android-workspace-guardian.mjs",
|
||||
"scripts/rea.mjs",
|
||||
])
|
||||
if (!packagedPaths.has(required))
|
||||
@@ -108,6 +110,7 @@ try {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
CODEX_HOME: join(home, ".codex"),
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
|
||||
@@ -173,6 +173,50 @@ try {
|
||||
assert.deepEqual(structured.normalized_result, evidence.normalized_result);
|
||||
console.log(`PASS MCP/CLI parity ${task.name}`);
|
||||
}
|
||||
const overloadedInput = {
|
||||
path,
|
||||
class_name: "androidx.activity.ComponentActivity",
|
||||
method_name: "setContentView",
|
||||
overload_index: 0,
|
||||
};
|
||||
const overloadedCli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"trace-android-references",
|
||||
path,
|
||||
overloadedInput.class_name,
|
||||
"--method-name",
|
||||
overloadedInput.method_name,
|
||||
"--overload-index",
|
||||
"0",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const overloadedEvidence = parseEvidence(JSON.parse(overloadedCli.stdout));
|
||||
assert.equal(overloadedEvidence.normalized_result.overload_index, 0);
|
||||
assert.equal(
|
||||
overloadedEvidence.normalized_result.reported_signature,
|
||||
"setContentView(int): void",
|
||||
);
|
||||
assert.equal(overloadedEvidence.normalized_result.total, 1);
|
||||
const overloadedMcp = await client.callTool(
|
||||
{ name: "trace_android_references", arguments: overloadedInput },
|
||||
{ timeout: 150_000 },
|
||||
);
|
||||
assert.notEqual(overloadedMcp.isError, true, JSON.stringify(overloadedMcp));
|
||||
assert.deepEqual(
|
||||
parseEvidence(overloadedMcp.structuredContent).normalized_result,
|
||||
overloadedEvidence.normalized_result,
|
||||
);
|
||||
console.log("PASS real overloaded method references through CLI/MCP");
|
||||
} catch (cause) {
|
||||
process.stderr.write(stderr);
|
||||
throw cause;
|
||||
@@ -205,7 +249,7 @@ assert.equal(method.body_status, "available");
|
||||
assert.equal(method.source.status, "complete");
|
||||
assert.ok(method.source.text.includes("getIntent()"));
|
||||
assert.ok(method.source.text.includes("setListAdapter"));
|
||||
assert.equal(method.method.dex_descriptor, null);
|
||||
assert.equal(method.method.dex_descriptor, "(Landroid/os/Bundle;)V");
|
||||
const references = cliResults.get("trace_android_references").normalized_result;
|
||||
assert.ok(references.total > 0);
|
||||
assert.ok(
|
||||
@@ -264,3 +308,8 @@ if (process.env.REA_ANDROID_REPORT !== undefined)
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
assert.notEqual(
|
||||
windowsLifecycle?.cli_abrupt_owner_exit_workspace_cleanup,
|
||||
false,
|
||||
"Android workspace remained after abrupt owner exit",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import { hashAndroidFile } from "./digest.mjs";
|
||||
|
||||
/** The public result keeps one using node and exposes its two raw DEX invokes. */
|
||||
export const verifyAndroidDirectInvokes = async ({
|
||||
apk,
|
||||
dexEntry,
|
||||
repository,
|
||||
entrypoint,
|
||||
environment,
|
||||
execute,
|
||||
}) => {
|
||||
const digest = await hashAndroidFile(apk);
|
||||
const check = (value) => {
|
||||
const evidence = parseEvidence(value);
|
||||
assert.equal(evidence.subject.digest.sha256, digest);
|
||||
assert.equal(evidence.operation, "trace_android_references");
|
||||
const result = evidence.normalized_result;
|
||||
assert.equal(result.class_name, "fixture.Target");
|
||||
assert.equal(result.method_name, "mark");
|
||||
assert.equal(result.dex_descriptor, "()V");
|
||||
assert.equal(result.reference_unit, "using_node");
|
||||
assert.equal(result.total, 1);
|
||||
assert.equal(result.references.length, 1);
|
||||
const source = result.references[0];
|
||||
assert.equal(source.reported_full_name, "fixture.Caller.twice");
|
||||
assert.deepEqual(
|
||||
source.direct_invokes.map((site) => site.dex_code_unit_offset),
|
||||
[0, 3],
|
||||
);
|
||||
for (const site of source.direct_invokes) {
|
||||
assert.equal(site.dex_entry, dexEntry);
|
||||
assert.equal(site.source_raw_method_id, "fixture.Caller.twice()V");
|
||||
assert.equal(site.opcode, "INVOKE_STATIC");
|
||||
}
|
||||
assert.equal(
|
||||
source.direct_invokes[0].dex_method_index,
|
||||
source.direct_invokes[1].dex_method_index,
|
||||
);
|
||||
return evidence;
|
||||
};
|
||||
const cliResponse = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"trace-android-references",
|
||||
apk,
|
||||
"fixture.Target",
|
||||
"--method-name",
|
||||
"mark",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const cli = check(JSON.parse(cliResponse.stdout));
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const client = new Client({
|
||||
name: "rea-real-android-direct-invokes",
|
||||
version: "1",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport, { timeout: 30_000 });
|
||||
const response = await client.callTool(
|
||||
{
|
||||
name: "trace_android_references",
|
||||
arguments: {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
method_name: "mark",
|
||||
},
|
||||
},
|
||||
{ timeout: 150_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
const mcp = check(response.structuredContent);
|
||||
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
|
||||
assert.deepEqual(mcp.parameters, cli.parameters);
|
||||
assert.deepEqual(mcp.provider, cli.provider);
|
||||
} catch (cause) {
|
||||
process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
assert.equal(
|
||||
await hashAndroidFile(apk),
|
||||
digest,
|
||||
"Static inspection changed fixture bytes",
|
||||
);
|
||||
console.log(
|
||||
`PASS real CLI/MCP direct-invoke offsets from ${dexEntry}, parity and unchanged APK`,
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join, resolve } from "node:path";
|
||||
import { verifyAndroidInitializers } from "./initializers.mjs";
|
||||
import { verifyAndroidDirectInvokes } from "./direct-invokes.mjs";
|
||||
|
||||
/** Prove cold metadata stays unmaterialized and overload identities survive code generation. */
|
||||
export const verifyAndroidMetadata = async ({
|
||||
@@ -60,6 +61,7 @@ export const verifyAndroidMetadata = async ({
|
||||
apk,
|
||||
resolve(repository, "tests/fixtures/android/static-initializers"),
|
||||
classes,
|
||||
resolve(repository, "tests/fixtures/android/direct-invokes"),
|
||||
],
|
||||
{ timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
@@ -71,12 +73,19 @@ export const verifyAndroidMetadata = async ({
|
||||
result.stdout,
|
||||
/INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u,
|
||||
);
|
||||
console.log(
|
||||
"PASS real cold metadata, full generic types and stable overloaded method selection",
|
||||
);
|
||||
console.log(
|
||||
"PASS real folded and mixed static initializers, absent bodies and bounded smali",
|
||||
);
|
||||
console.log(
|
||||
"PASS real raw DEX direct-invoke offsets and cross-DEX source identity",
|
||||
);
|
||||
await verifyAndroidInitializers({
|
||||
apk: resolve(classes, "initializers.apk"),
|
||||
repository,
|
||||
@@ -84,6 +93,22 @@ export const verifyAndroidMetadata = async ({
|
||||
environment,
|
||||
execute,
|
||||
});
|
||||
await verifyAndroidDirectInvokes({
|
||||
apk: resolve(classes, "direct-invokes.apk"),
|
||||
dexEntry: "classes.dex",
|
||||
repository,
|
||||
entrypoint,
|
||||
environment,
|
||||
execute,
|
||||
});
|
||||
await verifyAndroidDirectInvokes({
|
||||
apk: resolve(classes, "direct-invokes-multidex.apk"),
|
||||
dexEntry: "classes2.dex",
|
||||
repository,
|
||||
entrypoint,
|
||||
environment,
|
||||
execute,
|
||||
});
|
||||
} finally {
|
||||
await rm(classes, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createJadxWorkspaceRoot } from "../../../dist/android/JadxWorkspaceRoot.js";
|
||||
|
||||
const source = fileURLToPath(import.meta.url);
|
||||
const marker = "REA synthetic guardian isolation test\n";
|
||||
const exists = (path) =>
|
||||
stat(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
if (process.argv[1] === source && process.argv[2] === "owner") {
|
||||
const root = await createJadxWorkspaceRoot();
|
||||
await writeFile(join(root.path, "synthetic.txt"), marker);
|
||||
process.stdout.write(JSON.stringify({ path: root.path }) + "\n");
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
const bounded = async (promise, milliseconds, label) => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(label)), milliseconds);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const launch = async () => {
|
||||
const child = spawn(process.execPath, [source, "owner"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-8192);
|
||||
});
|
||||
const exit = new Promise((resolve, reject) => {
|
||||
child.once("close", resolve);
|
||||
child.once("error", reject);
|
||||
});
|
||||
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||
try {
|
||||
const ready = await bounded(
|
||||
lines[Symbol.asyncIterator]().next(),
|
||||
20000,
|
||||
`Android guardian owner readiness timed out: ${stderr}`,
|
||||
);
|
||||
assert.equal(ready.done, false, stderr);
|
||||
const { path } = JSON.parse(ready.value);
|
||||
assert.equal(typeof path, "string");
|
||||
assert.equal(await exists(join(path, "synthetic.txt")), true);
|
||||
return { child, exit, path };
|
||||
} catch (cause) {
|
||||
child.kill();
|
||||
await bounded(exit, 15000, "Android guardian owner did not exit");
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async ({ child, exit, path }) => {
|
||||
assert.equal(child.kill(), true);
|
||||
await bounded(exit, 15000, "Android guardian owner did not exit");
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (!(await exists(path))) return;
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error(`Android guardian retained ${path} after owner death`);
|
||||
};
|
||||
|
||||
/** Two independent owners must never delete one another's private workspace. */
|
||||
export async function verifyWindowsGuardianIsolation() {
|
||||
assert.equal(process.platform, "win32");
|
||||
const first = await launch();
|
||||
let second;
|
||||
try {
|
||||
second = await launch();
|
||||
assert.notEqual(first.path, second.path);
|
||||
await stop(first);
|
||||
assert.equal(
|
||||
await exists(join(second.path, "synthetic.txt")),
|
||||
true,
|
||||
"First owner deleted the second owner's workspace",
|
||||
);
|
||||
await stop(second);
|
||||
console.log("PASS Windows Android guardian two-owner cleanup isolation");
|
||||
} finally {
|
||||
for (const owner of [first, second]) {
|
||||
if (owner === undefined) continue;
|
||||
if (owner.child.exitCode === null && owner.child.signalCode === null)
|
||||
owner.child.kill();
|
||||
await bounded(owner.exit, 15000, "Android guardian owner did not exit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveJadxConfigurationInputs,
|
||||
validateJadxConfiguration,
|
||||
} from "../../../dist/android/JadxConfiguration.js";
|
||||
import { verifyWindowsGuardianIsolation } from "./windows-guardian-isolation.mjs";
|
||||
|
||||
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
|
||||
export async function verifyWindowsAndroidLifecycle(options) {
|
||||
@@ -24,15 +25,16 @@ export async function verifyWindowsAndroidLifecycle(options) {
|
||||
await verifyJavaSelection(options.environment);
|
||||
const cancellation = await verifyMcp(options, "cancel");
|
||||
await verifyMcp(options, "disconnect");
|
||||
await verifyCliOwnerExit(options);
|
||||
const abruptWorkspaceRemoved = await verifyCliOwnerExit(options);
|
||||
await verifyWindowsGuardianIsolation();
|
||||
return {
|
||||
mcp_cancelled_engine_exited: true,
|
||||
mcp_cancelled_workspace_removed: true,
|
||||
mcp_ping_and_next_call: true,
|
||||
mcp_disconnect_engine_exited: true,
|
||||
cli_abrupt_owner_exit_engine_exited: true,
|
||||
// A forced Windows process exit cannot execute JavaScript workspace cleanup.
|
||||
cli_abrupt_owner_exit_workspace_cleanup: false,
|
||||
cli_abrupt_owner_exit_workspace_cleanup: abruptWorkspaceRemoved,
|
||||
guardian_two_owner_isolation: true,
|
||||
observed_resources: cancellation,
|
||||
java_home_and_quoted_case_insensitive_path: true,
|
||||
};
|
||||
@@ -161,6 +163,7 @@ async function verifyCliOwnerExit({
|
||||
path,
|
||||
repository,
|
||||
}) {
|
||||
const baseline = new Set(await runtimeRoots());
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-android-package", path, "--format", "json"],
|
||||
@@ -185,7 +188,40 @@ async function verifyCliOwnerExit({
|
||||
await deadline(exited, 15000);
|
||||
await observer.verifyExit();
|
||||
assert.equal(output, "", "Aborted CLI published successful Evidence");
|
||||
console.log("PASS Windows CLI abrupt owner exit: real Java job cleanup");
|
||||
const created = (await runtimeRoots()).filter(
|
||||
(name) => !baseline.has(name),
|
||||
);
|
||||
assert.ok(
|
||||
created.length <= 1,
|
||||
"Concurrent Android workspaces make abrupt cleanup observation ambiguous",
|
||||
);
|
||||
if (created.length === 0) {
|
||||
console.log(
|
||||
"PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const ownedName = created[0];
|
||||
try {
|
||||
await waitFor(
|
||||
async () => !(await runtimeRoots()).includes(ownedName),
|
||||
5000,
|
||||
"Android workspace remained after abrupt owner exit",
|
||||
);
|
||||
} catch (cause) {
|
||||
if (
|
||||
!(cause instanceof Error) ||
|
||||
cause.message !== "Android workspace remained after abrupt owner exit"
|
||||
)
|
||||
throw cause;
|
||||
}
|
||||
const removed = !(await runtimeRoots()).includes(ownedName);
|
||||
console.log(
|
||||
removed
|
||||
? "PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup"
|
||||
: "OBSERVED Windows CLI abrupt owner exit: Java job cleaned, workspace residue remains",
|
||||
);
|
||||
return removed;
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill();
|
||||
await deadline(exited, 15000);
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
} from "node:fs/promises";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import * as nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
@@ -18,247 +11,348 @@ import { firmwareResultSchemas } from "../../../dist/domain/firmware/firmwareAna
|
||||
import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import { hashFirmwareFile } from "../../../dist/firmware/FirmwareFiles.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const entrypoint = resolve(
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { join, resolve, dirname } = nodePath;
|
||||
|
||||
const CALL_TIMEOUT_MS = 150_000;
|
||||
const CONNECT_TIMEOUT_MS = 30_000;
|
||||
const SEVENZIP_TIMEOUT_MS = 10_000;
|
||||
const MAX_STDOUT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const ENTRYPOINT = resolve(
|
||||
process.env.REA_FIRMWARE_TEST_ENTRYPOINT ??
|
||||
join(repository, "scripts/rea.mjs"),
|
||||
join(REPO_ROOT, "scripts/rea.mjs"),
|
||||
);
|
||||
if (process.platform !== "linux")
|
||||
throw new Error(
|
||||
"verify:firmware requires Linux; other hosts are not verified",
|
||||
);
|
||||
for (const variable of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
|
||||
const path = process.env[variable];
|
||||
if (path === undefined)
|
||||
throw new Error(
|
||||
`verify:firmware requires ${variable}; see docs/firmware-analysis.md`,
|
||||
);
|
||||
await access(path);
|
||||
}
|
||||
try {
|
||||
await exec("7z", ["i"], { timeout: 10_000 });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const fixtureRoot = resolve(
|
||||
const FIXTURE_ROOT = resolve(
|
||||
process.env.REA_FIRMWARE_FIXTURE_ROOT ??
|
||||
join(repository, "_reference/firmware-integration/generated"),
|
||||
join(REPO_ROOT, "_reference/firmware-integration/generated"),
|
||||
);
|
||||
await access(join(fixtureRoot, "oracle.json")).catch((cause) => {
|
||||
throw new Error(
|
||||
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
|
||||
{ cause },
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prerequisite checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function checkPrerequisites() {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error(
|
||||
"verify:firmware requires Linux; other hosts are not verified",
|
||||
);
|
||||
}
|
||||
|
||||
for (const name of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
`verify:firmware requires ${name}; see docs/firmware-analysis.md`,
|
||||
);
|
||||
}
|
||||
await fs.access(value);
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync("7z", ["i"], { timeout: SEVENZIP_TIMEOUT_MS });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(join(FIXTURE_ROOT, "oracle.json"));
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await checkPrerequisites();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const oracle = JSON.parse(
|
||||
await readFile(join(fixtureRoot, "oracle.json"), "utf8"),
|
||||
await fs.readFile(join(FIXTURE_ROOT, "oracle.json"), "utf8"),
|
||||
);
|
||||
const path = join(fixtureRoot, "firmware.bin");
|
||||
assert.equal(await hashFirmwareFile(path), oracle.sha256);
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-real-firmware-"));
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, value]) => value !== undefined),
|
||||
const firmwarePath = join(FIXTURE_ROOT, "firmware.bin");
|
||||
assert.equal(await hashFirmwareFile(firmwarePath), oracle.sha256);
|
||||
|
||||
const workDir = await fs.mkdtemp(join(tmpdir(), "rea-real-firmware-"));
|
||||
const baseEnv = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, v]) => v !== undefined),
|
||||
);
|
||||
const client = new Client({ name: "rea-real-firmware-verifier", version: "1" });
|
||||
const cli = async (args, env = environment) => {
|
||||
const response = await exec(
|
||||
const mcpClient = new Client({
|
||||
name: "rea-real-firmware-verifier",
|
||||
version: "1",
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invocation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runCli(args, env = baseEnv) {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[entrypoint, ...args, "--format", "json"],
|
||||
[ENTRYPOINT, ...args, "--format", "json"],
|
||||
{
|
||||
env,
|
||||
cwd: repository,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
cwd: REPO_ROOT,
|
||||
timeout: CALL_TIMEOUT_MS,
|
||||
maxBuffer: MAX_STDOUT_BYTES,
|
||||
},
|
||||
);
|
||||
return parseEvidence(JSON.parse(response.stdout));
|
||||
};
|
||||
const call = async (name, args) => {
|
||||
const response = await client.callTool(
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
}
|
||||
|
||||
async function callTool(name, args) {
|
||||
const result = await mcpClient.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 150_000 },
|
||||
{ timeout: CALL_TIMEOUT_MS },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(response.structuredContent !== undefined);
|
||||
return response.structuredContent;
|
||||
};
|
||||
const verifyRegions = (evidence) => {
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
assert.ok(result.structuredContent !== undefined);
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result verifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function verifyRegions(evidence) {
|
||||
assert.equal(evidence.subject.digest.sha256, oracle.sha256);
|
||||
const result = firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||
|
||||
const parsed = firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const gzip = result.regions.find(
|
||||
(region) => region.signature === "gzip" && region.offset === oracle.offset,
|
||||
|
||||
const gzipRegion = parsed.regions.find(
|
||||
(r) => r.signature === "gzip" && r.offset === oracle.offset,
|
||||
);
|
||||
assert.ok(
|
||||
gzip !== undefined,
|
||||
gzipRegion !== undefined,
|
||||
"Binwalk must find the independently generated gzip offset",
|
||||
);
|
||||
assert.equal(gzip.reported_size, oracle.length);
|
||||
assert.equal(gzip.size_basis, "provider_reported_validation_unknown");
|
||||
return result.regions.map(
|
||||
({ provider_id: _provider_id, ...region }) => region,
|
||||
);
|
||||
};
|
||||
const verifyFiles = async (evidence) => {
|
||||
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||
assert.equal(gzipRegion.reported_size, oracle.length);
|
||||
assert.equal(gzipRegion.size_basis, "provider_reported_validation_unknown");
|
||||
|
||||
// Strip provider-specific ids so CLI and MCP results compare equal.
|
||||
return parsed.regions.map((region) => {
|
||||
const { provider_id: _ignored, ...rest } = region;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyFiles(evidence) {
|
||||
const parsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assert.ok(result.chunks.some((chunk) => chunk.handler === "gzip"));
|
||||
|
||||
assert.ok(parsed.chunks.some((c) => c.handler === "gzip"));
|
||||
|
||||
for (const [suffix, expected] of Object.entries(oracle.files)) {
|
||||
const file = result.files.find((candidate) =>
|
||||
candidate.relative_path.endsWith(`/${suffix}`),
|
||||
const entry = parsed.files.find((f) =>
|
||||
f.relative_path.endsWith(`/${suffix}`),
|
||||
);
|
||||
assert.ok(file !== undefined, `Missing extracted ${suffix}`);
|
||||
assert.equal(file.sha256, expected.sha256);
|
||||
assert.equal(file.size, expected.size);
|
||||
assert.equal(await hashFirmwareFile(file.path), expected.sha256);
|
||||
assert.equal(file.original_file_range, null);
|
||||
assert.equal(file.runtime_address, null);
|
||||
assert.ok(entry !== undefined, `Missing extracted ${suffix}`);
|
||||
assert.equal(entry.sha256, expected.sha256);
|
||||
assert.equal(entry.size, expected.size);
|
||||
assert.equal(await hashFirmwareFile(entry.path), expected.sha256);
|
||||
assert.equal(entry.original_file_range, null);
|
||||
assert.equal(entry.runtime_address, null);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
const inspected = await cli(["inspect-firmware-regions", path]);
|
||||
const cliRegions = verifyRegions(inspected);
|
||||
const extracted = await cli([
|
||||
"extract-firmware",
|
||||
path,
|
||||
join(root, "cli-output"),
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const toContentIdentity = (parsed) =>
|
||||
parsed.files.map(({ relative_path, sha256, size }) => ({
|
||||
relative_path,
|
||||
sha256,
|
||||
size,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verification stages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function stageCli() {
|
||||
const regionsEvidence = await runCli([
|
||||
"inspect-firmware-regions",
|
||||
firmwarePath,
|
||||
]);
|
||||
const cliFiles = await verifyFiles(extracted);
|
||||
assert.equal(cliFiles.coverage, "partial");
|
||||
const regions = verifyRegions(regionsEvidence);
|
||||
|
||||
const extractEvidence = await runCli([
|
||||
"extract-firmware",
|
||||
firmwarePath,
|
||||
join(workDir, "cli-output"),
|
||||
]);
|
||||
const files = await verifyFiles(extractEvidence);
|
||||
|
||||
assert.equal(files.coverage, "partial");
|
||||
assert.ok(
|
||||
cliFiles.chunks.some(
|
||||
(chunk) =>
|
||||
chunk.handler === null &&
|
||||
chunk.range.offset === 0 &&
|
||||
chunk.range.length === 64,
|
||||
files.chunks.some(
|
||||
(c) =>
|
||||
c.handler === null && c.range.offset === 0 && c.range.length === 64,
|
||||
),
|
||||
);
|
||||
await client.connect(
|
||||
new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
}),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const inspectedMcp = await call("inspect_firmware_regions", { path });
|
||||
const mcpEvidence = parseEvidence(inspectedMcp);
|
||||
assert.deepEqual(verifyRegions(mcpEvidence), cliRegions);
|
||||
const extractedMcp = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "mcp-output"),
|
||||
|
||||
return { regions, files };
|
||||
}
|
||||
|
||||
async function connectMcp() {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [ENTRYPOINT, "mcp"],
|
||||
cwd: REPO_ROOT,
|
||||
env: baseEnv,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp));
|
||||
const contentIdentity = (result) =>
|
||||
result.files.map(({ relative_path, sha256, size }) => ({
|
||||
relative_path,
|
||||
sha256,
|
||||
size,
|
||||
}));
|
||||
assert.deepEqual(contentIdentity(mcpFiles), contentIdentity(cliFiles));
|
||||
const selected = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "selected-output"),
|
||||
await mcpClient.connect(transport, { timeout: CONNECT_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function stageMcp(cli) {
|
||||
const regionsRaw = await callTool("inspect_firmware_regions", {
|
||||
path: firmwarePath,
|
||||
});
|
||||
assert.deepEqual(verifyRegions(parseEvidence(regionsRaw)), cli.regions);
|
||||
|
||||
const extractRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "mcp-output"),
|
||||
});
|
||||
const mcpFiles = await verifyFiles(parseEvidence(extractRaw));
|
||||
assert.deepEqual(toContentIdentity(mcpFiles), toContentIdentity(cli.files));
|
||||
|
||||
const rangedRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "selected-output"),
|
||||
range: { offset: oracle.offset, length: oracle.length },
|
||||
});
|
||||
const selectedFiles = await verifyFiles(parseEvidence(selected));
|
||||
assert.equal(selectedFiles.selection.offset, oracle.offset);
|
||||
assert.equal(selectedFiles.selection.length, oracle.length);
|
||||
const depth = await call("extract_firmware", {
|
||||
path,
|
||||
output_directory: join(root, "depth-output"),
|
||||
const ranged = await verifyFiles(parseEvidence(rangedRaw));
|
||||
assert.equal(ranged.selection.offset, oracle.offset);
|
||||
assert.equal(ranged.selection.length, oracle.length);
|
||||
|
||||
const depthRaw = await callTool("extract_firmware", {
|
||||
path: firmwarePath,
|
||||
output_directory: join(workDir, "depth-output"),
|
||||
max_depth: 1,
|
||||
});
|
||||
const depthResult = firmwareResultSchemas.extract_firmware.parse(
|
||||
depth.normalized_result,
|
||||
const depthParsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
depthRaw.normalized_result,
|
||||
);
|
||||
assert.equal(depthResult.coverage, "partial");
|
||||
assert.ok(depthResult.depth_limited_paths.length > 0);
|
||||
assert.equal(depthParsed.coverage, "partial");
|
||||
assert.ok(depthParsed.depth_limited_paths.length > 0);
|
||||
|
||||
console.log(
|
||||
"PASS real Binwalk/Unblob: CLI, MCP, independent offsets/digests, range extraction, unknown chunks and depth limits",
|
||||
);
|
||||
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") {
|
||||
const ext4 = join(fixtureRoot, "ext4.bin");
|
||||
await access(ext4);
|
||||
const response = await call("extract_firmware", {
|
||||
path: ext4,
|
||||
output_directory: join(root, "ext4-output"),
|
||||
});
|
||||
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||
response.normalized_result,
|
||||
|
||||
return mcpFiles;
|
||||
}
|
||||
|
||||
async function stageExt4() {
|
||||
const ext4Path = join(FIXTURE_ROOT, "ext4.bin");
|
||||
await fs.access(ext4Path);
|
||||
|
||||
const raw = await callTool("extract_firmware", {
|
||||
path: ext4Path,
|
||||
output_directory: join(workDir, "ext4-output"),
|
||||
});
|
||||
const parsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
raw.normalized_result,
|
||||
);
|
||||
assert.ok(parsed.chunks.some((c) => c.handler === "extfs"));
|
||||
assert.equal(parsed.coverage, "complete");
|
||||
|
||||
for (const [suffix, expected] of Object.entries(oracle.files)) {
|
||||
const match = parsed.files.find((f) =>
|
||||
f.relative_path.endsWith(`/${suffix}`),
|
||||
);
|
||||
assert.ok(result.chunks.some((chunk) => chunk.handler === "extfs"));
|
||||
assert.equal(result.coverage, "complete");
|
||||
for (const [suffix, expected] of Object.entries(oracle.files))
|
||||
assert.equal(
|
||||
result.files.find((file) => file.relative_path.endsWith(`/${suffix}`))
|
||||
?.sha256,
|
||||
expected.sha256,
|
||||
);
|
||||
console.log("PASS real ext4 extraction with debugfs; no mounts");
|
||||
// Preserve the process-ownership utility while withholding all extractors.
|
||||
const withoutExtractors = join(root, "without-extractors");
|
||||
await access("/bin/ps");
|
||||
await mkdir(withoutExtractors);
|
||||
await symlink("/bin/ps", join(withoutExtractors, "ps"));
|
||||
const missing = await cli(
|
||||
["extract-firmware", ext4, join(root, "missing-extractor-output")],
|
||||
{ ...environment, PATH: withoutExtractors },
|
||||
);
|
||||
const partial = firmwareResultSchemas.extract_firmware.parse(
|
||||
missing.normalized_result,
|
||||
);
|
||||
assert.equal(partial.coverage, "partial");
|
||||
assert.ok(JSON.stringify(partial.diagnostics).includes("debugfs"));
|
||||
console.log(
|
||||
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
|
||||
);
|
||||
assert.equal(match?.sha256, expected.sha256);
|
||||
}
|
||||
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1") {
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||
throw new Error(
|
||||
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
|
||||
);
|
||||
const child = mcpFiles.files.find((file) =>
|
||||
file.relative_path.endsWith("/usr/bin/probe"),
|
||||
);
|
||||
assert.ok(child !== undefined);
|
||||
await call("open_binary", { path: child.path, provider_id: "ghidra" });
|
||||
const dossier = await call("analyze_function", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
const evidence = parseEvidence(dossier);
|
||||
assert.equal(
|
||||
evidence.subject.digest.sha256,
|
||||
oracle.files["usr/bin/probe"].sha256,
|
||||
);
|
||||
const pseudocode = await call("procedure_pseudo_code", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
assert.equal(parseEvidence(pseudocode).provider.id, "ghidra");
|
||||
assert.ok(
|
||||
JSON.stringify(evidence.normalized_result).includes("firmware_probe"),
|
||||
);
|
||||
await call("close_binary", {});
|
||||
console.log(
|
||||
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
|
||||
console.log("PASS real ext4 extraction with debugfs; no mounts");
|
||||
|
||||
// Keep the process-ownership utility available but hide every extractor.
|
||||
const emptyBin = join(workDir, "without-extractors");
|
||||
await fs.access("/bin/ps");
|
||||
await fs.mkdir(emptyBin);
|
||||
await fs.symlink("/bin/ps", join(emptyBin, "ps"));
|
||||
|
||||
const degraded = await runCli(
|
||||
["extract-firmware", ext4Path, join(workDir, "missing-extractor-output")],
|
||||
{ ...baseEnv, PATH: emptyBin },
|
||||
);
|
||||
const degradedParsed = firmwareResultSchemas.extract_firmware.parse(
|
||||
degraded.normalized_result,
|
||||
);
|
||||
assert.equal(degradedParsed.coverage, "partial");
|
||||
assert.ok(JSON.stringify(degradedParsed.diagnostics).includes("debugfs"));
|
||||
console.log(
|
||||
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
|
||||
);
|
||||
}
|
||||
|
||||
async function stageGhidra(mcpFiles) {
|
||||
if (process.env.GHIDRA_INSTALL_DIR === undefined) {
|
||||
throw new Error(
|
||||
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
|
||||
);
|
||||
}
|
||||
|
||||
const probe = mcpFiles.files.find((f) =>
|
||||
f.relative_path.endsWith("/usr/bin/probe"),
|
||||
);
|
||||
assert.ok(probe !== undefined);
|
||||
|
||||
await callTool("open_binary", { path: probe.path, provider_id: "ghidra" });
|
||||
|
||||
const dossierRaw = await callTool("analyze_function", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
const dossier = parseEvidence(dossierRaw);
|
||||
assert.equal(
|
||||
await hashFirmwareFile(path),
|
||||
dossier.subject.digest.sha256,
|
||||
oracle.files["usr/bin/probe"].sha256,
|
||||
);
|
||||
|
||||
const pseudoRaw = await callTool("procedure_pseudo_code", {
|
||||
procedure: "firmware_probe",
|
||||
});
|
||||
assert.equal(parseEvidence(pseudoRaw).provider.id, "ghidra");
|
||||
assert.ok(
|
||||
JSON.stringify(dossier.normalized_result).includes("firmware_probe"),
|
||||
);
|
||||
|
||||
await callTool("close_binary", {});
|
||||
console.log(
|
||||
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
try {
|
||||
const cli = await stageCli();
|
||||
await connectMcp();
|
||||
const mcpFiles = await stageMcp(cli);
|
||||
|
||||
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") await stageExt4();
|
||||
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1")
|
||||
await stageGhidra(mcpFiles);
|
||||
|
||||
assert.equal(
|
||||
await hashFirmwareFile(firmwarePath),
|
||||
oracle.sha256,
|
||||
"Original firmware bytes must remain unchanged",
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mcpClient.close();
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -810,6 +810,36 @@ exit 1`,
|
||||
expect(result.error.message).toContain("overwrite");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["offline", false, "error: device offline"],
|
||||
["unauthorized", false, "error: device unauthorized"],
|
||||
["offline", true, "error: device offline"],
|
||||
] as const)(
|
||||
"reports the %s device state when the push existence probe cannot run (overwrite %s)",
|
||||
async (state, overwrite, stderr) => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ]; then printf '%s\\n' "$3" >> "${stubRoot}/calls"; printf '%s\\n' "${stderr}" >&2; exit 1; fi`,
|
||||
);
|
||||
const localFile = join(root, "payload.bin");
|
||||
await writeFile(localFile, "x");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "push_adb_file",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
local_path: localFile,
|
||||
device_path: "/data/local/tmp/payload.bin",
|
||||
overwrite,
|
||||
},
|
||||
});
|
||||
// Existence is unknown, so nothing is claimed about the path and no push runs.
|
||||
expect(projected).toMatchObject({ code: "unsupported_target" });
|
||||
expect(projected.message).toContain(state);
|
||||
expect(projected.message).not.toContain("already exists");
|
||||
expect(await readFile(join(root, "calls"), "utf8")).toBe("shell\n");
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a local push source that does not exist", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
|
||||
+8
-10
@@ -1623,17 +1623,15 @@ export class AdbProvider {
|
||||
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
|
||||
signal,
|
||||
);
|
||||
let deviceExists: boolean;
|
||||
if (existsProbe.ok) deviceExists = true;
|
||||
else if (existsProbe.error instanceof AnalysisProtocolError)
|
||||
deviceExists = false;
|
||||
else if (existsProbe.error instanceof AnalysisTimeoutError)
|
||||
// `test -e` exits nonzero with no adb diagnosis when the path is absent.
|
||||
// Any other probe failure leaves existence unknown, so it is reported as
|
||||
// is rather than read as an existing path or followed by a push.
|
||||
if (
|
||||
!existsProbe.ok &&
|
||||
!(existsProbe.error instanceof AnalysisProtocolError)
|
||||
)
|
||||
return existsProbe;
|
||||
else if (existsProbe.error instanceof AnalysisCancelledError)
|
||||
return existsProbe;
|
||||
else if (existsProbe.error instanceof AnalysisResourceConstraintError)
|
||||
return existsProbe;
|
||||
else deviceExists = true; // authorization/refusal states surface on push instead
|
||||
const deviceExists = existsProbe.ok;
|
||||
if (deviceExists && !input.overwrite)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
|
||||
@@ -45,7 +45,7 @@ const methodsOf = (summary: ClassSummary) => {
|
||||
reported_signature: method.signature,
|
||||
overload_index: index,
|
||||
is_constructor: method.is_constructor,
|
||||
dex_descriptor: null,
|
||||
dex_descriptor: method.dex_descriptor,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -314,11 +314,25 @@ const traceReferences = async (
|
||||
): Promise<JsonValue> => {
|
||||
const methodName = request.input.method_name;
|
||||
const candidates = matchingMethods(summary, request);
|
||||
if (candidates.length > 1)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
request.operation,
|
||||
`References require a unique method name; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`,
|
||||
if (methodName === undefined && request.input.overload_index !== undefined)
|
||||
inputFailure(
|
||||
request,
|
||||
"overload_index",
|
||||
"Method name is required for an overload index.",
|
||||
);
|
||||
if (candidates.length > 1 && request.input.overload_index === undefined)
|
||||
inputFailure(
|
||||
request,
|
||||
"overload_index",
|
||||
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`,
|
||||
);
|
||||
const index = request.input.overload_index ?? 0;
|
||||
const selected = methodName === undefined ? undefined : candidates[index];
|
||||
if (methodName !== undefined && selected === undefined)
|
||||
inputFailure(
|
||||
request,
|
||||
"overload_index",
|
||||
`Index ${index} is outside the ${candidates.length} matching overloads.`,
|
||||
);
|
||||
const expectedTarget =
|
||||
methodName === undefined
|
||||
@@ -329,7 +343,9 @@ const traceReferences = async (
|
||||
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
|
||||
{
|
||||
class_name: summary.full_name,
|
||||
...(methodName === undefined ? {} : { method_name: methodName }),
|
||||
...(methodName === undefined
|
||||
? {}
|
||||
: { method_name: methodName, overload_index: index }),
|
||||
limit: 2_147_483_647,
|
||||
resolve_line: false,
|
||||
},
|
||||
@@ -337,6 +353,9 @@ const traceReferences = async (
|
||||
);
|
||||
if (
|
||||
xrefs.target !== expectedTarget ||
|
||||
(selected !== undefined &&
|
||||
(xrefs.overload_index !== index ||
|
||||
xrefs.reported_signature !== selected.reported_signature)) ||
|
||||
xrefs.total !== xrefs.count ||
|
||||
xrefs.count !== xrefs.items.length
|
||||
)
|
||||
@@ -348,6 +367,9 @@ const traceReferences = async (
|
||||
engine,
|
||||
class_name: summary.full_name,
|
||||
method_name: methodName ?? null,
|
||||
overload_index: selected?.overload_index ?? null,
|
||||
reported_signature: selected?.reported_signature ?? null,
|
||||
dex_descriptor: selected?.dex_descriptor ?? null,
|
||||
total: xrefs.total,
|
||||
references: xrefs.items.map((reference) => ({
|
||||
kind: reference.kind,
|
||||
@@ -357,7 +379,10 @@ const traceReferences = async (
|
||||
top_class: reference.top_class,
|
||||
source_line: null,
|
||||
dex_offset: null,
|
||||
direct_invokes: reference.direct_invokes ?? null,
|
||||
direct_invokes_exhaustive: reference.direct_invokes_exhaustive ?? null,
|
||||
})),
|
||||
coverage: "complete",
|
||||
reference_unit: "using_node",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js";
|
||||
|
||||
const count = z.number().int().nonnegative();
|
||||
/** Selector failures authored by REA's bridge, separate from arbitrary engine errors. */
|
||||
@@ -50,6 +51,7 @@ export const jadxClassSchema = z.object({
|
||||
z.object({
|
||||
name: z.string(),
|
||||
signature: z.string(),
|
||||
dex_descriptor: dexDescriptorSchema,
|
||||
is_constructor: z.boolean(),
|
||||
def_pos: z.number().int(),
|
||||
}),
|
||||
@@ -72,6 +74,8 @@ export const jadxMethodSchema = z.object({
|
||||
});
|
||||
export const jadxXrefsSchema = z.object({
|
||||
target: z.string(),
|
||||
overload_index: count.optional(),
|
||||
reported_signature: z.string().optional(),
|
||||
total: count,
|
||||
count,
|
||||
resolve_line: z.literal(false),
|
||||
@@ -83,6 +87,18 @@ export const jadxXrefsSchema = z.object({
|
||||
containing_class: z.string(),
|
||||
top_class: z.string(),
|
||||
def_pos: z.number().int(),
|
||||
direct_invokes_exhaustive: z.boolean().optional(),
|
||||
direct_invokes: z
|
||||
.array(
|
||||
z.object({
|
||||
dex_entry: z.string().min(1),
|
||||
source_raw_method_id: z.string().min(1),
|
||||
dex_code_unit_offset: count,
|
||||
dex_method_index: count,
|
||||
opcode: z.string().regex(/^INVOKE_/u),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
+23
-12
@@ -18,7 +18,10 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
createJadxWorkspaceRoot,
|
||||
type JadxWorkspaceRoot,
|
||||
} from "./JadxWorkspaceRoot.js";
|
||||
import {
|
||||
snapshotAndroidEngine,
|
||||
snapshotAndroidTarget,
|
||||
@@ -152,7 +155,7 @@ const hashAndroidSource = async (
|
||||
|
||||
interface PendingCleanup {
|
||||
session: JadxSession | undefined;
|
||||
root: PrivateRuntimeRoot | undefined;
|
||||
root: JadxWorkspaceRoot | undefined;
|
||||
readonly previousError: AnalysisError | undefined;
|
||||
}
|
||||
|
||||
@@ -211,7 +214,7 @@ const cleanup = async (
|
||||
|
||||
interface RetainedSession {
|
||||
readonly key: string;
|
||||
readonly root: PrivateRuntimeRoot;
|
||||
readonly root: JadxWorkspaceRoot;
|
||||
readonly session: JadxSession;
|
||||
readonly snapshot: string;
|
||||
readonly jarHash: string;
|
||||
@@ -325,7 +328,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
options?.signal === undefined
|
||||
? timeout
|
||||
: AbortSignal.any([options.signal, timeout]);
|
||||
let root: PrivateRuntimeRoot | undefined;
|
||||
let root: JadxWorkspaceRoot | undefined;
|
||||
let session: JadxSession | undefined;
|
||||
try {
|
||||
if (target.format !== "apk")
|
||||
@@ -389,20 +392,24 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
request.operation,
|
||||
signal,
|
||||
);
|
||||
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
|
||||
root = await createJadxWorkspaceRoot();
|
||||
const workspaceSignal =
|
||||
root.failureSignal === undefined
|
||||
? signal
|
||||
: AbortSignal.any([signal, root.failureSignal]);
|
||||
const engine = await snapshotAndroidEngine(
|
||||
configuration.jar,
|
||||
jarHash,
|
||||
root.path,
|
||||
request.operation,
|
||||
signal,
|
||||
workspaceSignal,
|
||||
);
|
||||
const snapshot = await snapshotAndroidTarget(
|
||||
target.path,
|
||||
target.sha256,
|
||||
root.path,
|
||||
request.operation,
|
||||
signal,
|
||||
workspaceSignal,
|
||||
);
|
||||
const bridge = join(root.path, "ReaJadxBridge.java");
|
||||
await copyFile(BRIDGE_SOURCE, bridge);
|
||||
@@ -410,7 +417,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
const copiedBridgeHash = await hashAndroidSource(
|
||||
bridge,
|
||||
request.operation,
|
||||
signal,
|
||||
workspaceSignal,
|
||||
);
|
||||
if (copiedBridgeHash !== bridgeHash)
|
||||
throw androidSourceChanged(
|
||||
@@ -418,7 +425,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
BRIDGE_SOURCE,
|
||||
undefined,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
workspaceSignal.throwIfAborted();
|
||||
session = new JadxSession(
|
||||
{
|
||||
command: configuration.java,
|
||||
@@ -430,7 +437,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
],
|
||||
cwd: root.path,
|
||||
hostEnvironment: this.environment,
|
||||
signal,
|
||||
signal: workspaceSignal,
|
||||
},
|
||||
this.launcher,
|
||||
);
|
||||
@@ -445,6 +452,10 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
}
|
||||
root = retained.root;
|
||||
session = retained.session;
|
||||
const activeSignal =
|
||||
root.failureSignal === undefined
|
||||
? signal
|
||||
: AbortSignal.any([signal, root.failureSignal]);
|
||||
const { snapshot } = retained;
|
||||
const outcome = ok(
|
||||
await session.execute({
|
||||
@@ -453,10 +464,10 @@ export class JadxProvider implements AndroidAnalysisPort {
|
||||
snapshot,
|
||||
jarHash: retained.jarHash,
|
||||
bridgeHash: retained.bridgeHash,
|
||||
signal,
|
||||
signal: activeSignal,
|
||||
}),
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
activeSignal.throwIfAborted();
|
||||
this.#retained = retained;
|
||||
return outcome;
|
||||
} catch (cause) {
|
||||
|
||||
@@ -25,7 +25,8 @@ export const JADX_PROVIDER_IDENTITY = Object.freeze({
|
||||
/** Audited engine coverage, kept outside portable Android semantics. */
|
||||
export const JADX_LIMITATIONS = [
|
||||
"Static decompiler observations do not establish runtime behavior; the APK is never executed.",
|
||||
"Method signatures use provider display types; exact DEX descriptors and instruction offsets are unavailable. Overload indices are local to this engine and artifact.",
|
||||
"Method signatures use provider display types; DEX prototypes come from frozen JADX metadata. Display names may be aliases, and overload indices are local to this engine and artifact.",
|
||||
"Class inventories use parsed metadata before code generation, including synthetic members that may be omitted from decompiled source. Source positions remain unknown.",
|
||||
"Incoming reference totals count JADX using nodes. Direct invoke entries within a using method report raw DEX code-unit offsets and method indexes, but coverage is limited to those nodes and directly matched invokes; it does not establish every possible dynamic or reflective call site.",
|
||||
"Manifest summary fields are extracted by the upstream engine; decoded XML is retained for verification. APK signatures, split APKs, native libraries and Android runtime capture are not verified by this provider.",
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { win32 } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
|
||||
/** Match the native owner's ordinary spelling only for extended local drive paths. */
|
||||
const localDrivePath = (path: string): string =>
|
||||
path.replace(/^\\\\\?\\(?=[A-Za-z]:\\)/u, "");
|
||||
|
||||
const guardianSource = fileURLToPath(
|
||||
new URL("../../scripts/rea-android-workspace-guardian.mjs", import.meta.url),
|
||||
);
|
||||
const readySchema = z.strictObject({
|
||||
path: z.string(),
|
||||
volumeSerial: z.string().regex(/^[a-f0-9]{16}$/u),
|
||||
fileId: z.string().regex(/^[a-f0-9]{32}$/u),
|
||||
privateDacl: z.literal(true),
|
||||
});
|
||||
|
||||
/** The Android provider needs a path and a cleanup owner, regardless of host. */
|
||||
export interface JadxWorkspaceRoot {
|
||||
readonly path: string;
|
||||
readonly failureSignal?: AbortSignal;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
type GuardianExit = {
|
||||
readonly code: number | null;
|
||||
readonly signal: NodeJS.Signals | null;
|
||||
readonly error?: Error;
|
||||
};
|
||||
|
||||
const boundedWait = async <T>(
|
||||
promise: Promise<T>,
|
||||
milliseconds: number,
|
||||
label: string,
|
||||
): Promise<T> => {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(label)), milliseconds);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/** A detached guardian retains native cleanup ownership without keeping its caller alive. */
|
||||
const releaseGuardianReferences = (
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
): void => {
|
||||
child.stdin.destroy();
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
child.unref();
|
||||
};
|
||||
|
||||
class WindowsJadxWorkspaceRoot implements JadxWorkspaceRoot {
|
||||
readonly #failure = new AbortController();
|
||||
readonly failureSignal = this.#failure.signal;
|
||||
readonly #child: ChildProcessWithoutNullStreams;
|
||||
readonly #exit: Promise<GuardianExit>;
|
||||
readonly #diagnostics: () => string;
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
|
||||
private constructor(
|
||||
readonly path: string,
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
exit: Promise<GuardianExit>,
|
||||
diagnostics: () => string,
|
||||
) {
|
||||
this.#child = child;
|
||||
this.#exit = exit;
|
||||
this.#diagnostics = diagnostics;
|
||||
void this.#exit.then((result) => {
|
||||
if (!this.#closing)
|
||||
this.#failure.abort(
|
||||
new Error(
|
||||
`Android workspace guardian exited before cleanup: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async create(): Promise<WindowsJadxWorkspaceRoot> {
|
||||
const parent = tmpdir();
|
||||
const child = spawn(process.execPath, [guardianSource, parent], {
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
SystemRoot: process.env.SystemRoot,
|
||||
WINDIR: process.env.WINDIR,
|
||||
TEMP: parent,
|
||||
TMP: parent,
|
||||
},
|
||||
});
|
||||
let diagnostics = "";
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
diagnostics = (diagnostics + chunk.toString()).slice(-8192);
|
||||
});
|
||||
const exit = new Promise<GuardianExit>((resolve) => {
|
||||
child.once("error", (error: Error) =>
|
||||
resolve({ code: null, signal: null, error }),
|
||||
);
|
||||
child.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
const ready = new Promise<string>((resolve, reject) => {
|
||||
let frame = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
frame += chunk.toString();
|
||||
if (frame.length > 4096) {
|
||||
reject(
|
||||
new Error("Android workspace guardian readiness exceeded 4 KiB"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const end = frame.indexOf("\n");
|
||||
if (end !== -1) resolve(frame.slice(0, end));
|
||||
});
|
||||
child.stdout.once("error", reject);
|
||||
});
|
||||
try {
|
||||
const line = await boundedWait(
|
||||
Promise.race([
|
||||
ready,
|
||||
exit.then((result) => {
|
||||
throw new Error(
|
||||
`Android workspace guardian exited during startup: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}; ${diagnostics}`,
|
||||
);
|
||||
}),
|
||||
]),
|
||||
15000,
|
||||
"Android workspace guardian startup timed out",
|
||||
);
|
||||
const observation = readySchema.parse(JSON.parse(line));
|
||||
if (
|
||||
win32.dirname(localDrivePath(observation.path)).toLowerCase() !==
|
||||
localDrivePath(win32.resolve(parent)).toLowerCase() ||
|
||||
!/^rea-android-[a-f0-9]{32}$/u.test(win32.basename(observation.path))
|
||||
)
|
||||
throw new Error(
|
||||
"Android workspace guardian returned an unexpected root",
|
||||
);
|
||||
child.stdout.resume();
|
||||
return new WindowsJadxWorkspaceRoot(
|
||||
observation.path,
|
||||
child,
|
||||
exit,
|
||||
() => diagnostics,
|
||||
);
|
||||
} catch (cause) {
|
||||
if (!child.stdin.destroyed) child.stdin.end();
|
||||
try {
|
||||
await boundedWait(
|
||||
exit,
|
||||
30000,
|
||||
"Android workspace guardian did not settle after startup failure",
|
||||
);
|
||||
} catch (cleanupFailure) {
|
||||
throw new AggregateError(
|
||||
[cause, cleanupFailure],
|
||||
"Android workspace guardian startup and cleanup failed",
|
||||
);
|
||||
} finally {
|
||||
releaseGuardianReferences(child);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
this.#closePromise ??= this.#finish().catch((cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async #finish(): Promise<void> {
|
||||
if (!this.#child.stdin.destroyed) this.#child.stdin.end();
|
||||
let result: GuardianExit;
|
||||
try {
|
||||
result = await boundedWait(
|
||||
this.#exit,
|
||||
120000,
|
||||
`Android workspace guardian cleanup timed out for ${this.path}`,
|
||||
);
|
||||
} catch (cause) {
|
||||
releaseGuardianReferences(this.#child);
|
||||
throw cause;
|
||||
}
|
||||
if (result.error !== undefined || result.code !== 0)
|
||||
throw new Error(
|
||||
`Android workspace guardian cleanup failed for ${this.path}: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}; ${this.#diagnostics()}`,
|
||||
);
|
||||
try {
|
||||
await lstat(this.path);
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
return;
|
||||
throw cause;
|
||||
}
|
||||
throw new Error(
|
||||
`Android workspace guardian exited but retained ${this.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** POSIX retains its in-process root; Windows keeps a detached native owner. */
|
||||
export const createJadxWorkspaceRoot = (): Promise<JadxWorkspaceRoot> =>
|
||||
process.platform === "win32"
|
||||
? WindowsJadxWorkspaceRoot.create()
|
||||
: PrivateRuntimeRoot.create({ prefix: "rea-android-" });
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
APKTOOL_COMMAND_ENV,
|
||||
@@ -10,29 +10,43 @@ import {
|
||||
resolveApktoolCommand,
|
||||
} from "./ApktoolConfiguration.js";
|
||||
|
||||
const lookup = vi.hoisted(() => ({ stat: vi.fn() }));
|
||||
vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:fs/promises")>()),
|
||||
stat: lookup.stat,
|
||||
}));
|
||||
beforeEach(() => {
|
||||
lookup.stat
|
||||
.mockReset()
|
||||
.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" }));
|
||||
});
|
||||
|
||||
describe("resolveApktoolCommand", () => {
|
||||
it("prefers an absolute REA_APKTOOL_COMMAND", () => {
|
||||
it("prefers an absolute REA_APKTOOL_COMMAND", async () => {
|
||||
expect(
|
||||
resolveApktoolCommand({
|
||||
[APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool",
|
||||
}),
|
||||
await resolveApktoolCommand(
|
||||
{
|
||||
[APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool",
|
||||
},
|
||||
"linux",
|
||||
),
|
||||
).toEqual({
|
||||
command: "/usr/local/bin/apktool",
|
||||
commandSource: "environment",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to PATH resolution without the environment key", () => {
|
||||
expect(resolveApktoolCommand({})).toEqual({
|
||||
it("leaves POSIX PATH lookup to process creation", async () => {
|
||||
expect(await resolveApktoolCommand({}, "linux")).toEqual({
|
||||
command: "apktool",
|
||||
commandSource: "path",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a relative environment path explicitly", () => {
|
||||
expect(() =>
|
||||
it("rejects a relative environment path explicitly", async () => {
|
||||
await expect(
|
||||
resolveApktoolCommand({ [APKTOOL_COMMAND_ENV]: "bin/apktool" }),
|
||||
).toThrow(/must be an absolute path/u);
|
||||
).rejects.toThrow(/must be an absolute path/u);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,3 +133,250 @@ describe("apktoolInvocation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows PATH launcher selection", () => {
|
||||
const environment = {
|
||||
Path: '"C:\\Selected Tools";C:\\Other Tools',
|
||||
PathExt: ".EXE;.CMD;.BAT",
|
||||
ComSpec: "C:\\Windows\\System32\\cmd.exe",
|
||||
NoDefaultCurrentDirectoryInExePath: "",
|
||||
};
|
||||
const files = (...paths: string[]) => {
|
||||
const selected = new Set(paths.map((path) => path.toLowerCase()));
|
||||
lookup.stat.mockImplementation(async (path: string) => {
|
||||
if (selected.has(path.toLowerCase())) return { isFile: () => true };
|
||||
throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
});
|
||||
};
|
||||
|
||||
it.each([".BAT", ".CMD"])(
|
||||
"resolves a default %s launcher before choosing cmd.exe for version and decode",
|
||||
async (extension) => {
|
||||
const command = `C:\\Selected Tools\\apktool${extension}`;
|
||||
files(command);
|
||||
const selection = await resolveApktoolCommand(environment, "win32");
|
||||
expect(selection).toEqual({ command, commandSource: "path" });
|
||||
for (const arguments_ of [
|
||||
["--version"],
|
||||
["d", "-o", "C:\\output folder", "C:\\target.apk"],
|
||||
]) {
|
||||
const invocation = apktoolInvocation(
|
||||
selection,
|
||||
arguments_,
|
||||
environment,
|
||||
"win32",
|
||||
);
|
||||
expect(invocation).toEqual({
|
||||
command: environment.ComSpec,
|
||||
arguments: [
|
||||
"/d",
|
||||
"/e:on",
|
||||
"/v:off",
|
||||
"/s",
|
||||
"/c",
|
||||
`"${[command, ...arguments_].map((token) => `"${token}"`).join(" ")}"`,
|
||||
],
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("honors PATH directory order before extension order", async () => {
|
||||
files("C:\\Selected Tools\\apktool.CMD", "C:\\Other Tools\\apktool.EXE");
|
||||
expect(await resolveApktoolCommand(environment, "win32")).toMatchObject({
|
||||
command: "C:\\Selected Tools\\apktool.CMD",
|
||||
});
|
||||
});
|
||||
|
||||
it("honors the selected PATHEXT ordering", async () => {
|
||||
files("C:\\Selected Tools\\apktool.CMD", "C:\\Selected Tools\\apktool.EXE");
|
||||
expect(
|
||||
await resolveApktoolCommand(
|
||||
{ ...environment, PathExt: ".CMD;.EXE" },
|
||||
"win32",
|
||||
),
|
||||
).toMatchObject({ command: "C:\\Selected Tools\\apktool.CMD" });
|
||||
});
|
||||
|
||||
it("keeps native executable arguments outside cmd.exe interpretation", async () => {
|
||||
const command = "C:\\Selected Tools\\apktool.EXE";
|
||||
files(command);
|
||||
const selection = await resolveApktoolCommand(environment, "win32");
|
||||
const arguments_ = ["d", "C:\\input & literal.apk"];
|
||||
expect(
|
||||
apktoolInvocation(selection, arguments_, environment, "win32"),
|
||||
).toEqual({
|
||||
command,
|
||||
arguments: arguments_,
|
||||
windowsVerbatimArguments: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit launcher spelling without searching PATH", async () => {
|
||||
const command = "C:\\selected alias\\APKTOOL.cmd";
|
||||
expect(
|
||||
await resolveApktoolCommand(
|
||||
{ ...environment, [APKTOOL_COMMAND_ENV]: command },
|
||||
"win32",
|
||||
),
|
||||
).toEqual({ command, commandSource: "environment" });
|
||||
expect(lookup.stat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not inspect the current directory when the caller disables it", async () => {
|
||||
await expect(
|
||||
resolveApktoolCommand(environment, "win32"),
|
||||
).rejects.toMatchObject({ code: "command_missing" });
|
||||
expect(
|
||||
lookup.stat.mock.calls.every(
|
||||
([path]) =>
|
||||
String(path).startsWith("C:\\Selected Tools\\") ||
|
||||
String(path).startsWith("C:\\Other Tools\\"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not turn an explicitly empty PATHEXT into default extensions", async () => {
|
||||
files("C:\\Selected Tools\\apktool.EXE");
|
||||
await expect(
|
||||
resolveApktoolCommand({ ...environment, PathExt: "" }, "win32"),
|
||||
).rejects.toMatchObject({ code: "command_missing" });
|
||||
expect(lookup.stat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips directories without treating them as executable launchers", async () => {
|
||||
files("C:\\Other Tools\\apktool.EXE");
|
||||
const stat = lookup.stat.getMockImplementation()!;
|
||||
lookup.stat.mockImplementation(async (path: string) =>
|
||||
path === "C:\\Selected Tools\\apktool.EXE"
|
||||
? { isFile: () => false }
|
||||
: stat(path),
|
||||
);
|
||||
expect(await resolveApktoolCommand(environment, "win32")).toMatchObject({
|
||||
command: "C:\\Other Tools\\apktool.EXE",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves inspection failures instead of reporting a missing command or selecting a later file", async () => {
|
||||
lookup.stat.mockRejectedValue(
|
||||
Object.assign(new Error("denied"), { code: "EACCES" }),
|
||||
);
|
||||
const failure = await resolveApktoolCommand(environment, "win32").catch(
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(ApktoolConfigurationFailure);
|
||||
if (!(failure instanceof ApktoolConfigurationFailure)) throw failure;
|
||||
expect(failure).toMatchObject({
|
||||
code: "command_unresolved",
|
||||
diagnostics: {
|
||||
candidate_path: "C:\\Selected Tools\\apktool.EXE",
|
||||
filesystem_error: "EACCES",
|
||||
},
|
||||
});
|
||||
expect(apktoolConfigurationAvailability(failure)).toMatchObject({
|
||||
code: "not_configured",
|
||||
});
|
||||
expect(lookup.stat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("honors cancellation before inspecting launchers", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
resolveApktoolCommand(environment, "win32", controller.signal),
|
||||
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
|
||||
expect(lookup.stat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not select a launcher when cancellation arrives during lookup", async () => {
|
||||
const controller = new AbortController();
|
||||
lookup.stat.mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return { isFile: () => true };
|
||||
});
|
||||
await expect(
|
||||
resolveApktoolCommand(
|
||||
{
|
||||
PATH: "C:\\Tools",
|
||||
PATHEXT: ".CMD",
|
||||
NoDefaultCurrentDirectoryInExePath: "1",
|
||||
},
|
||||
"win32",
|
||||
controller.signal,
|
||||
),
|
||||
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
|
||||
expect(lookup.stat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["EXE", ".CMD\\..\\outside.exe", ".CMD\0"])(
|
||||
"rejects malformed PATHEXT %j before path lookup",
|
||||
async (PATHEXT) => {
|
||||
await expect(
|
||||
resolveApktoolCommand({ PATH: "C:\\Tools", PATHEXT }, "win32"),
|
||||
).rejects.toMatchObject({
|
||||
code: "command_unresolved",
|
||||
diagnostics: { invalid_extension: PATHEXT },
|
||||
});
|
||||
expect(lookup.stat).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels a pending filesystem lookup and owns its eventual rejection", async () => {
|
||||
const controller = new AbortController();
|
||||
let started!: () => void;
|
||||
let rejectLookup!: (cause: Error) => void;
|
||||
const pendingStat = new Promise((_resolve, reject) => {
|
||||
rejectLookup = reject;
|
||||
});
|
||||
const observed = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
lookup.stat.mockImplementation(() => {
|
||||
started();
|
||||
return pendingStat;
|
||||
});
|
||||
const pending = resolveApktoolCommand(
|
||||
{
|
||||
PATH: "C:\\Tools",
|
||||
PATHEXT: ".CMD",
|
||||
NODEFAULTCURRENTDIRECTORYINEXEPATH: "1",
|
||||
},
|
||||
"win32",
|
||||
controller.signal,
|
||||
);
|
||||
await observed;
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
});
|
||||
rejectLookup(new Error("filesystem completed after cancellation"));
|
||||
await Promise.resolve();
|
||||
expect(lookup.stat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds the entire pending PATH lookup by the selection deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
lookup.stat.mockReturnValue(new Promise(() => {}));
|
||||
const failure = expect(
|
||||
resolveApktoolCommand(
|
||||
{
|
||||
PATH: "C:\\Tools",
|
||||
PATHEXT: ".CMD",
|
||||
NODEFAULTCURRENTDIRECTORYINEXEPATH: "1",
|
||||
},
|
||||
"win32",
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisTimeoutError",
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
await failure;
|
||||
expect(lookup.stat).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { isAbsolute, win32 } from "node:path";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
|
||||
import {
|
||||
execFileOutput,
|
||||
@@ -27,30 +34,119 @@ export interface ApktoolCommandSelection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller-supplied apktool launcher. `REA_APKTOOL_COMMAND` must
|
||||
* be absolute so the selection stays explicit; without it, apktool is
|
||||
* resolved from `PATH` at run time. Both the upstream wrapper script and
|
||||
* `java -jar` launcher scripts work: REA only runs `<command> --version`
|
||||
* and `<command> d ...`.
|
||||
* Preserve an explicit launcher, or resolve the default Windows command before
|
||||
* choosing native versus batch process creation. POSIX keeps execFile's PATH
|
||||
* lookup. The resolved selection is reused for the version and decode runs.
|
||||
*/
|
||||
export const resolveApktoolCommand = (
|
||||
export const resolveApktoolCommand = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): ApktoolCommandSelection => {
|
||||
const configured = environment[APKTOOL_COMMAND_ENV];
|
||||
if (configured === undefined || configured.trim() === "")
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApktoolCommandSelection> => {
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("inspect_apktool_client");
|
||||
const selectedEnvironment = snapshotEnvironment(environment, platform);
|
||||
const configured = selectedEnvironment[APKTOOL_COMMAND_ENV];
|
||||
if (configured !== undefined && configured.trim() !== "") {
|
||||
if (
|
||||
!(platform === "win32"
|
||||
? win32.isAbsolute(configured)
|
||||
: isAbsolute(configured))
|
||||
)
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"not_absolute",
|
||||
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
|
||||
{ configured_command: configured },
|
||||
);
|
||||
return { command: configured, commandSource: "environment" };
|
||||
}
|
||||
if (platform !== "win32")
|
||||
return { command: "apktool", commandSource: "path" };
|
||||
if (!isAbsolute(configured))
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"not_absolute",
|
||||
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
|
||||
{ configured_command: configured },
|
||||
);
|
||||
return { command: configured, commandSource: "environment" };
|
||||
const extensions = (selectedEnvironment.PATHEXT ?? ".EXE;.COM;.BAT;.CMD")
|
||||
.split(";")
|
||||
.filter((extension) => extension !== "");
|
||||
for (const extension of extensions)
|
||||
if (!extension.startsWith(".") || /[\\/:\0]/u.test(extension))
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"command_unresolved",
|
||||
"Windows PATHEXT must contain filename extensions, not paths",
|
||||
{
|
||||
selected_command: "apktool",
|
||||
command_source: "path",
|
||||
invalid_extension: extension,
|
||||
},
|
||||
);
|
||||
const directories = (selectedEnvironment.PATH ?? "")
|
||||
.split(";")
|
||||
.filter((directory) => directory !== "");
|
||||
if (selectedEnvironment.NODEFAULTCURRENTDIRECTORYINEXEPATH === undefined)
|
||||
directories.unshift(process.cwd());
|
||||
// A single deadline owns the whole PATH search, including filesystem waits.
|
||||
const deadline = new ProviderStartupDeadline(VERSION_TIMEOUT_MS, signal);
|
||||
const lookupInterruption = () =>
|
||||
deadline.interruption === "cancelled"
|
||||
? new AnalysisCancelledError("inspect_apktool_client")
|
||||
: new AnalysisTimeoutError("inspect_apktool_client", VERSION_TIMEOUT_MS, {
|
||||
diagnostics: { phase: "windows_path_lookup" },
|
||||
});
|
||||
try {
|
||||
for (const entry of directories) {
|
||||
const directory =
|
||||
entry.startsWith('"') && entry.endsWith('"')
|
||||
? entry.slice(1, -1)
|
||||
: entry;
|
||||
for (const extension of extensions) {
|
||||
if (deadline.interruption !== undefined) throw lookupInterruption();
|
||||
const candidate = win32.resolve(directory, `apktool${extension}`);
|
||||
try {
|
||||
const metadata = await waitForAbortable(
|
||||
stat(candidate),
|
||||
deadline.signal,
|
||||
);
|
||||
if (metadata === ABORTED || deadline.interruption !== undefined)
|
||||
throw lookupInterruption();
|
||||
if (metadata.isFile())
|
||||
return { command: candidate, commandSource: "path" };
|
||||
} catch (cause: unknown) {
|
||||
if (deadline.interruption !== undefined) throw lookupInterruption();
|
||||
const code =
|
||||
typeof cause === "object" && cause !== null
|
||||
? Reflect.get(cause, "code")
|
||||
: undefined;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") continue;
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"command_unresolved",
|
||||
`The PATH-selected apktool launcher could not be inspected: ${candidate}`,
|
||||
{
|
||||
selected_command: "apktool",
|
||||
command_source: "path",
|
||||
candidate_path: candidate,
|
||||
...(typeof code === "string" ? { filesystem_error: code } : {}),
|
||||
},
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
deadline.dispose();
|
||||
}
|
||||
|
||||
throw new ApktoolConfigurationFailure(
|
||||
"command_missing",
|
||||
"The apktool launcher was not found on the selected Windows PATH/PATHEXT",
|
||||
{
|
||||
selected_command: "apktool",
|
||||
command_source: "path",
|
||||
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export type ApktoolConfigurationFailureCode =
|
||||
| "not_absolute"
|
||||
| "command_missing"
|
||||
| "command_unresolved"
|
||||
| "launcher_unsupported"
|
||||
| "version_unresolved";
|
||||
|
||||
@@ -121,7 +217,7 @@ export const inspectApktoolClient = async (
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<ApktoolClientIdentity> => {
|
||||
const hostEnvironment = { ...process.env, ...environment };
|
||||
const hostEnvironment = snapshotEnvironment(environment, platform);
|
||||
let invocation: ApktoolInvocation;
|
||||
try {
|
||||
invocation = apktoolInvocation(
|
||||
@@ -195,7 +291,9 @@ const rejectionCodeOf = (
|
||||
): ProviderRejectionCode =>
|
||||
code === "command_missing"
|
||||
? "executable_missing"
|
||||
: code === "not_absolute" || code === "launcher_unsupported"
|
||||
: code === "not_absolute" ||
|
||||
code === "launcher_unsupported" ||
|
||||
code === "command_unresolved"
|
||||
? "not_configured"
|
||||
: "version_unresolved";
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js";
|
||||
import {
|
||||
APKTOOL_COMMAND_ENV,
|
||||
ApktoolConfigurationFailure,
|
||||
apktoolConfigurationAvailability,
|
||||
apktoolInvocation,
|
||||
@@ -155,10 +156,9 @@ const runApktool = async (
|
||||
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const hostEnvironment = { ...process.env, ...environment };
|
||||
let invocation: ReturnType<typeof apktoolInvocation>;
|
||||
try {
|
||||
invocation = apktoolInvocation(selection, arguments_, hostEnvironment);
|
||||
invocation = apktoolInvocation(selection, arguments_, environment);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof WindowsBatchCommandError)) throw cause;
|
||||
const reason = `The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`;
|
||||
@@ -178,7 +178,7 @@ const runApktool = async (
|
||||
arguments: invocation.arguments,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
runId: randomUUID(),
|
||||
hostEnvironment,
|
||||
hostEnvironment: environment,
|
||||
},
|
||||
{
|
||||
timeoutMs: limits.timeoutMs,
|
||||
@@ -317,7 +317,9 @@ export interface ApktoolProviderOptions {
|
||||
* (`--no-src`) because Java-source recovery belongs to the JADX family.
|
||||
*/
|
||||
export class ApktoolProvider {
|
||||
private readonly environment: Readonly<Record<string, string | undefined>>;
|
||||
private readonly selectedEnvironment:
|
||||
| Readonly<Record<string, string | undefined>>
|
||||
| undefined;
|
||||
|
||||
private readonly workspaces = new Set<string>();
|
||||
private readonly removeWorkspace: (path: string) => Promise<void>;
|
||||
@@ -328,14 +330,25 @@ export class ApktoolProvider {
|
||||
#closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: ApktoolProviderOptions = {}) {
|
||||
this.environment = snapshotEnvironment(options.environment ?? process.env);
|
||||
this.selectedEnvironment =
|
||||
options.environment === undefined
|
||||
? undefined
|
||||
: snapshotEnvironment(options.environment);
|
||||
this.removeWorkspace =
|
||||
options.removeWorkspace ??
|
||||
((path) => rm(path, { recursive: true, force: true }));
|
||||
}
|
||||
|
||||
private selection(): ApktoolCommandSelection {
|
||||
return resolveApktoolCommand(this.environment);
|
||||
private operationEnvironment(): Readonly<NodeJS.ProcessEnv> {
|
||||
const ambient = snapshotEnvironment(process.env);
|
||||
if (this.selectedEnvironment === undefined) return ambient;
|
||||
// Child inheritance is sampled per operation. An explicit map keeps its
|
||||
// launcher choice authoritative, including an omitted command selecting PATH.
|
||||
return snapshotEnvironment({
|
||||
...ambient,
|
||||
...this.selectedEnvironment,
|
||||
[APKTOOL_COMMAND_ENV]: this.selectedEnvironment[APKTOOL_COMMAND_ENV],
|
||||
});
|
||||
}
|
||||
|
||||
/** Probe the selected apktool launcher; decodes nothing. */
|
||||
@@ -354,9 +367,14 @@ export class ApktoolProvider {
|
||||
async #inspectAvailability(
|
||||
signal: AbortSignal,
|
||||
): Promise<ProviderAvailability> {
|
||||
const environment = this.operationEnvironment();
|
||||
try {
|
||||
const selection = this.selection();
|
||||
await inspectApktoolClient(selection, signal, this.environment);
|
||||
const selection = await resolveApktoolCommand(
|
||||
environment,
|
||||
process.platform,
|
||||
signal,
|
||||
);
|
||||
await inspectApktoolClient(selection, signal, environment);
|
||||
throwIfCancelled("inspect_apktool_client", signal);
|
||||
return {
|
||||
status: "available",
|
||||
@@ -406,17 +424,22 @@ export class ApktoolProvider {
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
const environment = this.operationEnvironment();
|
||||
let selection: ApktoolCommandSelection;
|
||||
let identity: ApktoolClientIdentity;
|
||||
try {
|
||||
selection = this.selection();
|
||||
identity = await inspectApktoolClient(
|
||||
selection,
|
||||
selection = await resolveApktoolCommand(
|
||||
environment,
|
||||
process.platform,
|
||||
signal,
|
||||
this.environment,
|
||||
);
|
||||
identity = await inspectApktoolClient(selection, signal, environment);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
if (
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
cause instanceof AnalysisTimeoutError
|
||||
)
|
||||
return err(cause);
|
||||
if (cause instanceof ApktoolConfigurationFailure) {
|
||||
return err(
|
||||
providerConfigurationRefusal(
|
||||
@@ -449,6 +472,7 @@ export class ApktoolProvider {
|
||||
return this.decodeResources(
|
||||
request.operation,
|
||||
selection,
|
||||
environment,
|
||||
client,
|
||||
provider,
|
||||
request.input,
|
||||
@@ -500,6 +524,7 @@ export class ApktoolProvider {
|
||||
private async decodeResources(
|
||||
operation: "decode_android_resources",
|
||||
selection: ApktoolCommandSelection,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
client: {
|
||||
command: string;
|
||||
command_source: string;
|
||||
@@ -628,7 +653,7 @@ export class ApktoolProvider {
|
||||
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
|
||||
},
|
||||
signal,
|
||||
this.environment,
|
||||
environment,
|
||||
(owner) => this.#pendingCommandOwners.set(owner, root),
|
||||
);
|
||||
if (!decoded.ok) {
|
||||
|
||||
@@ -50,6 +50,13 @@ export interface AnalysisExecution {
|
||||
readonly limitations: readonly string[];
|
||||
/** Provider-owned completeness assessment for caller/callee observations. */
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
/**
|
||||
* Whether the provider capped an `xrefs` read: true when it reported a
|
||||
* truncation, false when it explicitly reported none. Absent when it reports
|
||||
* nothing, which is not a completeness claim. Never part of Evidence or tool
|
||||
* output.
|
||||
*/
|
||||
readonly referencesTruncated?: boolean;
|
||||
readonly locations: readonly EvidenceLocation[];
|
||||
readonly subject: EvidenceSubjectTarget | null;
|
||||
}
|
||||
@@ -59,6 +66,7 @@ interface CreateAnalysisExecutionOptions {
|
||||
readonly analysisProfile?: AnalysisProfileCommitment;
|
||||
readonly limitations?: readonly string[];
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
readonly referencesTruncated?: boolean;
|
||||
readonly locations?: readonly EvidenceLocation[];
|
||||
readonly subject?: EvidenceSubjectTarget;
|
||||
}
|
||||
@@ -103,6 +111,9 @@ export function createAnalysisExecution(
|
||||
options.relationshipCoverage,
|
||||
),
|
||||
}),
|
||||
...(options.referencesTruncated === undefined
|
||||
? {}
|
||||
: { referencesTruncated: options.referencesTruncated }),
|
||||
locations: [...(options.locations ?? [])],
|
||||
subject: options.subject ?? null,
|
||||
};
|
||||
|
||||
@@ -76,6 +76,8 @@ export const parseBinaryTarget = async (
|
||||
await access(candidate, constants.R_OK);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const canonical = await realpath(candidate);
|
||||
// Windows native admission must inspect the caller-selected reparse components.
|
||||
const sourcePath = process.platform === "win32" ? candidate : canonical;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const resolved = await resolveAppBundle(canonical);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
@@ -113,7 +115,7 @@ export const parseBinaryTarget = async (
|
||||
);
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: canonical,
|
||||
sourcePath,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "executable",
|
||||
format: "raw-binary",
|
||||
@@ -152,7 +154,7 @@ export const parseBinaryTarget = async (
|
||||
);
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: canonical,
|
||||
sourcePath,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "executable",
|
||||
format: "dos-com",
|
||||
@@ -166,7 +168,7 @@ export const parseBinaryTarget = async (
|
||||
)
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: process.platform === "win32" ? candidate : canonical,
|
||||
sourcePath,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "database",
|
||||
format: "analysis-database",
|
||||
@@ -175,7 +177,7 @@ export const parseBinaryTarget = async (
|
||||
if (artifactFormat !== undefined) {
|
||||
const identity = {
|
||||
path,
|
||||
sourcePath: process.platform === "win32" ? candidate : canonical,
|
||||
sourcePath,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
};
|
||||
return isArchiveFormat(artifactFormat)
|
||||
@@ -195,7 +197,7 @@ export const parseBinaryTarget = async (
|
||||
);
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: process.platform === "win32" ? candidate : canonical,
|
||||
sourcePath,
|
||||
...bundle,
|
||||
sha256: await sha256Handle(handle, path, metadata, signal),
|
||||
kind: "executable",
|
||||
|
||||
@@ -497,3 +497,71 @@ it("reports host denial before target mismatch but keeps composed workflow prere
|
||||
).reason,
|
||||
).toBe("target_required");
|
||||
});
|
||||
|
||||
describe("trace_feature requirements", () => {
|
||||
const capabilities = (operations: readonly string[]) =>
|
||||
operations.map((operation) => ({
|
||||
operation,
|
||||
available: true,
|
||||
reason: null,
|
||||
}));
|
||||
|
||||
it("needs the search operations the workflow actually calls", () => {
|
||||
expect(
|
||||
entry(
|
||||
"trace_feature",
|
||||
status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: capabilities([
|
||||
"search_strings",
|
||||
"search_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
]),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ available: true, reason: "available" });
|
||||
});
|
||||
|
||||
it.each(["search_strings", "search_procedures"])(
|
||||
"is unavailable without %s",
|
||||
(missing) => {
|
||||
const operations = [
|
||||
"search_strings",
|
||||
"search_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
].filter((operation) => operation !== missing);
|
||||
|
||||
expect(
|
||||
entry(
|
||||
"trace_feature",
|
||||
status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: capabilities(operations),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ available: false, reason: "provider_missing" });
|
||||
},
|
||||
);
|
||||
|
||||
it("is unavailable when only the list operations exist", () => {
|
||||
expect(
|
||||
entry(
|
||||
"trace_feature",
|
||||
status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: capabilities([
|
||||
"list_strings",
|
||||
"list_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
]),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ available: false, reason: "provider_missing" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,8 +121,8 @@ const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
|
||||
analyze_function: ["analyze_function"],
|
||||
inspect_native_api: ["analyze_function"],
|
||||
trace_feature: [
|
||||
"list_strings",
|
||||
"list_procedures",
|
||||
"search_strings",
|
||||
"search_procedures",
|
||||
"xrefs",
|
||||
"resolve_containing_procedure",
|
||||
],
|
||||
@@ -141,6 +141,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
|
||||
|
||||
/** Native contracts defined only for an active Mach-O executable. */
|
||||
const MACHO_TARGET_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"inspect_signature",
|
||||
"inspect_macho",
|
||||
"list_architectures",
|
||||
"observe_native_calls",
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { basename, dirname, join, sep } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CLAUDE_DESKTOP_PACKAGE_FAMILY,
|
||||
resolveWindowsClaudeDesktopLocation,
|
||||
type ClaudeDesktopLocationFileSystem,
|
||||
} from "./ClaudeDesktopConfigLocation.js";
|
||||
import {
|
||||
packagePrivateLauncherBlocker,
|
||||
packagePrivateStorageFamily,
|
||||
redirectedWriteBlocker,
|
||||
type MsixPackageView,
|
||||
} from "./MsixPackageStorage.js";
|
||||
|
||||
const home = join("/profile", "a");
|
||||
const env = {
|
||||
APPDATA: join(home, "AppData", "Roaming"),
|
||||
LOCALAPPDATA: join(home, "AppData", "Local"),
|
||||
};
|
||||
const packages = join(env.LOCALAPPDATA, "Packages");
|
||||
const appDataConfig = join(env.APPDATA, "Claude", "claude_desktop_config.json");
|
||||
const privateConfig = join(
|
||||
packages,
|
||||
CLAUDE_DESKTOP_PACKAGE_FAMILY,
|
||||
"LocalCache",
|
||||
"Roaming",
|
||||
"Claude",
|
||||
"claude_desktop_config.json",
|
||||
);
|
||||
|
||||
const failure = (code: string): Error =>
|
||||
Object.assign(new Error(`${code}: injected`), { code });
|
||||
|
||||
const file = { isFile: () => true };
|
||||
const privateDirectory = dirname(privateConfig);
|
||||
const privatePreferences = join(privateDirectory, "Preferences");
|
||||
const appDataDirectory = dirname(appDataConfig);
|
||||
|
||||
/** Package storage holding `privateFiles`; realpath decides the process view. */
|
||||
const packagedFileSystem = (
|
||||
realpath: ClaudeDesktopLocationFileSystem["realpath"],
|
||||
privateFiles: readonly string[] = [privateConfig],
|
||||
): ClaudeDesktopLocationFileSystem => ({
|
||||
readdir: (path) => {
|
||||
if (path === packages) return [CLAUDE_DESKTOP_PACKAGE_FAMILY];
|
||||
if (path === privateDirectory)
|
||||
return privateFiles.map((name) => basename(name));
|
||||
throw failure("ENOENT");
|
||||
},
|
||||
lstat: (path) => {
|
||||
if (!privateFiles.includes(path)) throw failure("ENOENT");
|
||||
return file;
|
||||
},
|
||||
realpath,
|
||||
});
|
||||
|
||||
describe("Claude Desktop MSIX process view", () => {
|
||||
it("recognizes a process that sees the private copy at the AppData path", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(
|
||||
home,
|
||||
env,
|
||||
packagedFileSystem((path) =>
|
||||
path === appDataConfig ? privateConfig : path,
|
||||
),
|
||||
);
|
||||
expect(location).toEqual({
|
||||
ok: true,
|
||||
value: expect.objectContaining({
|
||||
configPath: privateConfig,
|
||||
msixPackage: expect.objectContaining({ processView: "redirected" }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an absent AppData file as an unredirected process", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(
|
||||
home,
|
||||
env,
|
||||
packagedFileSystem((path) => {
|
||||
if (path === appDataConfig) throw failure("ENOENT");
|
||||
return path;
|
||||
}),
|
||||
);
|
||||
expect(location.ok && location.value.msixPackage?.processView).toBe(
|
||||
"direct",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes redirection through another private file before the configuration exists", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(
|
||||
home,
|
||||
env,
|
||||
packagedFileSystem(
|
||||
(path) =>
|
||||
path === join(appDataDirectory, "Preferences")
|
||||
? privatePreferences
|
||||
: path,
|
||||
[privatePreferences],
|
||||
),
|
||||
);
|
||||
expect(location.ok && location.value.configPath).toBe(privateConfig);
|
||||
expect(location.ok && location.value.msixPackage?.processView).toBe(
|
||||
"redirected",
|
||||
);
|
||||
});
|
||||
|
||||
it("continues after a listed private file disappears", () => {
|
||||
const fs = packagedFileSystem(
|
||||
(path) =>
|
||||
path === join(appDataDirectory, "Preferences")
|
||||
? privatePreferences
|
||||
: path,
|
||||
[privatePreferences],
|
||||
);
|
||||
const location = resolveWindowsClaudeDesktopLocation(home, env, {
|
||||
...fs,
|
||||
readdir: (path) =>
|
||||
path === privateDirectory
|
||||
? ["vanished", "Preferences"]
|
||||
: fs.readdir(path),
|
||||
});
|
||||
expect(location.ok && location.value.msixPackage?.processView).toBe(
|
||||
"redirected",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the view unknown when the package has no private files", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(
|
||||
home,
|
||||
env,
|
||||
packagedFileSystem((path) => path, []),
|
||||
);
|
||||
expect(location.ok && location.value.msixPackage?.processView).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the view unknown when the AppData path cannot be resolved", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(
|
||||
home,
|
||||
env,
|
||||
packagedFileSystem((path) => {
|
||||
if (path === appDataConfig) throw failure("EACCES");
|
||||
return path;
|
||||
}),
|
||||
);
|
||||
expect(location.ok && location.value.msixPackage?.processView).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unresolved Claude Desktop package identity", () => {
|
||||
it("reports unreadable package storage instead of assuming AppData", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(home, env, {
|
||||
readdir: () => {
|
||||
throw failure("EACCES");
|
||||
},
|
||||
lstat: () => file,
|
||||
realpath: (path) => path,
|
||||
});
|
||||
expect(location).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
path: packages,
|
||||
message: expect.stringContaining("could not be inspected"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an uninspectable private copy instead of choosing a file", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(home, env, {
|
||||
readdir: () => [CLAUDE_DESKTOP_PACKAGE_FAMILY],
|
||||
lstat: () => {
|
||||
throw failure("EPERM");
|
||||
},
|
||||
realpath: (path) => path,
|
||||
});
|
||||
expect(location.ok).toBe(false);
|
||||
expect(!location.ok && location.error.path).toBe(privateConfig);
|
||||
});
|
||||
|
||||
it("matches the package family case-insensitively, as Windows does", () => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(home, env, {
|
||||
readdir: () => [CLAUDE_DESKTOP_PACKAGE_FAMILY.toUpperCase()],
|
||||
lstat: () => {
|
||||
throw failure("ENOENT");
|
||||
},
|
||||
realpath: (path) => path,
|
||||
});
|
||||
expect(location.ok && location.value.msixPackage?.packageFamilyName).toBe(
|
||||
CLAUDE_DESKTOP_PACKAGE_FAMILY.toUpperCase(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("package-private REA launchers", () => {
|
||||
const launcher = join(
|
||||
env.APPDATA,
|
||||
"npm",
|
||||
"node_modules",
|
||||
"rea-agents",
|
||||
"scripts",
|
||||
"rea.mjs",
|
||||
);
|
||||
const stored = join(
|
||||
packages,
|
||||
CLAUDE_DESKTOP_PACKAGE_FAMILY,
|
||||
"LocalCache",
|
||||
"Roaming",
|
||||
"npm",
|
||||
"node_modules",
|
||||
"rea-agents",
|
||||
"scripts",
|
||||
"rea.mjs",
|
||||
);
|
||||
|
||||
it("names the package that privately stores a path", () => {
|
||||
expect(packagePrivateStorageFamily(stored, packages)).toBe(
|
||||
CLAUDE_DESKTOP_PACKAGE_FAMILY,
|
||||
);
|
||||
expect(packagePrivateStorageFamily(launcher, packages)).toBeUndefined();
|
||||
expect(
|
||||
packagePrivateStorageFamily(
|
||||
join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x"),
|
||||
packages,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks a launcher that only the package's processes can see", () => {
|
||||
const blocker = packagePrivateLauncherBlocker(
|
||||
[join("/Program Files", "nodejs", "node.exe"), launcher, "mcp"],
|
||||
packages,
|
||||
(path) => (path === launcher ? stored : path),
|
||||
);
|
||||
expect(blocker).toContain(stored);
|
||||
expect(blocker).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
|
||||
expect(blocker).toContain("npm install -g rea-agents");
|
||||
});
|
||||
|
||||
it("compares the launcher with the canonical package storage path", () => {
|
||||
const shortProfilePackages = join(
|
||||
"/profile",
|
||||
"A~1",
|
||||
"AppData",
|
||||
"Local",
|
||||
"Packages",
|
||||
);
|
||||
const blocker = packagePrivateLauncherBlocker(
|
||||
[launcher, "mcp"],
|
||||
shortProfilePackages,
|
||||
(path) =>
|
||||
path === shortProfilePackages
|
||||
? packages
|
||||
: path === launcher
|
||||
? stored
|
||||
: path,
|
||||
);
|
||||
expect(blocker).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
|
||||
});
|
||||
|
||||
it("allows ordinary and unresolvable launchers", () => {
|
||||
expect(
|
||||
packagePrivateLauncherBlocker(
|
||||
["cmd.exe", "/d", "/c", "npx", "-y", "rea-agents@6.4.0", "mcp"],
|
||||
packages,
|
||||
() => {
|
||||
throw new Error("package runners are not resolved");
|
||||
},
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
packagePrivateLauncherBlocker([launcher, "mcp"], packages, () => {
|
||||
throw failure("ENOENT");
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writes from a redirected AppData view", () => {
|
||||
const view: MsixPackageView = {
|
||||
packageFamilyName: CLAUDE_DESKTOP_PACKAGE_FAMILY,
|
||||
privateConfigPath: privateConfig,
|
||||
appDataConfigPath: appDataConfig,
|
||||
virtualizedRoots: [env.APPDATA, env.LOCALAPPDATA],
|
||||
processView: "redirected",
|
||||
};
|
||||
const vscode = join(env.APPDATA, "Code", "User", "mcp.json");
|
||||
const hermes = join(env.LOCALAPPDATA, "hermes", "config.yaml");
|
||||
const codex = join(home, ".codex", "config.toml");
|
||||
|
||||
it("refuses only targets under the package's virtualized roots", () => {
|
||||
const blocker = redirectedWriteBlocker(
|
||||
view,
|
||||
[vscode, codex, hermes],
|
||||
"setup",
|
||||
);
|
||||
expect(blocker).toContain(`${vscode}, ${hermes}`);
|
||||
expect(blocker).not.toContain(codex);
|
||||
expect(blocker).toContain("Rerun setup from a terminal");
|
||||
expect(redirectedWriteBlocker(view, [codex], "setup")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("compares aliases through the existing parent of a new destination", () => {
|
||||
const alias = join(home, "skill-alias");
|
||||
const target = join(alias, "skills", "reverse-engineer-anything");
|
||||
const blocker = redirectedWriteBlocker(
|
||||
view,
|
||||
[target],
|
||||
"uninstall",
|
||||
(path) => {
|
||||
if (path === alias) return join(env.APPDATA, "dsh");
|
||||
if (path.startsWith(`${alias}${sep}`)) throw failure("ENOENT");
|
||||
return path;
|
||||
},
|
||||
);
|
||||
expect(blocker).toContain(target);
|
||||
expect(blocker).toContain("Rerun uninstall");
|
||||
});
|
||||
|
||||
it("keeps canonicalization failures visible instead of assuming a target is outside AppData", () => {
|
||||
expect(
|
||||
redirectedWriteBlocker(view, [codex], "setup", () => {
|
||||
throw failure("EACCES");
|
||||
}),
|
||||
).toContain("Cannot establish the MSIX write location");
|
||||
});
|
||||
|
||||
it("allows every target without positive evidence of redirection", () => {
|
||||
for (const processView of ["direct", "unknown"] as const)
|
||||
expect(
|
||||
redirectedWriteBlocker({ ...view, processView }, [vscode], "uninstall"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
redirectedWriteBlocker(undefined, [vscode], "setup"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
windowsAppDataRoots,
|
||||
type MsixPackageView,
|
||||
} from "./MsixPackageStorage.js";
|
||||
|
||||
const CONFIG_FILE = "claude_desktop_config.json";
|
||||
|
||||
/** Private files compared at most when looking for a redirected AppData view. */
|
||||
const PROCESS_VIEW_PROBES = 8;
|
||||
|
||||
/**
|
||||
* Claude Desktop's MSIX package family: package name `Claude` and the
|
||||
* publisher ID Windows derives from `CN="Anthropic, PBC", O="Anthropic, PBC",
|
||||
* L=San Francisco, S=California, C=US, …`.
|
||||
*/
|
||||
export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
|
||||
|
||||
/** `Claude_` plus a 13-character Crockford base32 MSIX publisher ID. */
|
||||
const CLAUDE_PACKAGE_FAMILY = /^Claude_[0-9a-hjkmnp-tv-z]{13}$/iu;
|
||||
|
||||
/** The configuration Claude Desktop reads and the evidence that it is installed. */
|
||||
export interface ClaudeDesktopLocation {
|
||||
readonly configPath: string;
|
||||
readonly markerPath: string;
|
||||
readonly msixPackage?: MsixPackageView;
|
||||
}
|
||||
|
||||
/** Unresolved Claude Desktop identity, reported instead of guessing a file. */
|
||||
export interface ClaudeDesktopLocationFailure {
|
||||
readonly path: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Synchronous probes; each throws for failures other than absence. */
|
||||
export interface ClaudeDesktopLocationFileSystem {
|
||||
readonly readdir: (path: string) => readonly string[];
|
||||
readonly lstat: (path: string) => { isFile(): boolean };
|
||||
readonly realpath: (path: string) => string;
|
||||
}
|
||||
|
||||
const systemFileSystem: ClaudeDesktopLocationFileSystem = {
|
||||
readdir: (path) => readdirSync(path),
|
||||
lstat: (path) => lstatSync(path),
|
||||
realpath: (path) => realpathSync.native(path),
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the `claude_desktop_config.json` that Windows Claude Desktop reads.
|
||||
* Without its MSIX package storage, that is the AppData file. With it, the
|
||||
* package reads its private copy when one exists and the AppData file
|
||||
* otherwise; a new registration goes to the private copy. A `Claude_*`
|
||||
* package from another publisher leaves the reader unknown.
|
||||
*/
|
||||
export const resolveWindowsClaudeDesktopLocation = (
|
||||
home: string,
|
||||
env: Readonly<NodeJS.ProcessEnv>,
|
||||
fileSystem: ClaudeDesktopLocationFileSystem = systemFileSystem,
|
||||
): Result<ClaudeDesktopLocation, ClaudeDesktopLocationFailure> => {
|
||||
const roots = windowsAppDataRoots(home, env);
|
||||
const appDataDirectory = join(roots.roaming, "Claude");
|
||||
const appDataConfigPath = join(appDataDirectory, CONFIG_FILE);
|
||||
const packagesDirectory = join(roots.local, "Packages");
|
||||
let families: readonly string[];
|
||||
try {
|
||||
families = fileSystem
|
||||
.readdir(packagesDirectory)
|
||||
.filter((name) => CLAUDE_PACKAGE_FAMILY.test(name));
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
path: packagesDirectory,
|
||||
message: `MSIX package storage ${packagesDirectory} could not be inspected, so REA cannot establish whether Claude Desktop reads ${appDataConfigPath} or a package-private copy: ${errorMessage(cause)}. Repair access to that directory, then rerun.`,
|
||||
});
|
||||
families = [];
|
||||
}
|
||||
const foreign = families.filter(
|
||||
(name) =>
|
||||
name.toLowerCase() !== CLAUDE_DESKTOP_PACKAGE_FAMILY.toLowerCase(),
|
||||
);
|
||||
if (foreign.length > 0)
|
||||
return err({
|
||||
path: join(packagesDirectory, foreign[0] ?? ""),
|
||||
message: `MSIX package storage ${foreign.map((name) => join(packagesDirectory, name)).join(", ")} uses Claude Desktop's package name with an unrecognized publisher (Claude Desktop is ${CLAUDE_DESKTOP_PACKAGE_FAMILY}). REA cannot establish which ${CONFIG_FILE} that package reads and does not fall back to ${appDataConfigPath}. Remove the unrecognized package, or register REA in the configuration it reads.`,
|
||||
});
|
||||
const family = families[0];
|
||||
if (family === undefined)
|
||||
return ok({ configPath: appDataConfigPath, markerPath: appDataDirectory });
|
||||
const container = join(packagesDirectory, family);
|
||||
const privateDirectory = join(container, "LocalCache", "Roaming", "Claude");
|
||||
const privateConfigPath = join(privateDirectory, CONFIG_FILE);
|
||||
const privateCopy = present(fileSystem, privateConfigPath);
|
||||
if (!privateCopy.ok) return privateCopy;
|
||||
const appDataCopy = privateCopy.value
|
||||
? ok(false)
|
||||
: present(fileSystem, appDataConfigPath);
|
||||
if (!appDataCopy.ok) return appDataCopy;
|
||||
return ok({
|
||||
configPath:
|
||||
privateCopy.value || !appDataCopy.value
|
||||
? privateConfigPath
|
||||
: appDataConfigPath,
|
||||
markerPath: container,
|
||||
msixPackage: {
|
||||
packageFamilyName: family,
|
||||
privateConfigPath,
|
||||
appDataConfigPath,
|
||||
virtualizedRoots: [roots.roaming, roots.local],
|
||||
processView: processView(
|
||||
fileSystem,
|
||||
privateDirectory,
|
||||
appDataDirectory,
|
||||
privateCopy.value,
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* A process the package started sees the package's private files at their
|
||||
* AppData paths; any other process sees the real AppData files or nothing.
|
||||
* Claude Desktop saves its user data there while it runs, so the
|
||||
* configuration, or any other private file, can show which view this is.
|
||||
*/
|
||||
const processView = (
|
||||
fileSystem: ClaudeDesktopLocationFileSystem,
|
||||
privateDirectory: string,
|
||||
appDataDirectory: string,
|
||||
privateConfig: boolean,
|
||||
): MsixPackageView["processView"] => {
|
||||
let probes: readonly string[];
|
||||
try {
|
||||
probes = privateConfig
|
||||
? [CONFIG_FILE]
|
||||
: fileSystem
|
||||
.readdir(privateDirectory)
|
||||
.filter((name) => {
|
||||
try {
|
||||
return fileSystem.lstat(join(privateDirectory, name)).isFile();
|
||||
} catch (cause: unknown) {
|
||||
// A candidate may vanish or become unreadable while listed.
|
||||
void cause;
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.slice(0, PROCESS_VIEW_PROBES);
|
||||
} catch (cause: unknown) {
|
||||
// No private file can be listed, so no view can be compared.
|
||||
void cause;
|
||||
return "unknown";
|
||||
}
|
||||
let view: MsixPackageView["processView"] = "unknown";
|
||||
for (const name of probes) {
|
||||
let privateTarget: string;
|
||||
try {
|
||||
privateTarget = fileSystem.realpath(join(privateDirectory, name));
|
||||
} catch (cause: unknown) {
|
||||
// A private file that vanished cannot be compared; try another.
|
||||
void cause;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (fileSystem.realpath(join(appDataDirectory, name)) === privateTarget)
|
||||
return "redirected";
|
||||
view = "direct";
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissing(cause)) return "unknown";
|
||||
view = "direct";
|
||||
}
|
||||
}
|
||||
return view;
|
||||
};
|
||||
|
||||
const present = (
|
||||
fileSystem: ClaudeDesktopLocationFileSystem,
|
||||
path: string,
|
||||
): Result<boolean, ClaudeDesktopLocationFailure> => {
|
||||
try {
|
||||
fileSystem.lstat(path);
|
||||
return ok(true);
|
||||
} catch (cause: unknown) {
|
||||
if (isMissing(cause)) return ok(false);
|
||||
return err({
|
||||
path,
|
||||
message: `${path} could not be inspected, so REA cannot establish which ${CONFIG_FILE} Claude Desktop's MSIX package reads: ${errorMessage(cause)}. Repair access to that path, then rerun.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user