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 |
@@ -1,112 +0,0 @@
|
||||
---
|
||||
name: rea-ci-tests-audit
|
||||
description: Audit REA CI and test suites for correctness, fixture drift, false passes, and measured performance; use for suite or workflow investigations and efficiency changes, rather than ordinary feature testing or a single test failure.
|
||||
---
|
||||
|
||||
# REA CI and tests audit
|
||||
|
||||
Establish which checks still catch real regressions and which costs can be
|
||||
removed without losing that protection. The outcome may be a verified fix,
|
||||
an evidence-backed recommendation, or an explicit decision to stop.
|
||||
|
||||
Follow the checkout's `AGENTS.md`, [testing guide](../../../docs/testing.md),
|
||||
and [contribution rules](../../../CONTRIBUTING.md). Use their current commands
|
||||
and pinned toolchain, rather than commands remembered from an earlier audit.
|
||||
Preserve the requested scope: investigation alone does not authorize edits;
|
||||
this skill does not grant permission to publish issues, push, or merge. Existing
|
||||
user authorization still applies. Keep audit notes and raw results outside the
|
||||
tracked tree, normally in a task-specific `/tmp` directory.
|
||||
|
||||
## Establish the executed baseline
|
||||
|
||||
Fetch when the user asks for latest main. Pin the baseline SHA and inspected
|
||||
workflow revision; use an isolated worktree when changing or comparing code.
|
||||
Do not modify another task's checkout. Record dirty changes and the exact
|
||||
command, toolchain, host, selected tests, instrumentation, and prerequisites.
|
||||
An old run is evidence for its executed revision, even if main advances.
|
||||
|
||||
Trace workflow triggers, changed-path selection, job dependencies, reusable
|
||||
actions, package scripts, test discovery, artifact producers, and report gates
|
||||
through to the commands that actually execute. Include scripts, generated data,
|
||||
runtime registration, and subprocess boundaries missing from import graphs.
|
||||
Check positive and negative selection paths: required files must be discovered
|
||||
once per intended test configuration; deliberate platform/configuration repeats
|
||||
are separate coverage. A missing explicit test or missing required report must
|
||||
not turn green. Optional lanes may skip; preserve their stated prerequisites.
|
||||
|
||||
Use existing timing and failure artifacts to select suspicious families. Run
|
||||
relevant checks to establish a baseline; reserve a full local suite for broad
|
||||
CI diagnosis or uncertainty that focused checks cannot resolve. Do not rebuild
|
||||
or rerun an unchanged complete suite after every documentation edit.
|
||||
|
||||
## Prove the failure or protection
|
||||
|
||||
For each candidate, state the expected observable behavior and its owner,
|
||||
then construct the smallest reproduction against production behavior. Follow
|
||||
the producer and affected consumers before deciding where to fix it.
|
||||
|
||||
- For drift, compare fixtures with current admission requirements, protocol
|
||||
replies, named contracts, tool versions, and actual public entrypoints. A
|
||||
plausible-looking mock can fail before the behavior its test claims to cover.
|
||||
Search the whole affected fixture family, including other version strings.
|
||||
Preserve intentionally malformed fixtures and test their intended rejection.
|
||||
- For a suspicious green test, perturb the relevant behavior in an isolated
|
||||
copy or inject a controlled failure. Verify that the existing assertion or
|
||||
gate detects it. A test may merely repeat its mock, import abandoned code,
|
||||
or hide a subprocess/checker failure. Restore mutations after the experiment.
|
||||
- For shared setup or pruning, map distinct assertions to their retained
|
||||
scenarios. Preserve malformed inputs, cancellation, cleanup failure,
|
||||
permissions, capacity, identity, and meaningful concurrency interactions.
|
||||
A faster run that skips a case or weakens its oracle is not an improvement.
|
||||
- For provider coverage, separate fake-producer, boundary, and actual-engine
|
||||
evidence. Inspect named skips and prerequisites; fixtures do not establish
|
||||
real Ghidra, LLDB, browser, or other provider behavior.
|
||||
|
||||
Classify findings as reproduced defects, supported costs, hypotheses, or
|
||||
unavailable evidence. Record the baseline command, observed output/exit status,
|
||||
expected result, affected scope, and competing explanation. A failure on main
|
||||
may be pre-existing; that does not by itself make it irrelevant to a CI audit.
|
||||
Attribute PR failures using the executed revision and a clean baseline, not
|
||||
just their resemblance to a previous failure. Retry only to test a specific
|
||||
transient explanation; retain the original failure and stop when repetition
|
||||
adds no evidence. A passing retry does not establish a root cause or a fix.
|
||||
|
||||
## Assess efficiency without losing the gate
|
||||
|
||||
Read [measurement and CI checks](references/measurement-and-ci.md) when
|
||||
investigating slowness, caching, runner waits, shared artifacts, or log noise.
|
||||
Compare equivalent completed work before claiming a speedup. Distinguish
|
||||
setup/build time, test execution, coverage instrumentation, dependency waits,
|
||||
and runner scheduling; state which costs remain unmeasured.
|
||||
|
||||
Prefer removing proven duplicate preparation, reusing immutable setup, and
|
||||
reducing repeated subprocess work before shrinking correctness coverage.
|
||||
Preserve independent sessions where lifecycle or concurrency is the oracle.
|
||||
Keep live tests fresh; a cached previous pass cannot validate current host,
|
||||
installed provider contents, permissions, or external state.
|
||||
|
||||
## Fix, verify, and hand off within scope
|
||||
|
||||
If implementation is authorized, make the smallest structural change at the
|
||||
owning boundary and add a regression that fails on the baseline for the right
|
||||
reason. Retest affected consumers and meaningful negative paths; verify cache
|
||||
invalidation or discovery when those mechanisms change. Read the installed
|
||||
Turbo documentation before changing its configuration or commands.
|
||||
|
||||
Run the repository's applicable checks and inspect complete current-head CI
|
||||
results before an authorized merge. Preserve required coverage and discovery
|
||||
gates. Do not suppress a failure, add blanket retries, raise timeouts, or delete
|
||||
distinct assertions solely to make CI green. Changes to these controls need
|
||||
their own demonstrated reason and retained failure detection.
|
||||
|
||||
Report each confirmed issue with exact revision, reproduction, consequence,
|
||||
proposed correction, and validation limits. Report performance with workload
|
||||
and measurements, not just a percentage. Use English issue/PR records when
|
||||
requested; follow the user's assignment, commit identity, review, and merge
|
||||
preferences without assuming them for other users. Open ready-for-review PRs
|
||||
only when that lifecycle is authorized. Resolve necessary review findings and
|
||||
check final main against the tested tree after merging.
|
||||
|
||||
Stop when the authorized work is verified, remaining benefits are unproven,
|
||||
or further changes would trade correctness for speculative speed. List genuine
|
||||
uncertainties without turning them into additional optimization rounds.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "REA CI and tests audit"
|
||||
short_description: "Audit CI/test correctness and measured performance"
|
||||
default_prompt: "Use $rea-ci-tests-audit to investigate CI and test correctness, drift, and performance before proposing changes."
|
||||
@@ -1,65 +0,0 @@
|
||||
# Measurement and CI checks
|
||||
|
||||
## Equivalent completed work
|
||||
|
||||
Record the source/workflow SHA, command, test identities and counts, skips,
|
||||
assertions or output contract, exit status, coverage scope and thresholds,
|
||||
toolchain/provider versions, host resources, and prepared artifacts. Inspect
|
||||
both the child result and its report: a profiling wrapper may exit successfully
|
||||
after the measured command failed. Focused tests can pass while global coverage
|
||||
thresholds fail; describe both rather than calling the command green.
|
||||
|
||||
Measure wall time, user/system CPU, and peak process-family memory for relevant
|
||||
completed tasks. Parent-only RSS misses workers and their children. On Linux,
|
||||
summed RSS can double-count shared pages; report RSS/PSS semantics, sampling
|
||||
interval, missing samples, and observed cleanup limits. Use equivalent host
|
||||
tools elsewhere. State unknown costs, including agent tokens; fewer log bytes
|
||||
alone prove neither fewer tokens nor faster tests.
|
||||
|
||||
Separate cold execution, warm build/file caches, and warm runtime caches.
|
||||
Use repeated or alternating runs when variance affects the decision, with
|
||||
enough observations to distinguish the proposed benefit from noise. Keep
|
||||
workload, instrumentation, and host conditions comparable; record interference.
|
||||
A run with fewer tests, early rejection, lost coverage, or a different runner
|
||||
does not prove suite acceleration. CI-wide claims need end-to-end measurements,
|
||||
not an extrapolation from one improved family.
|
||||
|
||||
## Workflow, caches, and shared artifacts
|
||||
|
||||
Trace each reused result from producer to consumers and identify its lifetime.
|
||||
Check key/input coverage against the actual file inventory and result-affecting
|
||||
configuration, environment, generated data, tool versions, and host state.
|
||||
Exercise an affected input change to prove invalidation; exercise an irrelevant
|
||||
change to support reuse where useful. Cache deterministic preparation and
|
||||
static analysis only when their inputs fully describe the result.
|
||||
|
||||
Verify downloaded artifacts match the expected revision, platform, runtime,
|
||||
and schema. Check restore misses, failed/partial producers, stale reports,
|
||||
removed output files, and consumers after producer failures. Shared writers
|
||||
need output ownership or locks; tests need isolated state, not an unnecessary
|
||||
global lock. Worker counts do not bound children spawned within each test.
|
||||
Preserve intentionally concurrent scenarios while bounding unrelated batches.
|
||||
|
||||
Check matrix expansion and all job pages before saying CI passed. Inspect
|
||||
required gates after failed, cancelled, and skipped dependencies. `always()`
|
||||
alone does not preserve failures; inspect the aggregation logic. Test/report
|
||||
uploads should retain available diagnostics after failure, while absent or
|
||||
malformed required results must fail closed. Do not demand an optional report
|
||||
from a lane intentionally excluded by the selector.
|
||||
|
||||
## Runner waits and console noise
|
||||
|
||||
Use per-job and per-step timestamps where available. Workflow creation to job
|
||||
start includes dependencies and other delays; it is not necessarily runner
|
||||
queue time. Identify when the job became eligible before attributing waits to
|
||||
runner capacity. If eligibility/queue timestamps are unavailable, report the
|
||||
combined delay and unknown split. Compare equivalent lanes and runner types
|
||||
across enough runs; a single busy runner is not a workflow regression.
|
||||
|
||||
For console reductions, retain checker exit codes, actionable errors, useful
|
||||
summary counts, and complete machine-readable artifacts. Verify authored code
|
||||
remains in the actual checker inventory when excluding generated outputs.
|
||||
Introduce an authored error as a control; it must still fail. An ignore pattern
|
||||
can hide real source as well as noise. Show that cached reports are restored,
|
||||
stale successful reports cannot mask current failures, and report parse errors
|
||||
remain failures. Measure readability or byte reduction separately from runtime.
|
||||
@@ -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/
|
||||
|
||||
+74
-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,6 +611,7 @@ 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
|
||||
@@ -622,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/
|
||||
@@ -642,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
|
||||
@@ -652,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
|
||||
@@ -666,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
|
||||
@@ -675,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: |
|
||||
@@ -782,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
|
||||
@@ -794,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 }}
|
||||
|
||||
@@ -202,10 +202,6 @@ CI diagnosis. Packaging/setup/distribution changes additionally require
|
||||
`npm run verify:package` and `npm pack --dry-run`; provider changes require the
|
||||
matching real-provider lane. State which host/provider workflows ran.
|
||||
|
||||
For CI/test correctness, drift, or performance investigations, use the
|
||||
[`rea-ci-tests-audit` contributor skill](.agents/skills/rea-ci-tests-audit/SKILL.md).
|
||||
It guides reproduction and comparable measurements before changing checks.
|
||||
|
||||
`check:pr` reuses build and static-check outputs while executing the test suite
|
||||
fresh, including the fast Python producer checks. `docs:check` also runs the
|
||||
onboarding example consistency suite for README-only changes.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,6 +28,7 @@ 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;
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+30
-6
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
@@ -270,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",
|
||||
@@ -309,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,6 +55,9 @@ 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",
|
||||
@@ -218,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,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"),
|
||||
|
||||
@@ -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"]]
|
||||
: [
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -155,7 +155,7 @@ export const readClientRegistrationInspections = async (
|
||||
}
|
||||
const parsed = effectiveClientConfiguration(files.value);
|
||||
if (parsed === undefined) {
|
||||
record(unavailableStatus(client.name, client.configPath, "missing"));
|
||||
record(unavailableStatus(client, "missing"));
|
||||
continue;
|
||||
}
|
||||
const policy = await readClientPolicyBlock(
|
||||
@@ -191,7 +191,7 @@ export const readClientRegistrationInspections = async (
|
||||
});
|
||||
continue;
|
||||
}
|
||||
record(unavailableStatus(client.name, client.configPath, "missing"));
|
||||
record(unavailableStatus(client, "missing"));
|
||||
continue;
|
||||
}
|
||||
const registration = parseRegistration(raw, client);
|
||||
@@ -202,8 +202,7 @@ export const readClientRegistrationInspections = async (
|
||||
const activation = registrationActivation(registration, client, parsed);
|
||||
record(
|
||||
configuredStatus(
|
||||
client.name,
|
||||
client.configPath,
|
||||
client,
|
||||
command,
|
||||
activation === "enabled" &&
|
||||
registrationAligned(
|
||||
@@ -222,11 +221,7 @@ export const readClientRegistrationInspections = async (
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
record(
|
||||
unavailableStatus(
|
||||
client.name,
|
||||
client.configPath,
|
||||
isMissing(cause) ? "missing" : "invalid",
|
||||
),
|
||||
unavailableStatus(client, isMissing(cause) ? "missing" : "invalid"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -352,36 +347,45 @@ const parseRegistration = (
|
||||
return registration;
|
||||
};
|
||||
|
||||
const remediation =
|
||||
"Run rea setup to refresh this registration, then restart the client.";
|
||||
/** A client that rewrites its file from memory must be quit before setup. */
|
||||
const remediation = (client: SetupClient): string => {
|
||||
if (client.quitBeforeSetup !== true)
|
||||
return "Run rea setup to refresh this registration, then restart the client.";
|
||||
const name = client.displayName ?? client.name;
|
||||
return `Quit ${name} fully, run rea setup to refresh this registration, then start ${name} again. A running ${name} can rewrite its configuration from memory and drop the registration.`;
|
||||
};
|
||||
|
||||
const configuredStatus = (
|
||||
client: string,
|
||||
configPath: string,
|
||||
client: SetupClient,
|
||||
command: RegistrationCommand,
|
||||
state: "aligned" | "stale",
|
||||
policyRemediation?: string,
|
||||
): ClientRegistrationStatus =>
|
||||
state === "aligned"
|
||||
? { client, config_path: configPath, command, state, remediation: null }
|
||||
: {
|
||||
client,
|
||||
config_path: configPath,
|
||||
? {
|
||||
client: client.name,
|
||||
config_path: client.configPath,
|
||||
command,
|
||||
state,
|
||||
remediation: policyRemediation ?? remediation,
|
||||
remediation: null,
|
||||
}
|
||||
: {
|
||||
client: client.name,
|
||||
config_path: client.configPath,
|
||||
command,
|
||||
state,
|
||||
remediation: policyRemediation ?? remediation(client),
|
||||
};
|
||||
|
||||
const unavailableStatus = (
|
||||
client: string,
|
||||
configPath: string,
|
||||
client: SetupClient,
|
||||
state: "missing" | "invalid",
|
||||
): ClientRegistrationStatus => ({
|
||||
client,
|
||||
config_path: configPath,
|
||||
client: client.name,
|
||||
config_path: client.configPath,
|
||||
command: [],
|
||||
state,
|
||||
remediation,
|
||||
remediation: remediation(client),
|
||||
});
|
||||
|
||||
const clientEvidencePresent = async (client: SetupClient): Promise<boolean> => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { compareBundles } from "../domain/bundleComparison.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
readEvidenceBundle,
|
||||
readImmutableEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
} from "./EvidenceBundleFiles.js";
|
||||
|
||||
@@ -33,7 +34,8 @@ export const exportEvidenceBundleCommand = async (
|
||||
outputPath: string,
|
||||
overwrite: boolean,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
const loaded = await readEvidenceBundle(sourcePath);
|
||||
// The sealed snapshot lets the writer reuse this authentication.
|
||||
const loaded = await readImmutableEvidenceBundle(sourcePath);
|
||||
if (!loaded.ok) return loaded;
|
||||
const resources = new ArtifactResourceScope();
|
||||
const written = await writeEvidenceBundle(
|
||||
@@ -69,9 +71,11 @@ export const compareEvidenceBundlesCommand = async (input: {
|
||||
readonly leftPath: string;
|
||||
readonly rightPath: string;
|
||||
}): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
// Sealed snapshots let comparison and its bundle digests reuse this
|
||||
// authentication instead of re-validating every record twice more.
|
||||
const [left, right] = await Promise.all([
|
||||
readEvidenceBundle(input.leftPath),
|
||||
readEvidenceBundle(input.rightPath),
|
||||
readImmutableEvidenceBundle(input.leftPath),
|
||||
readImmutableEvidenceBundle(input.rightPath),
|
||||
]);
|
||||
if (!left.ok) return left;
|
||||
if (!right.ok) return right;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/**
|
||||
* How one MSIX-packaged client reads its AppData configuration. A package
|
||||
* with write virtualization saves AppData files to its private
|
||||
* `LocalCache\Roaming` copy, then reads that copy whenever it exists and the
|
||||
* ordinary AppData file otherwise.
|
||||
*/
|
||||
export interface MsixPackageView {
|
||||
readonly packageFamilyName: string;
|
||||
/** The package-private copy, which shadows the AppData file once it exists. */
|
||||
readonly privateConfigPath: string;
|
||||
/** The AppData path that unpackaged processes read and write. */
|
||||
readonly appDataConfigPath: string;
|
||||
/** Roots whose writes the package redirects for processes it starts. */
|
||||
readonly virtualizedRoots: readonly string[];
|
||||
/**
|
||||
* `redirected`: this process sees one of the package's private files at its
|
||||
* AppData path, so the running package started it. `direct`: it does not.
|
||||
* `unknown`: no private file could be compared.
|
||||
*/
|
||||
readonly processView: "redirected" | "direct" | "unknown";
|
||||
}
|
||||
|
||||
/** Windows' roaming and local application data roots; empty means unset. */
|
||||
export const windowsAppDataRoots = (
|
||||
home: string,
|
||||
env: Readonly<NodeJS.ProcessEnv>,
|
||||
): { readonly roaming: string; readonly local: string } => ({
|
||||
roaming: env.APPDATA?.trim() || join(home, "AppData", "Roaming"),
|
||||
local: env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
|
||||
});
|
||||
|
||||
/** The per-user root of MSIX package data containers. */
|
||||
export const msixPackagesDirectory = (
|
||||
home: string,
|
||||
env: Readonly<NodeJS.ProcessEnv>,
|
||||
): string => join(windowsAppDataRoots(home, env).local, "Packages");
|
||||
|
||||
/** Whether `path` is `root` or inside it, using the host's path comparison. */
|
||||
const pathWithin = (path: string, root: string): boolean => {
|
||||
const inner = relative(root, path);
|
||||
return (
|
||||
inner === "" ||
|
||||
(inner !== ".." && !inner.startsWith(`..${sep}`) && !isAbsolute(inner))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Name the package whose private `LocalCache` holds a canonical path. Its
|
||||
* processes see those files at virtual AppData locations; other processes do
|
||||
* not, and Windows removes them with the package.
|
||||
*/
|
||||
export const packagePrivateStorageFamily = (
|
||||
canonicalPath: string,
|
||||
packagesDirectory: string,
|
||||
): string | undefined => {
|
||||
if (!pathWithin(canonicalPath, packagesDirectory)) return undefined;
|
||||
const [family, storage] = relative(packagesDirectory, canonicalPath).split(
|
||||
/[\\/]/u,
|
||||
);
|
||||
return family !== undefined &&
|
||||
family !== "" &&
|
||||
storage?.toLowerCase() === "localcache"
|
||||
? family
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explain why setup must not register a launcher that exists only in one
|
||||
* package's private storage. Other clients could not start it, and doctor
|
||||
* outside that package could not verify it.
|
||||
*/
|
||||
export const packagePrivateLauncherBlocker = (
|
||||
command: readonly string[],
|
||||
packagesDirectory: string,
|
||||
canonicalPath: (path: string) => string = realpathSync.native,
|
||||
): string | undefined => {
|
||||
// Compare canonical forms: a short-name, junctioned or substituted profile
|
||||
// spells the same directory differently.
|
||||
let packages = packagesDirectory;
|
||||
try {
|
||||
packages = canonicalPath(packagesDirectory);
|
||||
} catch (cause: unknown) {
|
||||
// Without package storage, the spelled path is the only comparison.
|
||||
void cause;
|
||||
}
|
||||
for (const part of command) {
|
||||
if (!isAbsolute(part)) continue;
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = canonicalPath(part);
|
||||
} catch (cause: unknown) {
|
||||
// A launcher that cannot be resolved is not evidence of package storage.
|
||||
void cause;
|
||||
continue;
|
||||
}
|
||||
const family = packagePrivateStorageFamily(resolved, packages);
|
||||
if (family !== undefined)
|
||||
return `The REA launcher ${part} is stored at ${resolved}, inside the private storage of MSIX package ${family}. Only processes started by that app can see it at ${part}, and Windows removes it with the package, so other clients could not start this registration. Install REA from a terminal that the app did not start (for example with \`npm install -g rea-agents\`), then rerun setup from that terminal.`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explain why this process must not write `targets`: the running package
|
||||
* started it, so writes under its virtualized roots land in that package's
|
||||
* private storage, which the targets' owners never read.
|
||||
*/
|
||||
const canonicalDestination = (
|
||||
path: string,
|
||||
canonicalPath: (path: string) => string,
|
||||
): Result<string, { readonly path: string; readonly detail: string }> => {
|
||||
let ancestor = path;
|
||||
const missing: string[] = [];
|
||||
while (true) {
|
||||
try {
|
||||
return ok(join(canonicalPath(ancestor), ...missing));
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR") &&
|
||||
dirname(ancestor) !== ancestor
|
||||
) {
|
||||
missing.unshift(basename(ancestor));
|
||||
ancestor = dirname(ancestor);
|
||||
continue;
|
||||
}
|
||||
return err({
|
||||
path,
|
||||
detail: cause instanceof Error ? cause.message : String(cause),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const redirectedWriteBlocker = (
|
||||
view: MsixPackageView | undefined,
|
||||
targets: readonly string[],
|
||||
operation: "setup" | "uninstall",
|
||||
canonicalPath: (path: string) => string = realpathSync.native,
|
||||
): string | undefined => {
|
||||
if (view?.processView !== "redirected" || targets.length === 0)
|
||||
return undefined;
|
||||
const roots: string[] = [];
|
||||
const redirected: string[] = [];
|
||||
for (const root of view.virtualizedRoots) {
|
||||
const resolved = canonicalDestination(root, canonicalPath);
|
||||
if (!resolved.ok)
|
||||
return `Cannot establish the MSIX write location for ${resolved.error.path}: ${resolved.error.detail}. Rerun ${operation} from a terminal that this app did not start.`;
|
||||
roots.push(resolved.value);
|
||||
}
|
||||
for (const target of targets) {
|
||||
const resolved = canonicalDestination(target, canonicalPath);
|
||||
if (!resolved.ok)
|
||||
return `Cannot establish the MSIX write location for ${resolved.error.path}: ${resolved.error.detail}. Rerun ${operation} from a terminal that this app did not start.`;
|
||||
if (roots.some((root) => pathWithin(resolved.value, root)))
|
||||
redirected.push(target);
|
||||
}
|
||||
if (redirected.length === 0) return undefined;
|
||||
return `This process was started by MSIX package ${view.packageFamilyName}, which redirects its writes under ${view.virtualizedRoots.join(" and ")} into the package's private storage. Changes to ${redirected.join(", ")} would not reach the files other apps read. Rerun ${operation} from a terminal that this app did not start.`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the package began reading its private copy after the AppData file
|
||||
* was chosen, for example by saving its settings when it quit.
|
||||
*/
|
||||
export const packageReadTargetChanged = async (
|
||||
view: MsixPackageView,
|
||||
configPath: string,
|
||||
): Promise<boolean> => {
|
||||
if (configPath !== view.appDataConfigPath) return false;
|
||||
try {
|
||||
await lstat(view.privateConfigPath);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
return !(
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
import { runSetup } from "./Setup.js";
|
||||
import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
import type { MsixPackageView } from "./MsixPackageStorage.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
// Host joins keep containment checks meaningful on POSIX test hosts too.
|
||||
const PROFILE = String.raw`C:\Users\a`;
|
||||
const ROAMING = join(PROFILE, "AppData", "Roaming");
|
||||
const LOCAL = join(PROFILE, "AppData", "Local");
|
||||
const PRIVATE_CONFIG = join(
|
||||
LOCAL,
|
||||
"Packages",
|
||||
"Claude_pzs8sxrjxfjjc",
|
||||
"LocalCache",
|
||||
"Roaming",
|
||||
"Claude",
|
||||
"claude_desktop_config.json",
|
||||
);
|
||||
const APPDATA_CONFIG = join(ROAMING, "Claude", "claude_desktop_config.json");
|
||||
|
||||
/** Claude Desktop as setup resolves it from its MSIX package storage. */
|
||||
const msixClaudeDesktop = (
|
||||
processView: MsixPackageView["processView"],
|
||||
): SetupClient => ({
|
||||
name: "claude_desktop",
|
||||
displayName: "Claude Desktop",
|
||||
configPath: PRIVATE_CONFIG,
|
||||
quitBeforeSetup: true,
|
||||
msixPackage: {
|
||||
packageFamilyName: "Claude_pzs8sxrjxfjjc",
|
||||
privateConfigPath: PRIVATE_CONFIG,
|
||||
appDataConfigPath: APPDATA_CONFIG,
|
||||
virtualizedRoots: [ROAMING, LOCAL],
|
||||
processView,
|
||||
},
|
||||
});
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("omits an aligned managed skill from an otherwise empty plan", async () => {
|
||||
@@ -293,3 +327,85 @@ describe("setup workflow action selection", () => {
|
||||
expect(result.remediation).not.toContain("internal_client_key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MSIX Claude Desktop setup", () => {
|
||||
it("discloses which file MSIX Claude Desktop reads and that it must be quit", async () => {
|
||||
const host = new FakeSetupHost("win32");
|
||||
host.hopper = join(PROFILE, "Hopper");
|
||||
host.skill = "unchanged";
|
||||
host.clients = [msixClaudeDesktop("direct")];
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(false), clientIds: ["claude_desktop"], dryRun: true },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("planned");
|
||||
const [action] = result.plannedActions;
|
||||
expect(action?.target).toBe(PRIVATE_CONFIG);
|
||||
expect(action?.detail).toContain(
|
||||
`Claude Desktop is installed as MSIX package Claude_pzs8sxrjxfjjc, which reads this package-private file instead of ${APPDATA_CONFIG}.`,
|
||||
);
|
||||
expect(action?.detail).toContain(
|
||||
"Quit Claude Desktop fully before approving; closing its window is not enough.",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses AppData writes from a process the MSIX package started", async () => {
|
||||
const host = new FakeSetupHost("win32");
|
||||
host.hopper = join(PROFILE, "Hopper");
|
||||
host.skill = "unchanged";
|
||||
const vscode = join(ROAMING, "Code", "User", "mcp.json");
|
||||
host.clients = [
|
||||
msixClaudeDesktop("redirected"),
|
||||
{ name: "vscode", displayName: "VS Code", configPath: vscode },
|
||||
{
|
||||
name: "codex",
|
||||
displayName: "Codex",
|
||||
configPath: join(PROFILE, ".codex", "config.toml"),
|
||||
},
|
||||
];
|
||||
|
||||
const blocked = await runSetup(
|
||||
{ ...options(true), clientIds: ["vscode", "codex"] },
|
||||
host,
|
||||
);
|
||||
expect(blocked.status).toBe("needs_human");
|
||||
expect(blocked.remediation).toContain(
|
||||
`Changes to ${vscode} would not reach the files other apps read.`,
|
||||
);
|
||||
expect(host.configurations).toBe(0);
|
||||
|
||||
const homeOnly = await runSetup(
|
||||
{ ...options(true), clientIds: ["codex"] },
|
||||
host,
|
||||
);
|
||||
expect(homeOnly.status).toBe("ready");
|
||||
expect(host.configurations).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses registrations whose launcher only one MSIX package can see", async () => {
|
||||
const host = new FakeSetupHost("win32");
|
||||
host.hopper = join(PROFILE, "Hopper");
|
||||
host.registrationCommandBlocker = "Install REA outside the package.";
|
||||
host.clients = [
|
||||
{ name: "codex", displayName: "Codex", configPath: "/codex.toml" },
|
||||
];
|
||||
|
||||
const blocked = await runSetup(
|
||||
{ ...options(true), clientIds: ["codex"] },
|
||||
host,
|
||||
);
|
||||
expect(blocked.status).toBe("needs_human");
|
||||
expect(blocked.remediation).toBe("Install REA outside the package.");
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(host.skillInstalls).toBe(0);
|
||||
|
||||
const skillOnly = await runSetup(
|
||||
{ ...options(true), clientIds: [], installSkill: true },
|
||||
host,
|
||||
);
|
||||
expect(skillOnly.status).toBe("ready");
|
||||
expect(host.skillInstalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
return skillDestinations(this.homeDirectory, clientIds);
|
||||
}
|
||||
readonly registrationCommand: readonly string[];
|
||||
registrationCommandBlocker?: string;
|
||||
nodeVersion = "24.18.0";
|
||||
version: string | undefined = "14.5";
|
||||
distribution: LinuxDistribution | undefined;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
|
||||
import {
|
||||
readRegularFile,
|
||||
readRegularFileText,
|
||||
@@ -40,7 +41,10 @@ import type {
|
||||
ClientConfigurationResult,
|
||||
SetupProviderEnvironment,
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import {
|
||||
registrationWriteBlocker,
|
||||
type SetupClient,
|
||||
} from "./SupportedClients.js";
|
||||
|
||||
export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure<string> {
|
||||
constructor(
|
||||
@@ -187,6 +191,13 @@ const persistClientConfiguration = async ({
|
||||
desired,
|
||||
fallbackServers,
|
||||
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
|
||||
// The plan chose this file; an MSIX package may have switched to its
|
||||
// private copy since, for example when it quit.
|
||||
if (
|
||||
client.msixPackage !== undefined &&
|
||||
(await packageReadTargetChanged(client.msixPackage, client.configPath))
|
||||
)
|
||||
return { status: "failed", reason: "target_changed" };
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
let recoveryBackupPath: string | undefined;
|
||||
@@ -358,6 +369,25 @@ export const inspectClientConfiguration = async (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationInspection> => {
|
||||
const inspection = await inspectClientDocument(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
);
|
||||
if (inspection.status !== "create" && inspection.status !== "update")
|
||||
return inspection;
|
||||
// A change the running client would overwrite is not a persisted registration.
|
||||
const blocker = registrationWriteBlocker(client, "setup");
|
||||
return blocker === undefined
|
||||
? inspection
|
||||
: { status: "invalid", remediation: blocker };
|
||||
};
|
||||
|
||||
const inspectClientDocument = async (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationInspection> => {
|
||||
if (client.configPathError !== undefined)
|
||||
return { status: "invalid", remediation: client.configPathError };
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { SetupClient } from "./SupportedClients.js";
|
||||
export const failedConfigurationMessage = (
|
||||
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
|
||||
): string => {
|
||||
if (result.reason === "target_changed")
|
||||
return "The client began reading a different configuration file after the plan was shown, so no change was made. Rerun setup to review the new target.";
|
||||
if (result.reason === "path")
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
if (result.reason === "backup")
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
retryRegularFileCleanup,
|
||||
} from "./RegularFileRead.js";
|
||||
import { providerRegistrationEnvironment } from "./SetupRegistrationEnvironment.js";
|
||||
import {
|
||||
msixPackagesDirectory,
|
||||
packagePrivateLauncherBlocker,
|
||||
} from "./MsixPackageStorage.js";
|
||||
import type {
|
||||
SetupHost,
|
||||
SetupInitialState,
|
||||
@@ -66,6 +70,31 @@ export const setupRegistrationCommand = (
|
||||
]
|
||||
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
||||
|
||||
/**
|
||||
* The launcher setup registers, and why it must not be registered when it
|
||||
* exists only inside one MSIX package's private storage.
|
||||
*/
|
||||
const setupRegistration = (
|
||||
platform: NodeJS.Platform,
|
||||
homeDirectory: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Pick<SetupHost, "registrationCommand" | "registrationCommandBlocker"> => {
|
||||
const registrationCommand = setupRegistrationCommand(
|
||||
platform,
|
||||
invokingPackageRunner(environment, platform),
|
||||
);
|
||||
const registrationCommandBlocker =
|
||||
platform === "win32"
|
||||
? packagePrivateLauncherBlocker(
|
||||
registrationCommand,
|
||||
msixPackagesDirectory(homeDirectory, environment),
|
||||
)
|
||||
: undefined;
|
||||
return registrationCommandBlocker === undefined
|
||||
? { registrationCommand }
|
||||
: { registrationCommand, registrationCommandBlocker };
|
||||
};
|
||||
|
||||
export const filterClientsNeedingConfigure = async (
|
||||
host: SetupHost,
|
||||
detectedClients: readonly SetupClient[],
|
||||
@@ -117,10 +146,7 @@ export const systemSetupHost = (
|
||||
homeDirectory,
|
||||
skillDestinations: (clientIds) =>
|
||||
skillDestinations(homeDirectory, clientIds, environment, platform),
|
||||
registrationCommand: setupRegistrationCommand(
|
||||
platform,
|
||||
invokingPackageRunner(environment, platform),
|
||||
),
|
||||
...setupRegistration(platform, homeDirectory, environment),
|
||||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
linuxDistribution: readLinuxDistribution,
|
||||
|
||||
@@ -8,12 +8,18 @@ import {
|
||||
import type { SkillDestination } from "./SetupSkill.js";
|
||||
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
||||
import type {
|
||||
ClientConfigurationInspection,
|
||||
SetupAction,
|
||||
SetupClientState,
|
||||
SetupHost,
|
||||
SetupProviderEnvironment,
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import {
|
||||
appDataRedirection,
|
||||
registrationPersistenceDetail,
|
||||
type SetupClient,
|
||||
} from "./SupportedClients.js";
|
||||
import { redirectedWriteBlocker } from "./MsixPackageStorage.js";
|
||||
import type { DoctorReport } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
@@ -161,12 +167,58 @@ export const planSetupActions = async (input: {
|
||||
blocker: `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`,
|
||||
};
|
||||
}
|
||||
const clientPlans = inspections.flatMap(({ client, inspection }) =>
|
||||
const clientPlans = plannedClientWrites(
|
||||
inspections,
|
||||
input.discovery.installHopper,
|
||||
);
|
||||
const plannedActions = setupPlan({
|
||||
platform: input.host.platform,
|
||||
homeDirectory: input.host.homeDirectory,
|
||||
installHopper: input.discovery.installHopper,
|
||||
installSkill: input.installSkill,
|
||||
skillClientIds: input.skillClientIds,
|
||||
skillDestinations: input.host.skillDestinations(input.skillClientIds),
|
||||
clients: clientPlans,
|
||||
providerEnvironment: input.providerEnvironment,
|
||||
...(input.discovery.linuxPackageFamily === undefined
|
||||
? {}
|
||||
: { linuxPackageFamily: input.discovery.linuxPackageFamily }),
|
||||
command: input.command,
|
||||
});
|
||||
const blocker =
|
||||
(clientPlans.length > 0
|
||||
? input.host.registrationCommandBlocker
|
||||
: undefined) ??
|
||||
redirectedWriteBlocker(
|
||||
appDataRedirection(input.discovery.clients),
|
||||
plannedActions
|
||||
.filter(({ kind }) => kind !== "install_hopper")
|
||||
.map(({ target }) => target),
|
||||
"setup",
|
||||
);
|
||||
return {
|
||||
plannedActions,
|
||||
selectedClients,
|
||||
clientsToConfigure: selectedClients.filter((client) =>
|
||||
clientPlans.some(({ client: planned }) => planned.name === client.name),
|
||||
),
|
||||
...(blocker === undefined ? {} : { blocker }),
|
||||
};
|
||||
};
|
||||
|
||||
/** Selected clients whose registration setup writes, with the disclosed operation. */
|
||||
const plannedClientWrites = (
|
||||
inspections: readonly {
|
||||
readonly client: SetupClient;
|
||||
readonly inspection: ClientConfigurationInspection;
|
||||
}[],
|
||||
installHopper: boolean,
|
||||
) =>
|
||||
inspections.flatMap(({ client, inspection }) =>
|
||||
client.format !== "unsupported" &&
|
||||
(inspection.status === "create" ||
|
||||
inspection.status === "update" ||
|
||||
(input.discovery.installHopper &&
|
||||
inspection.status === "already_current"))
|
||||
(installHopper && inspection.status === "already_current"))
|
||||
? [
|
||||
{
|
||||
client,
|
||||
@@ -182,27 +234,6 @@ export const planSetupActions = async (input: {
|
||||
]
|
||||
: [],
|
||||
);
|
||||
return {
|
||||
plannedActions: setupPlan({
|
||||
platform: input.host.platform,
|
||||
homeDirectory: input.host.homeDirectory,
|
||||
installHopper: input.discovery.installHopper,
|
||||
installSkill: input.installSkill,
|
||||
skillClientIds: input.skillClientIds,
|
||||
skillDestinations: input.host.skillDestinations(input.skillClientIds),
|
||||
clients: clientPlans,
|
||||
providerEnvironment: input.providerEnvironment,
|
||||
...(input.discovery.linuxPackageFamily === undefined
|
||||
? {}
|
||||
: { linuxPackageFamily: input.discovery.linuxPackageFamily }),
|
||||
command: input.command,
|
||||
}),
|
||||
selectedClients,
|
||||
clientsToConfigure: selectedClients.filter((client) =>
|
||||
clientPlans.some(({ client: planned }) => planned.name === client.name),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** Describe every selected setup mutation before user approval. */
|
||||
const setupPlan = (input: {
|
||||
@@ -259,7 +290,7 @@ const setupPlan = (input: {
|
||||
label: client.displayName ?? client.name,
|
||||
target: client.configPath,
|
||||
detail: clientConfigurationDetail(
|
||||
client.displayName ?? client.name,
|
||||
client,
|
||||
input.command,
|
||||
input.providerEnvironment,
|
||||
input.installHopper,
|
||||
@@ -303,7 +334,7 @@ const majorVersion = (version: string): number =>
|
||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||
|
||||
const clientConfigurationDetail = (
|
||||
client: string,
|
||||
client: SetupClient,
|
||||
command: readonly string[],
|
||||
environment: SetupProviderEnvironment,
|
||||
installHopper: boolean,
|
||||
@@ -320,5 +351,6 @@ const clientConfigurationDetail = (
|
||||
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
|
||||
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
|
||||
: "";
|
||||
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
|
||||
const persistence = registrationPersistenceDetail(client);
|
||||
return `Add the REA MCP registration for ${client.displayName ?? client.name}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}${persistence === undefined ? "" : ` ${persistence}`}`;
|
||||
};
|
||||
|
||||
@@ -25,7 +25,12 @@ export type ClientConfigurationResult =
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "path" | "backup" | "write" | "readback";
|
||||
readonly reason:
|
||||
| "path"
|
||||
| "target_changed"
|
||||
| "backup"
|
||||
| "write"
|
||||
| "readback";
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
@@ -55,6 +60,8 @@ export interface SetupHost {
|
||||
readonly homeDirectory: string;
|
||||
skillDestinations(clientIds: readonly string[]): readonly SkillDestination[];
|
||||
readonly registrationCommand: readonly string[];
|
||||
/** Why no client may be registered with `registrationCommand` from this host. */
|
||||
readonly registrationCommandBlocker?: string;
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
resolveHermesClientHome,
|
||||
} from "./HermesClientHome.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { resolveWindowsClaudeDesktopLocation } from "./ClaudeDesktopConfigLocation.js";
|
||||
import type { MsixPackageView } from "./MsixPackageStorage.js";
|
||||
|
||||
/** One supported client configuration location. */
|
||||
export interface SetupClient {
|
||||
@@ -29,6 +31,10 @@ export interface SetupClient {
|
||||
/** Same-scope fallback ZCode stops loading after its first native server. */
|
||||
readonly zcodeFallbackPath?: string;
|
||||
readonly markerPath?: string;
|
||||
/** MSIX package storage that decides which configuration the client reads. */
|
||||
readonly msixPackage?: MsixPackageView;
|
||||
/** A running client can rewrite its configuration from memory. */
|
||||
readonly quitBeforeSetup?: boolean;
|
||||
readonly format?:
|
||||
| "json"
|
||||
| "toml"
|
||||
@@ -61,12 +67,35 @@ interface ClientPathContext {
|
||||
readonly env: Readonly<NodeJS.ProcessEnv>;
|
||||
}
|
||||
|
||||
interface ClientDefinition {
|
||||
/** One resolved configuration location and its detection evidence. */
|
||||
interface ClientLocation {
|
||||
readonly configPath: string;
|
||||
readonly markerPath: string;
|
||||
readonly msixPackage?: MsixPackageView;
|
||||
}
|
||||
|
||||
type ClientDefinition = ClientDefinitionBase &
|
||||
(
|
||||
| {
|
||||
readonly configPath: ClientPath;
|
||||
readonly markerPath: ClientPath;
|
||||
readonly location?: undefined;
|
||||
}
|
||||
| {
|
||||
/** Resolve configuration and marker together when they share probes. */
|
||||
readonly location: (
|
||||
context: ClientPathContext,
|
||||
) => Result<ClientLocation, ClientPathFailure>;
|
||||
readonly configPath?: undefined;
|
||||
readonly markerPath?: undefined;
|
||||
}
|
||||
);
|
||||
|
||||
interface ClientDefinitionBase {
|
||||
readonly name: string;
|
||||
readonly displayName: string;
|
||||
readonly configPath: ClientPath;
|
||||
readonly markerPath: ClientPath;
|
||||
readonly format: NonNullable<SetupClient["format"]>;
|
||||
readonly quitBeforeSetup?: boolean;
|
||||
readonly zcodeFallbackPath?: (context: ClientPathContext) => string;
|
||||
readonly skillPath?: ClientPath;
|
||||
readonly configPaths?: (context: ClientPathContext) => readonly string[];
|
||||
@@ -111,16 +140,20 @@ const traeUserDirectory = ({
|
||||
return join(xdgConfigHome({ home, platform, env }), "Trae", "User");
|
||||
};
|
||||
|
||||
const claudeDesktopDirectory = ({
|
||||
home,
|
||||
platform,
|
||||
env,
|
||||
}: ClientPathContext): string => {
|
||||
if (platform === "win32")
|
||||
return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "Claude");
|
||||
if (platform === "linux")
|
||||
return join(xdgConfigHome({ home, platform, env }), "Claude");
|
||||
return join(home, "Library", "Application Support", "Claude");
|
||||
/** Windows packaging decides which file Claude Desktop reads; see its resolver. */
|
||||
const claudeDesktopLocation = (
|
||||
context: ClientPathContext,
|
||||
): Result<ClientLocation, ClientPathFailure> => {
|
||||
if (context.platform === "win32")
|
||||
return resolveWindowsClaudeDesktopLocation(context.home, context.env);
|
||||
const directory =
|
||||
context.platform === "linux"
|
||||
? join(xdgConfigHome(context), "Claude")
|
||||
: join(context.home, "Library", "Application Support", "Claude");
|
||||
return ok({
|
||||
configPath: join(directory, "claude_desktop_config.json"),
|
||||
markerPath: directory,
|
||||
});
|
||||
};
|
||||
|
||||
const claudeCodeConfigDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
@@ -211,6 +244,55 @@ export const manualRegistrationRemediation = (
|
||||
? GROK_BOT_MANUAL_REGISTRATION_REMEDIATION
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Facts a setup plan must disclose so an approved registration persists:
|
||||
* which file an MSIX package reads, and that a running client can overwrite it.
|
||||
*/
|
||||
export const registrationPersistenceDetail = (
|
||||
client: SetupClient,
|
||||
): string | undefined => {
|
||||
const name = client.displayName ?? client.name;
|
||||
const msix = client.msixPackage;
|
||||
const packageDetail =
|
||||
msix === undefined
|
||||
? undefined
|
||||
: client.configPath === msix.privateConfigPath
|
||||
? `${name} is installed as MSIX package ${msix.packageFamilyName}, which reads this package-private file instead of ${msix.appDataConfigPath}.`
|
||||
: `${name} is installed as MSIX package ${msix.packageFamilyName}, which reads this file until it saves its own copy at ${msix.privateConfigPath}.`;
|
||||
const quitDetail =
|
||||
client.quitBeforeSetup === true
|
||||
? `Quit ${name} fully before approving; closing its window is not enough. A running ${name} can rewrite this file from memory and drop the registration. Start it again after setup.`
|
||||
: undefined;
|
||||
const details = [packageDetail, quitDetail].filter(
|
||||
(detail): detail is string => detail !== undefined,
|
||||
);
|
||||
return details.length === 0 ? undefined : details.join(" ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Why this process must not change an MSIX client's configuration. A process
|
||||
* the running app started sees its redirected AppData, and the app would
|
||||
* overwrite the change from memory; an unknown view cannot rule that out.
|
||||
*/
|
||||
export const registrationWriteBlocker = (
|
||||
client: SetupClient,
|
||||
operation: "setup" | "uninstall",
|
||||
): string | undefined => {
|
||||
const msix = client.msixPackage;
|
||||
if (msix === undefined || msix.processView === "direct") return undefined;
|
||||
const name = client.displayName ?? client.name;
|
||||
return msix.processView === "redirected"
|
||||
? `This process was started from ${name}'s MSIX package ${msix.packageFamilyName}: its AppData is redirected into that package, so ${name} is running and would overwrite ${msix.privateConfigPath} from memory. Quit ${name} fully, then rerun ${operation} from a terminal that ${name} did not start.`
|
||||
: `REA cannot tell whether ${name}'s MSIX package ${msix.packageFamilyName} started this process, because none of its files under ${dirname(msix.privateConfigPath)} could be compared with ${dirname(msix.appDataConfigPath)}. A running ${name} would overwrite the change. Open ${name} once if it has never run, quit it fully, then rerun ${operation} from a terminal that ${name} did not start.`;
|
||||
};
|
||||
|
||||
/** The MSIX package view, if any, that redirects this process's AppData writes. */
|
||||
export const appDataRedirection = (
|
||||
clients: readonly SetupClient[],
|
||||
): MsixPackageView | undefined =>
|
||||
clients.find(({ msixPackage }) => msixPackage?.processView === "redirected")
|
||||
?.msixPackage;
|
||||
|
||||
const OMP_PROFILE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
||||
const OMP_RESERVED_PROFILE =
|
||||
/^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/iu;
|
||||
@@ -408,9 +490,8 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
{
|
||||
name: "claude_desktop",
|
||||
displayName: "Claude Desktop",
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(claudeDesktopDirectory(context), "claude_desktop_config.json"),
|
||||
markerPath: claudeDesktopDirectory,
|
||||
location: claudeDesktopLocation,
|
||||
quitBeforeSetup: true,
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
@@ -627,6 +708,19 @@ const resolvePath = (
|
||||
return typeof resolved === "string" ? ok(resolved) : resolved;
|
||||
};
|
||||
|
||||
const resolveLocation = (
|
||||
definition: ClientDefinition,
|
||||
context: ClientPathContext,
|
||||
): Result<ClientLocation, ClientPathFailure> => {
|
||||
if (definition.location !== undefined) return definition.location(context);
|
||||
const config = resolvePath(definition.configPath, context);
|
||||
if (!config.ok) return config;
|
||||
const marker = resolvePath(definition.markerPath, context);
|
||||
return marker.ok
|
||||
? ok({ configPath: config.value, markerPath: marker.value })
|
||||
: marker;
|
||||
};
|
||||
|
||||
/** Describe every client location that setup, doctor, or uninstall may inspect. */
|
||||
export const supportedClients = (
|
||||
home: string,
|
||||
@@ -637,27 +731,16 @@ export const supportedClients = (
|
||||
const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
|
||||
return definitions.map((definition) => {
|
||||
try {
|
||||
const config = resolvePath(definition.configPath, context);
|
||||
const marker = config.ok
|
||||
? resolvePath(definition.markerPath, context)
|
||||
: config;
|
||||
if (!config.ok)
|
||||
const location = resolveLocation(definition, context);
|
||||
if (!location.ok)
|
||||
return {
|
||||
name: definition.name,
|
||||
displayName: definition.displayName,
|
||||
format: definition.format,
|
||||
configPath: config.error.path,
|
||||
configPathError: config.error.message,
|
||||
configPath: location.error.path,
|
||||
configPathError: location.error.message,
|
||||
};
|
||||
if (!marker.ok)
|
||||
return {
|
||||
name: definition.name,
|
||||
displayName: definition.displayName,
|
||||
format: definition.format,
|
||||
configPath: marker.error.path,
|
||||
configPathError: marker.error.message,
|
||||
};
|
||||
const configPath = config.value;
|
||||
const { configPath, markerPath, msixPackage } = location.value;
|
||||
return {
|
||||
name: definition.name,
|
||||
displayName: definition.displayName,
|
||||
@@ -675,7 +758,11 @@ export const supportedClients = (
|
||||
...(definition.zcodeFallbackPath === undefined
|
||||
? {}
|
||||
: { zcodeFallbackPath: definition.zcodeFallbackPath(context) }),
|
||||
markerPath: marker.value,
|
||||
markerPath,
|
||||
...(msixPackage === undefined ? {} : { msixPackage }),
|
||||
...(definition.quitBeforeSetup === true
|
||||
? { quitBeforeSetup: true }
|
||||
: {}),
|
||||
format: definition.format,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
|
||||
+157
-33
@@ -32,10 +32,16 @@ import {
|
||||
} from "./RegularFileRead.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
appDataRedirection,
|
||||
manualRegistrationRemediation,
|
||||
registrationWriteBlocker,
|
||||
supportedClients,
|
||||
type SetupClient,
|
||||
} from "./SupportedClients.js";
|
||||
import {
|
||||
redirectedWriteBlocker,
|
||||
type MsixPackageView,
|
||||
} from "./MsixPackageStorage.js";
|
||||
export { isUninstallFailure } from "./UninstallFailure.js";
|
||||
|
||||
interface ManagedPathStats {
|
||||
@@ -93,6 +99,7 @@ export interface UninstallHost {
|
||||
/** Report a configuration that cannot be read safely, before any removal. */
|
||||
inspectClient(client: SetupClient): Promise<UninstallItem | undefined>;
|
||||
removeClient(client: SetupClient): Promise<UninstallItem>;
|
||||
inspectSkill?(): Promise<UninstallItem | undefined>;
|
||||
removeSkill(): Promise<UninstallItem>;
|
||||
purgeData(): Promise<readonly UninstallItem[]>;
|
||||
close?(): Promise<string | undefined>;
|
||||
@@ -118,6 +125,8 @@ export const runUninstall = async (
|
||||
const blocking = await host.inspectClient(client);
|
||||
if (blocking !== undefined) blocked.push(blocking);
|
||||
}
|
||||
const skillBlocker = await host.inspectSkill?.();
|
||||
if (skillBlocker !== undefined) blocked.push(skillBlocker);
|
||||
const items: UninstallItem[] =
|
||||
blocked.length > 0
|
||||
? [
|
||||
@@ -209,27 +218,70 @@ export const systemUninstallHost = (
|
||||
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const fileSystem = selectedFileSystem ?? systemFileSystem(resources);
|
||||
// Set with the client list: the package view that redirects this
|
||||
// process's AppData writes, if any.
|
||||
let redirection: MsixPackageView | undefined;
|
||||
const clientWriteBlocker = (client: SetupClient): string | undefined =>
|
||||
registrationWriteBlocker(client, "uninstall") ??
|
||||
redirectedWriteBlocker(redirection, [client.configPath], "uninstall");
|
||||
const skillWriteBlocker = (path: string): string | undefined =>
|
||||
redirectedWriteBlocker(redirection, [path], "uninstall");
|
||||
return {
|
||||
clients: () =>
|
||||
Promise.resolve(
|
||||
supportedClients(home, platform, environment).flatMap((client) =>
|
||||
client.configPaths === undefined
|
||||
? [client]
|
||||
: client.configPaths.map((configPath) => ({
|
||||
...client,
|
||||
configPath,
|
||||
})),
|
||||
clients: () => {
|
||||
const clients = supportedClients(home, platform, environment);
|
||||
redirection = appDataRedirection(clients);
|
||||
return Promise.resolve(
|
||||
clients.flatMap((client) =>
|
||||
uninstallConfigPaths(client).map((configPath) => ({
|
||||
...client,
|
||||
configPath,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
inspectClient: async (client) => {
|
||||
const read = await readClientConfiguration(client, fileSystem);
|
||||
return read.kind === "item" && read.item.status === "failed"
|
||||
? read.item
|
||||
: undefined;
|
||||
if (read.kind === "item")
|
||||
return read.item.status === "failed" ? read.item : undefined;
|
||||
const removal = clientRegistrationRemoval(read.parsed, client);
|
||||
if (!removal.removeNative && removal.legacyPath === undefined)
|
||||
return undefined;
|
||||
// A removal the running package would revert, or redirect into its
|
||||
// private storage, would leave the registration in place.
|
||||
const blocker = clientWriteBlocker(client);
|
||||
return blocker === undefined
|
||||
? undefined
|
||||
: item(client.name, "failed", blocker);
|
||||
},
|
||||
removeClient: (client) =>
|
||||
removeClient(client, fileSystem, () => clientWriteBlocker(client)),
|
||||
inspectSkill: async () => {
|
||||
for (const destination of skillDestinations(
|
||||
home,
|
||||
undefined,
|
||||
environment,
|
||||
platform,
|
||||
)) {
|
||||
const inspected = await inspectManagedSkill(
|
||||
destination.path,
|
||||
destination.client,
|
||||
fileSystem,
|
||||
);
|
||||
if (inspected?.status === "failed") return inspected;
|
||||
if (inspected !== undefined) continue;
|
||||
const blocker = skillWriteBlocker(destination.path);
|
||||
if (blocker !== undefined) return item("skill", "failed", blocker);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
removeClient: (client) => removeClient(client, fileSystem),
|
||||
removeSkill: () =>
|
||||
removeManagedSkills(home, fileSystem, environment, platform),
|
||||
removeManagedSkills(
|
||||
home,
|
||||
fileSystem,
|
||||
environment,
|
||||
platform,
|
||||
skillWriteBlocker,
|
||||
),
|
||||
purgeData: () => purgeManagedData(join(home, ".rea"), fileSystem),
|
||||
close: async () => {
|
||||
try {
|
||||
@@ -242,6 +294,36 @@ export const systemUninstallHost = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Every user file that can hold REA's registration for one client. An MSIX
|
||||
* package can read either its private copy or the AppData file, and earlier
|
||||
* setups wrote the AppData file, so both lose REA's entry.
|
||||
*/
|
||||
const uninstallConfigPaths = (client: SetupClient): readonly string[] => [
|
||||
...new Set([
|
||||
...(client.configPaths ?? [client.configPath]),
|
||||
...(client.msixPackage === undefined
|
||||
? []
|
||||
: [
|
||||
client.msixPackage.privateConfigPath,
|
||||
client.msixPackage.appDataConfigPath,
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
/** Whether removal would change this configuration. */
|
||||
const registrationPresent = (parsed: ClientConfigurationDocument): boolean => {
|
||||
const name = PRODUCT_IDENTITY.mcpServerKey;
|
||||
return (
|
||||
Object.hasOwn(parsed.servers, name) ||
|
||||
// Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`.
|
||||
legacyClientServerPath(parsed, name) !== undefined ||
|
||||
// Harness ownership is the stable entry id. A stale or malformed launcher
|
||||
// still belongs to that id, and so does a direct patch that projects no row.
|
||||
(parsed.dialect === "dsh" && dshDocument(parsed.document).carriesEntry)
|
||||
);
|
||||
};
|
||||
|
||||
type ClientConfigurationRead =
|
||||
| { readonly kind: "item"; readonly item: UninstallItem }
|
||||
| {
|
||||
@@ -308,21 +390,47 @@ const itemRead = (result: UninstallItem): ClientConfigurationRead => ({
|
||||
item: result,
|
||||
});
|
||||
|
||||
/**
|
||||
* Share the exact ownership and applicability decision with preflight. A
|
||||
* removable legacy entry carries its document path, so removal edits exactly
|
||||
* the entry this decision admitted.
|
||||
*/
|
||||
const clientRegistrationRemoval = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
client: SetupClient,
|
||||
): {
|
||||
readonly removeNative: boolean;
|
||||
readonly legacyPath: readonly string[] | undefined;
|
||||
} => {
|
||||
const name = PRODUCT_IDENTITY.mcpServerKey;
|
||||
if (
|
||||
parsed.dialect === "dsh" &&
|
||||
dshDocument(parsed.document).hasUnknownStructure
|
||||
)
|
||||
return { removeNative: false, legacyPath: undefined };
|
||||
const legacyPath = legacyClientServerPath(parsed, name);
|
||||
return {
|
||||
removeNative:
|
||||
(parsed.dialect === "dsh" && dshDocument(parsed.document).carriesEntry) ||
|
||||
(Object.hasOwn(parsed.servers, name) &&
|
||||
isOwnedRegistration(parsed.servers[name], client)),
|
||||
legacyPath:
|
||||
legacyPath !== undefined &&
|
||||
isOwnedRegistration(parsed.legacyServers[name], client)
|
||||
? legacyPath
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const removeClient = async (
|
||||
client: SetupClient,
|
||||
fileSystem: UninstallFileSystem,
|
||||
writeBlocker?: () => string | undefined,
|
||||
): Promise<UninstallItem> => {
|
||||
const read = await readClientConfiguration(client, fileSystem);
|
||||
if (read.kind === "item") return read.item;
|
||||
const { transactionPath, original, parsed } = read;
|
||||
const name = PRODUCT_IDENTITY.mcpServerKey;
|
||||
// Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`.
|
||||
const legacyPath = legacyClientServerPath(parsed, name);
|
||||
const hasNative = Object.hasOwn(parsed.servers, name);
|
||||
// Harness ownership is the stable entry id. A stale or malformed launcher
|
||||
// still belongs to that id, and so does a direct patch that projects no row.
|
||||
const carriesDshEntry =
|
||||
parsed.dialect === "dsh" && dshDocument(parsed.document).carriesEntry;
|
||||
if (
|
||||
parsed.dialect === "dsh" &&
|
||||
dshDocument(parsed.document).hasUnknownStructure
|
||||
@@ -332,20 +440,21 @@ const removeClient = async (
|
||||
"retained",
|
||||
"A Cordis expression may produce registration rows, so the patch was retained without changes.",
|
||||
);
|
||||
if (!hasNative && legacyPath === undefined && !carriesDshEntry)
|
||||
if (!registrationPresent(parsed))
|
||||
return item(client.name, "skipped", "REA registration is absent.");
|
||||
const removeNative =
|
||||
carriesDshEntry ||
|
||||
(hasNative && isOwnedRegistration(parsed.servers[name], client));
|
||||
const removeLegacy =
|
||||
legacyPath !== undefined &&
|
||||
isOwnedRegistration(parsed.legacyServers[name], client);
|
||||
const { removeNative, legacyPath } = clientRegistrationRemoval(
|
||||
parsed,
|
||||
client,
|
||||
);
|
||||
const removeLegacy = legacyPath !== undefined;
|
||||
if (!removeNative && !removeLegacy)
|
||||
return item(
|
||||
client.name,
|
||||
"retained",
|
||||
"The rea-named registration is not REA-owned.",
|
||||
);
|
||||
const blocker = writeBlocker?.();
|
||||
if (blocker !== undefined) return item(client.name, "failed", blocker);
|
||||
const remaining = { ...parsed.servers };
|
||||
if (removeNative) delete remaining[name];
|
||||
const document = withClientServers(
|
||||
@@ -355,7 +464,7 @@ const removeClient = async (
|
||||
);
|
||||
const editedPaths = [
|
||||
...(removeNative ? [clientServerPath(parsed, name)] : []),
|
||||
...(removeLegacy ? [legacyPath] : []),
|
||||
...(legacyPath === undefined ? [] : [legacyPath]),
|
||||
];
|
||||
const backupPath = `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
@@ -516,12 +625,13 @@ const removeManagedSkills = async (
|
||||
fileSystem: UninstallFileSystem,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
writeBlocker?: (path: string) => string | undefined,
|
||||
): Promise<UninstallItem> => {
|
||||
// Every destination shares one summary item, so each result is labeled by
|
||||
// the directory it refers to.
|
||||
const results = await Promise.all(
|
||||
skillDestinations(home, undefined, environment, platform).map(({ path }) =>
|
||||
removeManagedSkill(path, path, fileSystem),
|
||||
removeManagedSkill(path, path, fileSystem, writeBlocker),
|
||||
),
|
||||
);
|
||||
const failed = results.find(({ status }) => status === "failed");
|
||||
@@ -552,11 +662,11 @@ const removeManagedSkills = async (
|
||||
);
|
||||
};
|
||||
|
||||
const removeManagedSkill = async (
|
||||
const inspectManagedSkill = async (
|
||||
path: string,
|
||||
name: string,
|
||||
fileSystem: UninstallFileSystem,
|
||||
): Promise<UninstallItem> => {
|
||||
): Promise<UninstallItem | undefined> => {
|
||||
let root: ManagedPathStats;
|
||||
try {
|
||||
root = await fileSystem.stat(path);
|
||||
@@ -594,6 +704,20 @@ const removeManagedSkill = async (
|
||||
if (!isManagedSkillManifest(content))
|
||||
return item(name, "retained", "Skill manifest is not REA-owned.");
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const removeManagedSkill = async (
|
||||
path: string,
|
||||
name: string,
|
||||
fileSystem: UninstallFileSystem,
|
||||
writeBlocker?: (path: string) => string | undefined,
|
||||
): Promise<UninstallItem> => {
|
||||
const inspection = await inspectManagedSkill(path, name, fileSystem);
|
||||
if (inspection !== undefined) return inspection;
|
||||
const blocker = writeBlocker?.(path);
|
||||
if (blocker !== undefined) return item(name, "failed", blocker);
|
||||
|
||||
const manifestName = "SKILL.md";
|
||||
const otherFiles = MANAGED_SKILL_FILES.filter(
|
||||
(relativePath) => relativePath !== manifestName,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { AnalysisProviderCandidate } from "../AnalysisProvider.js";
|
||||
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js";
|
||||
|
||||
/** Profile-resolution truth for one discovered provider candidate. */
|
||||
export interface AnalysisProviderCandidateEvaluation {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BigIntStats, Stats } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -88,3 +89,36 @@ it("compares available nanosecond timestamps without rounding away source change
|
||||
sameRegularFileState(before, { ...before, ctimeNs: before.ctimeNs + 1n }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["EACCES", -13, "keeps"],
|
||||
["ENXIO", -6, "reclassifies"],
|
||||
] as const)(
|
||||
"%s from opening a non-regular path: admission %s the failure",
|
||||
async (code, errno, outcome) => {
|
||||
const failure = Object.assign(new Error(`open ${code}`), { code, errno });
|
||||
mockOpen.mockRejectedValue(failure);
|
||||
// The selected path itself is a directory that stat can still describe.
|
||||
const cause = await openRegularFile(tmpdir(), { symlinks: "follow" }).catch(
|
||||
(rejected: unknown) => rejected,
|
||||
);
|
||||
if (outcome === "keeps") expect(cause).toBe(failure);
|
||||
else expect(cause).toBeInstanceOf(NonRegularFileReadError);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps cancellation that arrives during the non-regular metadata probe", async () => {
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error("cancelled during probe");
|
||||
mockOpen.mockImplementation(async () => {
|
||||
// The probe stats the path after this rejection; abort before it settles.
|
||||
queueMicrotask(() => controller.abort(cancellation));
|
||||
throw Object.assign(new Error("open ENXIO"), { code: "ENXIO", errno: -6 });
|
||||
});
|
||||
await expect(
|
||||
openRegularFile(tmpdir(), {
|
||||
symlinks: "follow",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toBe(cancellation);
|
||||
});
|
||||
|
||||
@@ -289,4 +289,25 @@ describe("safe CDP response metadata referrer policy", () => {
|
||||
expect(captured.response.policies.referrer_policy).toBe(expected);
|
||||
expect(captured.response.policies.coop).toBe("same-origin");
|
||||
});
|
||||
|
||||
it.each<[string | number | undefined, number | null]>([
|
||||
["100", 100],
|
||||
[" 0 ", 0],
|
||||
[100, 100],
|
||||
["-5", null],
|
||||
["+12", null],
|
||||
["0x10", null],
|
||||
["1e3", null],
|
||||
["9007199254740991", 9007199254740991],
|
||||
["9007199254740992", null],
|
||||
[undefined, null],
|
||||
])("declares Content-Length %j only for decimal digits", (raw, expected) => {
|
||||
const captured = safeResponseMetadata(
|
||||
"request-length",
|
||||
`${origin}/api`,
|
||||
{ headers: raw === undefined ? {} : { "Content-Length": raw } },
|
||||
new Set([origin]),
|
||||
);
|
||||
expect(captured.response.content_length).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import {
|
||||
numberValue,
|
||||
cdpStringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { cdpStringValue, type UnknownRecord } from "./CdpCaptureValues.js";
|
||||
|
||||
type ResponseMetadata = WebPageInspection["metadata"]["responses"][number];
|
||||
type LinkMetadata = ResponseMetadata["links"][number];
|
||||
@@ -43,7 +39,7 @@ export const safeResponseMetadata = (
|
||||
request_id: requestId,
|
||||
url,
|
||||
mime_type: boundedHeader(cdpStringValue(response.mimeType)),
|
||||
content_length: nonnegativeInteger(headers.get("content-length")),
|
||||
content_length: contentLength(headers.get("content-length")),
|
||||
content_encoding: boundedHeader(headers.get("content-encoding")),
|
||||
csp,
|
||||
links,
|
||||
@@ -343,9 +339,12 @@ const policyToken = (value: string | undefined): string | null => {
|
||||
return /^[a-z][a-z0-9_.\x2d]*$/u.test(token) ? token : null;
|
||||
};
|
||||
|
||||
const nonnegativeInteger = (value: string | undefined): number | null => {
|
||||
const number = numberValue(value === undefined ? undefined : Number(value));
|
||||
return number === undefined ? null : Math.max(0, Math.trunc(number));
|
||||
// RFC 9110 section 8.6: Content-Length = 1*DIGIT. Other spellings declare no length.
|
||||
const contentLength = (value: string | undefined): number | null => {
|
||||
const token = (value ?? "").trim();
|
||||
if (!/^[0-9]+$/u.test(token)) return null;
|
||||
const length = Number(token);
|
||||
return Number.isSafeInteger(length) ? length : null;
|
||||
};
|
||||
|
||||
const boundedHeader = (value: string | undefined): string | null =>
|
||||
|
||||
@@ -175,7 +175,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
||||
];
|
||||
phase = "workspace";
|
||||
root = await this.createRuntime();
|
||||
this.#resources.retain(root, undefined, operation);
|
||||
this.#resources.retain(root, undefined);
|
||||
const snapshotPath = join(root.path, "capture");
|
||||
const configurationPath = join(root.path, "configuration");
|
||||
await mkdir(configurationPath, { mode: 0o700 });
|
||||
@@ -406,7 +406,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.cleanupOwner !== undefined
|
||||
)
|
||||
this.#resources.retain(root, cause.cleanupOwner, operation);
|
||||
this.#resources.retain(root, cause.cleanupOwner);
|
||||
result = err(
|
||||
this.#failure(
|
||||
cause,
|
||||
|
||||
@@ -99,13 +99,20 @@ export const registerAndroidCommands = (
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.traceAndroidReferences, {
|
||||
description: "Trace incoming APK class or unique-method references",
|
||||
description:
|
||||
"Trace incoming APK using nodes for a class or selected method",
|
||||
args: z.object({ path, className }),
|
||||
options: z.object({
|
||||
methodName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Unique declared method name; omit for class references"),
|
||||
.describe("Declared method name; omit for class references"),
|
||||
overloadIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe("Index among same-name methods from inspect-android-class"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.traceAndroidReferences, "trace_android_references", {
|
||||
@@ -114,6 +121,9 @@ export const registerAndroidCommands = (
|
||||
...(options.methodName === undefined
|
||||
? {}
|
||||
: { method_name: options.methodName }),
|
||||
...(options.overloadIndex === undefined
|
||||
? {}
|
||||
: { overload_index: options.overloadIndex }),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
+107
-80
@@ -32,89 +32,116 @@ export const registerUtilityCommands = (
|
||||
registerCapabilityCommands(cli, logger, analysis);
|
||||
registerNativeCommands(cli, logger, runProviderAnalysis);
|
||||
registerNativeCallCommands(cli, logger, runProviderAnalysis);
|
||||
for (const [command, operation] of [
|
||||
[CLI_COMMANDS.observeNativeUi, "observe_native_ui"],
|
||||
[CLI_COMMANDS.captureNativeUiScenario, "capture_native_ui_scenario"],
|
||||
] as const) {
|
||||
cli.command(command, {
|
||||
description:
|
||||
"Observe or run a scenario in one exact native application window",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local native executable or app path"),
|
||||
}),
|
||||
options: z.object({
|
||||
pid: z.number().int().positive().describe("Existing application PID"),
|
||||
windowId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe("Exact selected application window ID"),
|
||||
steps: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("JSON array of declarative AX actions or waits"),
|
||||
screenshot: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Capture only the selected window"),
|
||||
accessibility: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Read the selected window accessibility tree"),
|
||||
maxNodes: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.safe()
|
||||
.default(500)
|
||||
.describe("Maximum accessibility nodes per capture"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, command, async () => {
|
||||
const parameters = {
|
||||
pid: options.pid,
|
||||
window_id: options.windowId,
|
||||
screenshot: options.screenshot,
|
||||
accessibility: options.accessibility,
|
||||
max_nodes: options.maxNodes,
|
||||
};
|
||||
if (operation === "capture_native_ui_scenario") {
|
||||
const decoded =
|
||||
options.steps === undefined
|
||||
? { ok: true as const, value: undefined }
|
||||
: safeParseJson(options.steps);
|
||||
if (!decoded.ok)
|
||||
return invalidNativeUiScenarioInput(operation, [
|
||||
{ path: ["steps"], reason: "invalid_format", expected: "JSON" },
|
||||
]);
|
||||
const parsed = nativeUiScenarioInputSchema.safeParse({
|
||||
...parameters,
|
||||
steps: decoded.value,
|
||||
});
|
||||
if (!parsed.success)
|
||||
return invalidNativeUiScenarioInput(
|
||||
operation,
|
||||
projectInputIssues(parsed.error.issues, {
|
||||
...parameters,
|
||||
...(options.steps === undefined
|
||||
? {}
|
||||
: { steps: decoded.value }),
|
||||
}),
|
||||
);
|
||||
return runProviderAnalysis(
|
||||
args.path,
|
||||
operation,
|
||||
parsed.data,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
return runProviderAnalysis(args.path, operation, parameters, logger);
|
||||
}),
|
||||
});
|
||||
}
|
||||
registerNativeUiCommands(cli, logger, runProviderAnalysis);
|
||||
registerReferenceSourceCommand(cli, logger, environment);
|
||||
};
|
||||
|
||||
const nativeUiScopeOptions = {
|
||||
pid: z.number().int().positive().describe("Existing application PID"),
|
||||
windowId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe("Exact selected application window ID"),
|
||||
screenshot: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Capture only the selected window"),
|
||||
accessibility: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Read the selected window accessibility tree"),
|
||||
maxNodes: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.safe()
|
||||
.default(500)
|
||||
.describe("Maximum accessibility nodes per capture"),
|
||||
};
|
||||
|
||||
const nativeUiScope = (options: {
|
||||
readonly pid: number;
|
||||
readonly windowId: number;
|
||||
readonly screenshot: boolean;
|
||||
readonly accessibility: boolean;
|
||||
readonly maxNodes: number;
|
||||
}) => ({
|
||||
pid: options.pid,
|
||||
window_id: options.windowId,
|
||||
screenshot: options.screenshot,
|
||||
accessibility: options.accessibility,
|
||||
max_nodes: options.maxNodes,
|
||||
});
|
||||
|
||||
/**
|
||||
* Passive observation and active scenarios have different input contracts.
|
||||
* Only the scenario command accepts steps, so passive observation rejects
|
||||
* them as an unknown flag instead of silently dropping them.
|
||||
*/
|
||||
const registerNativeUiCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
runProviderAnalysis: DirectAnalysis["runProviderAnalysis"],
|
||||
): void => {
|
||||
const pathArgument = z.object({
|
||||
path: z.string().describe("Local native executable or app path"),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.observeNativeUi, {
|
||||
description:
|
||||
"Observe one already-running native application window by PID and window ID",
|
||||
args: pathArgument,
|
||||
options: z.object(nativeUiScopeOptions),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.observeNativeUi, () =>
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
"observe_native_ui",
|
||||
nativeUiScope(options),
|
||||
logger,
|
||||
),
|
||||
),
|
||||
});
|
||||
const operation = "capture_native_ui_scenario";
|
||||
cli.command(CLI_COMMANDS.captureNativeUiScenario, {
|
||||
description:
|
||||
"Run declarative AX actions or waits in one exact native application window",
|
||||
args: pathArgument,
|
||||
options: z.object({
|
||||
...nativeUiScopeOptions,
|
||||
steps: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("JSON array of declarative AX actions or waits"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.captureNativeUiScenario, async () => {
|
||||
const parameters = nativeUiScope(options);
|
||||
const decoded =
|
||||
options.steps === undefined
|
||||
? { ok: true as const, value: undefined }
|
||||
: safeParseJson(options.steps);
|
||||
if (!decoded.ok)
|
||||
return invalidNativeUiScenarioInput(operation, [
|
||||
{ path: ["steps"], reason: "invalid_format", expected: "JSON" },
|
||||
]);
|
||||
const parsed = nativeUiScenarioInputSchema.safeParse({
|
||||
...parameters,
|
||||
steps: decoded.value,
|
||||
});
|
||||
if (!parsed.success)
|
||||
return invalidNativeUiScenarioInput(
|
||||
operation,
|
||||
projectInputIssues(parsed.error.issues, {
|
||||
...parameters,
|
||||
...(options.steps === undefined ? {} : { steps: decoded.value }),
|
||||
}),
|
||||
);
|
||||
return runProviderAnalysis(args.path, operation, parsed.data, logger);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const invalidNativeUiScenarioInput = (
|
||||
operation: string,
|
||||
issues: ConstructorParameters<typeof AnalysisInputError>[2],
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ANDROID_TOOL_CONTRACTS = [
|
||||
name: "inspect_android_class",
|
||||
...toolContractMetadata("inspect_android_class"),
|
||||
description:
|
||||
"Inspect one exact Android class's parsed methods, fields and inner classes, including synthetic members. Returns display signatures and explicit unknown DEX descriptors without generating source. Overload indices remain consistent within the artifact and engine session.",
|
||||
"Inspect one exact Android class's parsed methods, fields and inner classes, including synthetic members. Returns display signatures and exact DEX prototypes without generating source. A prototype is not a complete raw method ID; overload indices remain consistent within the artifact and engine session.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.inspect_android_class,
|
||||
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_class),
|
||||
@@ -66,7 +66,7 @@ export const ANDROID_TOOL_CONTRACTS = [
|
||||
name: "inspect_android_method",
|
||||
...toolContractMetadata("inspect_android_method"),
|
||||
description:
|
||||
"Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports representation, fallback markers and source truncation; never treats display signatures as exact DEX identities.",
|
||||
"Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports its DEX prototype, representation, fallback markers and source truncation; display signatures may be aliases.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.inspect_android_method,
|
||||
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_method),
|
||||
@@ -85,7 +85,7 @@ export const ANDROID_TOOL_CONTRACTS = [
|
||||
name: "trace_android_references",
|
||||
...toolContractMetadata("trace_android_references"),
|
||||
description:
|
||||
"Trace incoming static references to an exact Android class or unique method. Overloaded method reference queries are explicitly unsupported by the current engine. Source lines and DEX offsets remain unknown; no runtime calls are inferred.",
|
||||
"Trace incoming static using nodes to an exact Android class or selected method overload. The total counts containing nodes, not individual invoke instructions. Use inspect_android_class to choose an overload_index when names collide. Source lines and the containing node dex_offset remain unknown. direct_invokes carries exact DEX code-unit instruction offsets when available; direct_invokes_exhaustive false marks byte-bounded partial instruction evidence, and null means coverage is unknown. Using-node coverage remains separate; no runtime calls are inferred.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.trace_android_references,
|
||||
outputSchema: evidenceResultOf(
|
||||
|
||||
@@ -40,7 +40,15 @@ export const androidInputSchemas = {
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Omit for class references; ambiguous overloaded methods are unsupported",
|
||||
"Omit for class references; select ambiguous methods with overload_index",
|
||||
),
|
||||
overload_index: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe(
|
||||
"Index among same-name methods from inspect_android_class; requires method_name",
|
||||
),
|
||||
}),
|
||||
} as const;
|
||||
@@ -90,12 +98,19 @@ const textCoverage = z.strictObject({
|
||||
text: z.string(),
|
||||
reported_total_bytes: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
/** A DEX prototype: parameter descriptors followed by the return descriptor. */
|
||||
export const dexDescriptorSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^\((?:\[*(?:[ZBSCIJFD]|L[^;()]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;()]+;))$/u,
|
||||
);
|
||||
|
||||
const method = z.strictObject({
|
||||
name: z.string(),
|
||||
reported_signature: z.string(),
|
||||
overload_index: z.number().int().nonnegative(),
|
||||
is_constructor: z.boolean(),
|
||||
dex_descriptor: z.null(),
|
||||
dex_descriptor: dexDescriptorSchema,
|
||||
});
|
||||
const engine = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
@@ -159,6 +174,9 @@ export const androidResultSchemas = {
|
||||
...common,
|
||||
class_name: z.string(),
|
||||
method_name: z.string().nullable(),
|
||||
overload_index: z.number().int().nonnegative().nullable(),
|
||||
reported_signature: z.string().nullable(),
|
||||
dex_descriptor: dexDescriptorSchema.nullable(),
|
||||
total: z.number().int().nonnegative(),
|
||||
references: z.array(
|
||||
z.strictObject({
|
||||
@@ -169,8 +187,23 @@ export const androidResultSchemas = {
|
||||
top_class: z.string(),
|
||||
source_line: z.null(),
|
||||
dex_offset: z.null(),
|
||||
/** false means transport-bounded instruction evidence; null/omitted is unknown. */
|
||||
direct_invokes_exhaustive: z.boolean().nullable().optional(),
|
||||
/** Raw direct invoke instructions inside this using node, when available. */
|
||||
direct_invokes: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
dex_entry: z.string().min(1),
|
||||
source_raw_method_id: z.string().min(1),
|
||||
dex_code_unit_offset: z.number().int().nonnegative(),
|
||||
dex_method_index: z.number().int().nonnegative(),
|
||||
opcode: z.string().regex(/^INVOKE_/u),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
coverage: z.literal("complete"),
|
||||
reference_unit: z.literal("using_node"),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
@@ -2,6 +2,14 @@ import { expect, it } from "vitest";
|
||||
import { evmInterfaceSchema } from "./evmInterface.js";
|
||||
import { evmInterfaceFixture } from "../../../tests/fixtures/evm/interface.js";
|
||||
|
||||
it("accepts historical metadata omission as an unknown facet without changing the source", () => {
|
||||
const value = evmInterfaceFixture();
|
||||
const { metadata: _metadata, ...oldBytecode } = value.bytecode;
|
||||
const old = { ...value, bytecode: oldBytecode };
|
||||
expect(evmInterfaceSchema.parse(old).bytecode.metadata).toBeNull();
|
||||
expect(old.bytecode).not.toHaveProperty("metadata");
|
||||
});
|
||||
|
||||
it.each(["byte-count", "body-offset", "selector", "authenticity"])(
|
||||
"rejects false interface evidence: %s",
|
||||
(problem) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { solidityMetadataSchema } from "./solidityMetadata.js";
|
||||
|
||||
const byteOffset = z.number().int().nonnegative();
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
@@ -34,6 +35,7 @@ export const evmInterfaceSchema = z
|
||||
kind: z.literal("unknown"),
|
||||
deployment_authenticity: z.literal("unknown"),
|
||||
hardfork: z.literal("unknown"),
|
||||
metadata: solidityMetadataSchema.nullable().default(null),
|
||||
}),
|
||||
evidence_kind: z.literal("inferred"),
|
||||
functions: z.array(
|
||||
@@ -62,6 +64,23 @@ export const evmInterfaceSchema = z
|
||||
message:
|
||||
"Selected byte count does not match the bytecode hex representation.",
|
||||
});
|
||||
const metadata = value.bytecode.metadata;
|
||||
if (
|
||||
metadata !== null &&
|
||||
metadata.status !== "unavailable" &&
|
||||
(metadata.bytecode_offset === null ||
|
||||
metadata.cbor_bytes === null ||
|
||||
metadata.bytecode_offset + metadata.cbor_bytes + 2 !==
|
||||
value.bytecode.bytes ||
|
||||
metadata.cbor_bytes !==
|
||||
Number.parseInt(value.bytecode.hex.slice(-4), 16))
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["bytecode", "metadata"],
|
||||
message:
|
||||
"Metadata range must match the terminal length suffix in selected bytecode.",
|
||||
});
|
||||
for (const [index, fn] of value.functions.entries())
|
||||
if (fn.bytecode_offset >= value.bytecode.bytes)
|
||||
context.addIssue({
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Scalar view of CBOR; uninterpreted containers remain in original bytecode. */
|
||||
export const solidityMetadataScalarSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({ kind: z.literal("text"), value: z.string().max(65535) }),
|
||||
z.strictObject({
|
||||
kind: z.literal("integer"),
|
||||
value: z
|
||||
.string()
|
||||
.max(21)
|
||||
.regex(/^-?(?:0|[1-9][0-9]*)$/),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("bytes"),
|
||||
value: z
|
||||
.string()
|
||||
.max(131070)
|
||||
.regex(/^(?:[a-f0-9]{2})*$/),
|
||||
}),
|
||||
z.strictObject({ kind: z.literal("boolean"), value: z.boolean() }),
|
||||
z.strictObject({ kind: z.literal("null") }),
|
||||
z.strictObject({
|
||||
kind: z.literal("uninterpreted"),
|
||||
container: z.enum(["array", "map"]),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** A trailer candidate, not authentication of Solidity origin or deployment. */
|
||||
export const solidityMetadataSchema = z
|
||||
.strictObject({
|
||||
profile: z.literal("terminal-cbor-scalars-v1"),
|
||||
status: z.enum(["unavailable", "decoded", "partial", "not-decoded"]),
|
||||
bytecode_offset: z.number().int().nonnegative().nullable(),
|
||||
cbor_bytes: z.number().int().positive().max(65535).nullable(),
|
||||
entries: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
key: solidityMetadataScalarSchema,
|
||||
value: solidityMetadataScalarSchema,
|
||||
}),
|
||||
)
|
||||
.max(32767),
|
||||
compiler_version: z
|
||||
.strictObject({
|
||||
representation: z.enum(["release-bytes", "prerelease-text"]),
|
||||
value: z.string().min(1).max(65535),
|
||||
})
|
||||
.nullable(),
|
||||
diagnostic: z.string().max(512).nullable(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const unavailable = value.status === "unavailable";
|
||||
if (
|
||||
unavailable
|
||||
? value.bytecode_offset !== null || value.cbor_bytes !== null
|
||||
: value.bytecode_offset === null || value.cbor_bytes === null
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Only unavailable metadata omits the candidate range.",
|
||||
});
|
||||
const unexamined = unavailable || value.status === "not-decoded";
|
||||
if (
|
||||
unexamined &&
|
||||
(value.entries.length !== 0 || value.compiler_version !== null)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Unavailable interpretation cannot report decoded entries or compiler identity.",
|
||||
});
|
||||
const uninterpreted = value.entries.some(
|
||||
({ key, value: entryValue }) =>
|
||||
key.kind === "uninterpreted" || entryValue.kind === "uninterpreted",
|
||||
);
|
||||
if (
|
||||
(value.status === "decoded" && uninterpreted) ||
|
||||
(value.status === "partial" && !uninterpreted)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Scalar coverage must preserve uninterpreted containers.",
|
||||
});
|
||||
});
|
||||
|
||||
export type SolidityMetadata = z.infer<typeof solidityMetadataSchema>;
|
||||
export type SolidityMetadataScalar = z.infer<
|
||||
typeof solidityMetadataScalarSchema
|
||||
>;
|
||||
@@ -999,3 +999,51 @@ const expectFunctionDimensionAlgebra = (
|
||||
}).success,
|
||||
).toBe(false);
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ match: "mismatched", changedDimensions: false, expected: "changed" },
|
||||
{ match: "ambiguous", changedDimensions: false, expected: "unknown" },
|
||||
{ match: "ambiguous", changedDimensions: true, expected: "unknown" },
|
||||
] as const)(
|
||||
"validates $match match precedence with changed dimensions=$changedDimensions",
|
||||
({ match, changedDimensions, expected }) => {
|
||||
const result = compareFunctions(
|
||||
FUNCTION_COMPARISON_EXAMPLE.left,
|
||||
changedDimensions
|
||||
? FUNCTION_COMPARISON_EXAMPLE.right
|
||||
: FUNCTION_COMPARISON_EXAMPLE.left,
|
||||
);
|
||||
const candidate = {
|
||||
...result,
|
||||
function_match: {
|
||||
status: match,
|
||||
method: "explicit",
|
||||
left_name: "main",
|
||||
right_name: "other",
|
||||
},
|
||||
};
|
||||
expect(
|
||||
functionComparisonResultSchema.safeParse({
|
||||
...candidate,
|
||||
status: expected,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
for (const status of ["unchanged", "changed", "unknown"] as const) {
|
||||
if (status === expected) continue;
|
||||
const parsed = functionComparisonResultSchema.safeParse({
|
||||
...candidate,
|
||||
status,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (!parsed.success)
|
||||
expect(parsed.error.issues).toMatchObject([
|
||||
{
|
||||
path: ["status"],
|
||||
code: "custom",
|
||||
message:
|
||||
"Function comparison status contradicts its dimensions or function match.",
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { overallStatus } from "./functionComparisonResults.js";
|
||||
import { evidenceSchema } from "./evidence.js";
|
||||
import { digestSchema } from "./../domain/digests.js";
|
||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
@@ -79,20 +80,33 @@ const functionMatchSchema = z.union([
|
||||
]);
|
||||
|
||||
/** Deterministic dimension-classified function comparison Evidence payload. */
|
||||
export const functionComparisonResultSchema = z.object({
|
||||
status: comparisonStatusSchema,
|
||||
function_match: functionMatchSchema,
|
||||
left_subject_sha256: digestSchema,
|
||||
right_subject_sha256: digestSchema,
|
||||
summary: z.object({
|
||||
unchanged: z.number().int().min(0),
|
||||
changed: z.number().int().min(0),
|
||||
unknown: z.number().int().min(0),
|
||||
}),
|
||||
dimensions: z.array(functionDimensionSchema).length(8),
|
||||
changes: z.array(functionDimensionSchema),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export const functionComparisonResultSchema = z
|
||||
.object({
|
||||
status: comparisonStatusSchema,
|
||||
function_match: functionMatchSchema,
|
||||
left_subject_sha256: digestSchema,
|
||||
right_subject_sha256: digestSchema,
|
||||
summary: z.object({
|
||||
unchanged: z.number().int().min(0),
|
||||
changed: z.number().int().min(0),
|
||||
unknown: z.number().int().min(0),
|
||||
}),
|
||||
dimensions: z.array(functionDimensionSchema).length(8),
|
||||
changes: z.array(functionDimensionSchema),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
if (
|
||||
result.status !==
|
||||
overallStatus(result.dimensions, result.function_match.status)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message:
|
||||
"Function comparison status contradicts its dimensions or function match.",
|
||||
});
|
||||
});
|
||||
|
||||
export type FunctionComparisonResult = z.infer<
|
||||
typeof functionComparisonResultSchema
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
|
||||
describe("child-process options detection", () => {
|
||||
it("detects options without an args array", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
import { spawn, execFile, fork } from "node:child_process";
|
||||
|
||||
spawn("tool", {
|
||||
env: { A: "1" },
|
||||
stdio: "pipe"
|
||||
});
|
||||
|
||||
execFile("tool", {
|
||||
env: { A: "1" }
|
||||
});
|
||||
|
||||
fork("worker.js", {
|
||||
env: { A: "1" },
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"]
|
||||
});
|
||||
`);
|
||||
|
||||
expect(
|
||||
ir.childProcessSpawns.map(
|
||||
({ method, environmentSupplied, stdioMode, resolution }) => ({
|
||||
method,
|
||||
environmentSupplied,
|
||||
stdioMode,
|
||||
resolution,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
method: "spawn",
|
||||
environmentSupplied: true,
|
||||
stdioMode: "pipe",
|
||||
resolution: "complete",
|
||||
},
|
||||
{
|
||||
method: "execFile",
|
||||
environmentSupplied: true,
|
||||
stdioMode: "default",
|
||||
resolution: "complete",
|
||||
},
|
||||
{
|
||||
method: "fork",
|
||||
environmentSupplied: true,
|
||||
stdioMode: "array",
|
||||
resolution: "complete",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves behavior with explicit args arrays", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
import { spawn, execFile, fork } from "node:child_process";
|
||||
|
||||
spawn("tool", [], { env: { A: "1" }, stdio: "pipe" });
|
||||
execFile("tool", [], { env: { A: "1" } });
|
||||
fork("worker.js", [], {
|
||||
env: { A: "1" },
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"]
|
||||
});
|
||||
`);
|
||||
|
||||
expect(
|
||||
ir.childProcessSpawns.map(({ environmentSupplied, stdioMode }) => ({
|
||||
environmentSupplied,
|
||||
stdioMode,
|
||||
})),
|
||||
).toEqual([
|
||||
{ environmentSupplied: true, stdioMode: "pipe" },
|
||||
{ environmentSupplied: true, stdioMode: "default" },
|
||||
{ environmentSupplied: true, stdioMode: "array" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not invent options when omitted", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
import { spawn, execFile, fork } from "node:child_process";
|
||||
|
||||
spawn("tool");
|
||||
execFile("tool");
|
||||
fork("worker.js");
|
||||
`);
|
||||
|
||||
expect(
|
||||
ir.childProcessSpawns.every(
|
||||
({ environmentSupplied, stdioMode }) =>
|
||||
environmentSupplied === false && stdioMode === "default",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(ir.childProcessSpawns).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -247,19 +247,24 @@ export function* classifyParsedJavaScriptRpcClientRootsSteps(
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Receiver identity facts for the overloaded `.open` syntax only. */
|
||||
/**
|
||||
* Receiver identity facts for the overloaded `.open` syntax, plus
|
||||
* `configuration` for `.get` reads of a `getConfiguration(...)` result.
|
||||
*/
|
||||
export type JavaScriptOpenReceiverFact =
|
||||
| "window"
|
||||
| "indexed-db"
|
||||
| "cache-storage"
|
||||
| "local"
|
||||
| "local-indexed-db"
|
||||
| "local-cache-storage";
|
||||
| "local-cache-storage"
|
||||
| "configuration";
|
||||
|
||||
/**
|
||||
* Resolve only the lexical receiver identity needed to distinguish browser
|
||||
* `window.open` from XHR and global storage `.open` calls. This deliberately
|
||||
* builds definitions without running semantic value or provenance analysis.
|
||||
* `window.open` from XHR and global storage `.open` calls, and configuration
|
||||
* `.get` reads from HTTP client calls. This deliberately builds definitions
|
||||
* without running semantic value or provenance analysis.
|
||||
*/
|
||||
export const classifyParsedJavaScriptOpenReceivers = (
|
||||
file: ParsedJavaScriptSource,
|
||||
@@ -278,14 +283,19 @@ export function* classifyParsedJavaScriptOpenReceiversSteps(
|
||||
if (
|
||||
(!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) ||
|
||||
(!t.isMemberExpression(node.callee) &&
|
||||
!t.isOptionalMemberExpression(node.callee)) ||
|
||||
semanticStaticPropertyKey(
|
||||
node.callee.property,
|
||||
node.callee.computed,
|
||||
) !== "open" ||
|
||||
!t.isIdentifier(node.callee.object)
|
||||
!t.isOptionalMemberExpression(node.callee))
|
||||
)
|
||||
return;
|
||||
const method = semanticStaticPropertyKey(
|
||||
node.callee.property,
|
||||
node.callee.computed,
|
||||
);
|
||||
if (method === "get") {
|
||||
if (isConfigurationReceiver(state, node.callee.object))
|
||||
facts.set(node.start ?? -1, "configuration");
|
||||
return;
|
||||
}
|
||||
if (method !== "open" || !t.isIdentifier(node.callee.object)) return;
|
||||
const receiver = node.callee.object.name;
|
||||
const binding = resolveSemanticBindingState(state, node, receiver);
|
||||
if (binding === undefined) {
|
||||
@@ -351,6 +361,82 @@ export function* classifyParsedJavaScriptOpenReceiversSteps(
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a call spells VS Code's `workspace.getConfiguration(section)`, whose
|
||||
* result `.get` reads setting keys rather than request paths. The `workspace`
|
||||
* member is required: a bare or differently scoped `getConfiguration` may be
|
||||
* an application's own HTTP client factory. Minifiers keep property names, so
|
||||
* bundled `x.workspace.getConfiguration` still matches.
|
||||
*/
|
||||
const isConfigurationAccessorCall = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
node: t.Node,
|
||||
): boolean => {
|
||||
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
|
||||
return false;
|
||||
const callee = node.callee;
|
||||
if (
|
||||
(!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) ||
|
||||
semanticStaticPropertyKey(callee.property, callee.computed) !==
|
||||
"getConfiguration"
|
||||
)
|
||||
return false;
|
||||
const host = callee.object;
|
||||
const matchesSpelling = t.isIdentifier(host)
|
||||
? host.name === "workspace"
|
||||
: (t.isMemberExpression(host) || t.isOptionalMemberExpression(host)) &&
|
||||
semanticStaticPropertyKey(host.property, host.computed) === "workspace";
|
||||
if (!matchesSpelling) return false;
|
||||
const root = t.isIdentifier(host)
|
||||
? host
|
||||
: t.isMemberExpression(host) || t.isOptionalMemberExpression(host)
|
||||
? host.object
|
||||
: undefined;
|
||||
if (!t.isIdentifier(root)) return false;
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
if (binding === undefined)
|
||||
return !semanticResolutionBlocked(state, root, root.name);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
const origins = [
|
||||
...binding.directOrigins,
|
||||
...(initializer === undefined
|
||||
? []
|
||||
: [semanticRequireOrigin(initializer.node, state)?.origin]),
|
||||
];
|
||||
return origins.some(
|
||||
(origin) =>
|
||||
origin?.specifier === "vscode" &&
|
||||
(t.isIdentifier(host)
|
||||
? origin.importedPath.at(-1) === "workspace" ||
|
||||
initializer?.projection.at(-1) === "workspace"
|
||||
: origin.importedPath.length === 0 ||
|
||||
(origin.importedPath.length === 1 &&
|
||||
(origin.importedPath[0] === "*" ||
|
||||
origin.importedPath[0] === "default"))),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a `.get` receiver is a configuration accessor result, either inline
|
||||
* or through a binding whose only visible write is one unconditional
|
||||
* initializer. Minifiers emit `let` for such bindings, so declaration kind
|
||||
* alone does not decide.
|
||||
*/
|
||||
const isConfigurationReceiver = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
receiver: t.Node,
|
||||
): boolean => {
|
||||
if (isConfigurationAccessorCall(state, receiver)) return true;
|
||||
if (!t.isIdentifier(receiver)) return false;
|
||||
const binding = resolveSemanticBindingState(state, receiver, receiver.name);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
return (
|
||||
initializer !== undefined &&
|
||||
initializer.projection.length === 0 &&
|
||||
isConfigurationAccessorCall(state, initializer.node)
|
||||
);
|
||||
};
|
||||
|
||||
const openGlobalFact = (
|
||||
name: string,
|
||||
): JavaScriptOpenReceiverFact | undefined => {
|
||||
|
||||
@@ -324,13 +324,18 @@ const resolveChildCandidates = (
|
||||
? []
|
||||
: (spawnsByBinding.get(binding.bindingId) ?? []);
|
||||
};
|
||||
|
||||
const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => {
|
||||
const index = candidate.method === "exec" ? 1 : 2;
|
||||
const value = candidate.node.arguments[index];
|
||||
const args = candidate.node.arguments;
|
||||
|
||||
const value =
|
||||
candidate.method === "exec"
|
||||
? args[1]
|
||||
: t.isObjectExpression(args[1])
|
||||
? args[1]
|
||||
: args[2];
|
||||
|
||||
return t.isObjectExpression(value) ? value : null;
|
||||
};
|
||||
|
||||
const spawnArgvCount = (candidate: SpawnCandidate): number | null => {
|
||||
if (candidate.method === "exec") return null;
|
||||
const args = candidate.node.arguments[1];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user