Compare commits

..
Author SHA1 Message Date
N0zoM1z0 10caef99e7 fix(ghidra): import HashMap before bridge compilation
The Swift literal decoder introduced by #2049 instantiates HashMap without
importing java.util.HashMap. Ghidra compiles the entire bridge before serving
operations, so the missing import prevents x64 PE sessions from starting too.

Verified on Windows with JDK 21.0.12.1 and Ghidra 12.1.4: all nine packaged
Java sources compile against 195 API jars, and public MCP opens a real Windows
where.exe target and returns its binary overview. Target bytes remain unchanged.
The MCP smoke uses runtime 1ca5fd38b with the patched bridge from fd65e278b;
the current full runtime build is blocked by unrelated Android JsonValue typing.
2026-10-13 22:58:41 +08:00
fd65e278be fix(setup): resolve the configuration MSIX Claude Desktop reads (#2426)
* fix(setup): resolve the configuration MSIX Claude Desktop reads

On Windows, setup, doctor and uninstall always used
%APPDATA%\Claude\claude_desktop_config.json. The MSIX build of Claude
Desktop (package family Claude_pzs8sxrjxfjjc) virtualizes AppData writes
and reads its package-private copy under
%LOCALAPPDATA%\Packages\<family>\LocalCache\Roaming\Claude once it exists.
Setup from an ordinary process therefore wrote a file the app never reads,
and doctor reported that file healthy.

Resolve the Claude Desktop location once, in supportedClients(): the
private copy when it exists, else the AppData file the package still
reads, else a new private copy. An unrecognized Claude_* publisher or
unreadable package storage is reported instead of falling back to AppData.

Compare one of the package's private files with its AppData path through
realpath.native to learn whether the running package started this
process. From that redirected view, or when no file can be compared,
setup and uninstall refuse to change Claude Desktop's configuration,
because the running app rewrites it from memory. From a redirected view
they also refuse writes under %APPDATA% and %LOCALAPPDATA% for other
clients and skills, which would land in the package's private storage.

Setup refuses to register a launcher stored in any MSIX package's
private storage, discloses which file the package reads, and tells the
user to quit Claude Desktop fully on every platform. Configuration fails
with target_changed if the package switched to its private copy after
the plan was shown. Uninstall removes REA from both files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(setup): record the verified MSIX Claude Desktop registration

On the Windows 11 host with the MSIX build 2.31226.1.0, setup from an
ordinary terminal with the app quit wrote the package-private copy. After
the app restarted, the entry persisted, doctor reported it aligned, and
Claude Desktop's MCP log showed REA starting and answering tools/list.
Uninstall against the real app remains unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(setup): format MSIX review corrections

* fix(setup): carry the admitted legacy path through uninstall removal

clientRegistrationRemoval returned a removeLegacy boolean, and removeClient
recomputed the legacy path separately, so TypeScript could not prove the
path existed when removeLegacy was true and `tsc --noEmit` failed in
Uninstall.ts. The decision now returns the admitted legacy path itself;
preflight and removal consume that one value.

The alias regression matched new-destination children with a "/"
separator while building paths with path.join, so it failed on Windows.
It now uses path.sep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(setup): split MSIX Claude Desktop lifecycle cases under the function length limit

The uninstall cases pushed the registration lifecycle describe past the 150-line lint limit. Group them separately, with the AppData registration case kept among the registration cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:04:22 +08:00
5996d8160f fix(native): bound captured output carried by native command failures (#2764)
* fix(native): bound captured output carried by native command failures

The native command runner retains up to 64 MiB of output because
successful analysis needs it complete, and failureCapture copied all of
it into the failure. An inspect_plist whose plutil output exceeded the
budget therefore returned a 67 MB resource_constraint error whose
details.captured_output.stdout repeated the whole retained output.

Keep a 64 KiB UTF-8-safe excerpt per stream in failure diagnostics,
mark them truncated, and keep the observed stdout/stderr byte totals.
Successful captures are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): bound codesign failures and cover excerpt cuts

translateCodeSignExitFailure wrapped an accepted nonzero codesign capture
into a NativeCommandFailure by copying both complete streams, so
inspect_signature failures still bypassed the diagnostic bound. Route it
through the shared failureCapture builder; accepted output stays
complete until the provider decides it is a failure.

Add a focused runner test for an ordinary nonzero exit with stdout and
stderr just above the excerpt size and a two-byte character straddling
the cut, and a provider test for the codesign path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): build excerpt test output inside the child process

The output strings embedded in one node -e argument totalled about 133
KB, above Linux's 128 KiB per-argument limit, so spawn failed with E2BIG
on the CI runner. Generate the repeated output in the child instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:03:19 -07:00
c938f97fb2 fix(android): clean Windows workspaces after owner termination (#2440)
* fix(android): clean Windows workspaces after owner termination

* fix(android): retain Windows workspace guardian on cleanup failure

* fix(android): preserve released guardian pipe state on cleanup retry

* fix(android): normalize extended local guardian parent paths

---------

Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:03:16 +08:00
c45d912c91 feat(android): expose exact DEX descriptors and direct invoke sites (#2439)
* feat(android): expose precise method references and DEX invoke sites

* fix(android): bound invoke evidence without losing reference inventory

* perf(android): skip invoke admission for class references

---------

Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:02:47 +08:00
ce748be2f3 fix(process): order interaction differences by their dispatch time (#2739)
* fix(process): order interaction differences by their dispatch time

first_divergence ranks candidate differences by the earliest recorded
time of the differing observations, but eventTime read only `at_ms`.
Interaction events record their time as `dispatched_at_ms`
(and `scheduled_at_ms`), so a changed interaction always ranked as
untimed. An interaction outcome that differed at 6 ms therefore lost to
a process sample that differed at 500 ms.

Give each comparison dimension its recorded time field. Interactions use
the dispatch time, when their outcome was observed; the other
dimensions keep `at_ms`. An earlier process difference still wins.

Fixes #2645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(process): require ordered interaction dispatch times in captures

Comparison ranks a changed interaction by its dispatch time, while
firstCollectionDivergence returns the first differing interaction by
sequence. The capture parser checked only that interaction sequences are
contiguous, so an imported capture whose dispatch times decrease could
make that first-by-sequence difference later than another differing
interaction.

Interactions are dispatched in sequence and their times come from the
monotonic capture clock, so require non-decreasing dispatch times at
parsing, as frames, rendered frames, process samples and filesystem
checkpoints already require for their timestamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:02:28 -07:00
173da755cd fix(reconstruction): reject contradictory function comparison status (#2798)
* fix(reconstruction): reject contradictory function comparison status

* test(reconstruction): cover imported function match precedence

---------

Co-authored-by: maxazure <405711+maxazure@users.noreply.github.com>
2026-10-13 22:01:41 +08:00
1eca600515 fix(apktool): resolve Windows PATH launchers before invocation (#2802)
* fix(apktool): resolve Windows PATH launchers before invocation

Resolve the default launcher through the selected PATH/PATHEXT before choosing native process creation or the existing conservative cmd.exe wrapper. Reuse the resulting selection and one canonical environment for the version probe and decode.

Follow-up to the release #1693 review of #1777. Source inspected and formatted; tests, builds, check reruns and real Windows Apktool execution were skipped at maintainer request.

* fix(apktool): bound lookup and retain only selected environment

Source inspection and targeted formatting only. Tests, builds, lint, typechecks and provider workloads were not run at the user's explicit request.

* fix(apktool): use supported timeout diagnostics

Retain lookup phase through AnalysisErrorOptions diagnostics while preserving the operation and timeout identity. Source inspection and targeted formatting only; tests, checks, builds and provider workloads were skipped at the user request.

---------

Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-13 22:00:47 +08:00
ae9a7f34e1 fix(process): report no remaining time after a provider deadline fires (#2720)
Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:59:57 -07:00
96d1f0f3fb fix(cli): refuse scenario steps on passive native UI observation (#2712)
observe-native-ui and capture-native-ui-scenario were registered in one
loop with a shared option schema and description. The passive command
therefore advertised `--steps` ("Observe or run a scenario"), accepted
any value, and then dispatched observe_native_ui without it, so a
caller who selected the wrong command never learned that no scenario ran.

Register each command against its own contract. Observation declares
only the passive scope options, so `--steps` is rejected as an unknown
flag; the scenario command keeps its JSON step decoding and validation.
Each command now has its own description.

Fixes #2674

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:59:33 -07:00
beca34f522 fix(ida): report missing tools when a compatibility profile mismatches (#2307)
A profile mismatch reached the caller as a bare AnalysisProtocolError,
which projects to `unreadable_output` with a generic message. The error
already named every missing tool, but none of that detail survived, so
the only way to identify the cause was to log the JSON-RPC traffic and
notice the exchange stopping after `tools/list`.

Throw a ProviderAdapterError instead, carrying the mode, the expected
profile and the missing tool names through the diagnostics channel that
already surfaces in `details`. Document which upstream versions each
profile accepts, since upstream `main` now serves neither.

Refs #2301

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:58:36 +08:00
48a683ac8c feat(evm): inspect lossless Solidity terminal CBOR metadata (#2790)
feat(evm): retain lossless terminal CBOR metadata

Refs morluto/rea#2388

Co-authored-by: Matheus Breguêz <matbrgz@gmail.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-13 21:55:30 +08:00
a3daf8a07b fix(ci): run the Windows native lane with its process boundary fixture (#2336)
* fix(ci): run the Windows native lane with its process boundary fixture

`verify-windows-native.mjs` takes the boundary executable as its second
argument and skips every observation that needs it when the argument is
absent. CI passed no argument, so the breakaway denial, caller and child
token comparison, Windows environment semantics and in-place ancestor
reparse attempt have never run there; they only ran for an operator who
built the fixture by hand, which `native/windows/README.md` described as
optional.

The fixture is an ordinary Windows executable independent of the addon,
so the lane that already installs MinGW to cross-build the artifact
cross-builds it too and uploads it beside the existing inspection
fixture. The Windows job downloads it and passes its path, matching how
that inspection fixture already crosses the same job boundary.

No observation, assertion or threshold changes; this only stops four of
them from being silently skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): pass the process boundary fixture in the real Ghidra Windows lane

`real-ghidra-windows.yml` also runs the native lane without the boundary
executable, so its proof run skipped the same breakaway, caller and child
token, environment semantics and ancestor reparse observations. Its
artifact job already installs MinGW, so it cross-builds and uploads the
fixture the same way, and the self-hosted Windows job downloads it and
passes its path.

This makes the README's claim about both Windows workflows true rather
than narrowing it to one lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(process): report no remaining time after a provider deadline fires

Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit dfdaa0ca74cc84a45dcb186422a0f51aa61bc427)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-13 21:55:17 +08:00
9680da76c8 perf(evidence): reuse sealed bundle reads in compare and export (#2715)
* perf(evidence): reuse sealed bundle reads in compare and export

`rea compare`, the compare_bundles MCP tool and `rea evidence-export`
read bundles through the mutable reader, so each consumer
re-authenticated every record again: compareBundles parsed both bundles
a second time and bundleDigest's serializeEvidenceBundle a third time;
export's writer parsed the source once more before serializing.

These consumers only read the bundle, so read it through the existing
sealed reader (readImmutableEvidenceBundle). Later parses hit the
immutable bundle cache. Ordinary mutable reads, kept deliberately by
#1721, are unchanged. Output is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(process): report no remaining time after a provider deadline fires

Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:50:25 +08:00
46f51ee6cc fix(flutter): name a missing or non-file APK selection (#2727)
* fix(flutter): name a missing or non-file APK selection

identify_flutter_build and inspect_dart_aot reported a missing path or a
directory as "The APK could not be read as a package: <path>: ENOENT ...",
which blames the package bytes for a selection mistake. Report
"Selected APK does not exist" for ENOENT/ENOTDIR and "Selected APK is not
a regular file" for a non-regular selection, matching the SQLite
inspector's wording. The issue path and invalid_format reason are
unchanged; package format failures keep their existing message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): classify unopenable non-regular selections at admission

A Unix socket cannot be opened at all: open() fails with ENXIO on Linux
and EOPNOTSUPP on macOS (surfaced by libuv as "Unknown system error
-102") before openRegularFile can stat the descriptor, so such a
selection escaped NonRegularFileReadError and the Flutter provider still
blamed the package bytes.

When open fails, classify the path by its own metadata (lstat when
symlinks are rejected, stat otherwise): an existing non-regular file
becomes NonRegularFileReadError. Missing paths, permission failures,
regular files, and the O_NOFOLLOW ELOOP rejection keep their original
errors. The Flutter selection test now covers a real Unix socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): reclassify only unopenable file-type errors at admission

Checking the path's metadata after any open failure turned EACCES on an
unreadable directory into NonRegularFileReadError, so callers reported
invalid input instead of access_denied. Reclassify only errors that mean
the file type cannot be opened (ENXIO, EOPNOTSUPP, ENOTSUP, matched by
errno because libuv has no name for macOS EOPNOTSUPP 102); permission,
symlink-rejection and resource failures keep their original errors.

Run the Unix socket selection check in its own test, skipped on Windows
where listen() takes named pipes, and keep the missing-path and
directory checks cross-platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): keep cancellation that arrives during the non-regular probe

A caller abort while the post-open metadata probe ran was replaced by
NonRegularFileReadError, so callers such as parseBinaryTarget reported an
input-format error instead of cancellation. Recheck the signal before
substituting the non-regular classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:49:23 +08:00
c22d7cd756 fix(browser): keep malformed Content-Length declarations unknown (#2773)
* fix(browser): keep malformed Content-Length declarations unknown

Response metadata normalized Content-Length with Number(), truncation and
clamping, so "-5", "0x10", "1e3" and "+12" became observed lengths 0, 16,
1000 and 12. RFC 9110 section 8.6 defines the field as 1*DIGIT; any other
spelling declares no length. Parse only decimal digits within the safe
integer range and keep every other value null.

Fixes #2596

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(browser): cover the largest accepted Content-Length

Pin the safe-integer boundary beside the overflow case so a future change
cannot reject the largest representable declared length unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:48:55 +08:00
4c79127fcb fix(sqlite): classify a missing database path as an invalid value (#2775)
* fix(sqlite): classify a missing database path as an invalid value

The snapshot reported an absent selected database as an artifact path
failure, which the adapter projects as invalid_format alongside malformed
paths and content. The snapshot owns the absence observation, so it now
returns an invalid_value input issue on path directly, matching the
adapter's existing ENOENT/ENOTDIR classification.

Fixes #2614

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sqlite): classify a missing database path as an invalid value

The snapshot reported an absent selected database as an artifact path
failure, which the adapter projects as invalid_format alongside malformed
paths and content. The snapshot owns the absence observation, so it now
returns an invalid_value input issue on path directly, matching the
adapter's existing ENOENT/ENOTDIR classification.

Fixes #2614

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:48:39 +08:00
c8e85b962a fix(javascript): recover child-process options without args (#2781)
* docs: celebrate 180,000 GitHub stars 🎉

* fix(javascript): recover child-process options without args

* fix(javascript): preserve execFile callback overload options

Choose execFile options by the second argument object shape rather than total argument count, preserving callback overloads.

Source inspection and git diff --check; tests/builds/typechecks/CI reruns skipped at explicit user request.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:46:27 +08:00
jbernousandGitHub fc0dbe7f9d fix(server): mark retained partial-observation errors as target content (#2398)
Pass the tool contract's `returnsTargetContent` to
`toRecordedPartialErrorToolResult` in the shared Evidence handler, matching
the Electron call site.

The call site that records a retained partial observation was added without
the argument required by the untrusted target-content framing, so the
delivery neither marked the result nor counted the provenance marker in its
wire budget. The type gate failed on main with
`contractToolHandler.ts(69,38): error TS2554`, which also blocked every
compiled boundary test lane.
2026-10-13 21:46:14 +08:00
23f72141d5 fix(javascript): keep configuration setting reads out of endpoints (#2402)
* fix(javascript): keep configuration setting reads out of endpoints

Static endpoint extraction reads the first literal argument of any call
named get as an HTTP request path unless the receiver is a provable keyed
collection. VS Code extension bundles read settings through
`workspace.getConfiguration(section).get(key)`, so every setting key
became a network endpoint: the bundled Git extension's dist/main.js
reported 173 network endpoints, nearly all of them setting names such as
`autofetchPeriod` and `blame.statusBarItem.enabled`.

The lexical receiver classifier now records a `configuration` fact for a
`.get` call whose receiver is a `getConfiguration(...)` call, inline or
through a binding whose only visible write is one unconditional
initializer. Minifiers emit `let` for these bindings, so the proof relies
on the recorded writes rather than the declaration kind, as tRPC client
root resolution already does. Endpoint extraction skips non-URL literals
for that fact in both application and web bundle analysis. Reassigned,
conditional, and shadowed receivers, URL or path literals, and other
clients such as `api.get("users")` keep their endpoints.

The receiver fact union was duplicated in three modules; consumers now
import the classifier's type.

On the Git extension bundle, network endpoints drop from 173 to 7 with
unchanged analysis time (2.1 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(javascript): require the workspace host for configuration receivers

A bare or differently scoped `getConfiguration(...)` may be an
application's own HTTP client factory, so `.get("users")` on its result
lost its endpoint. Only `workspace.getConfiguration(...)` and
`<x>.workspace.getConfiguration(...)` now produce the configuration fact.
Minifiers keep property names, so the bundled VS Code Git extension still
reports 7 network endpoints instead of 173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(javascript): recognize default VS Code namespace imports

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:45:07 +08:00
5d12a0b4b6 test(web): cover nullable HTTP captures through CDP and CLI (#1834)
* fix(browser): preserve unknown HTTP response comparison fields

* fix(browser): retain proven request inventory changes

* fix(javascript): preserve Electron identity and avoid quadratic property scans

Share visible Electron mutations with wrapper expansion, index static object
literal properties once, and align MCP assertions with current contracts.

* fix(javascript): track writes through symbolic Electron aliases

Resolve mutation targets through the same factories, module tables, containers,
and conditional origins as call analysis. Share mutation coverage between
direct and wrapper scans while isolating origin caches and preserving exact
property paths, local rebinding semantics, and analysis resource limits.

* fix(javascript): preserve container slots and imported mutation origins

* perf(javascript): defer container mutation replay until property reads

* fix(javascript): preserve captured object identities and write order

* fix(javascript): preserve ordered mutation origins

* fix(javascript): honor property mutation constraints

* test(web): narrow PR to HTTP and CLI comparison regressions

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:44:08 +08:00
9958754f6c fix(process): preserve discovery ownership across cleanup retries (#2643)
Bind retained descriptors and runtime resources to their admitting operation until cleanup succeeds, then rebind newly admitted work. Verify failed retries, successful recovery, shutdown and CLI projection.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-13 13:43:06 +00:00
a4b8e9ccee feat(ghidra): admit recordless legacy MIPS32 ELFs with a pinned 32-bit language (#1898)
* feat(ghidra): admit recordless legacy MIPS32 ELFs with a pinned 32-bit language

Firmware built with pre-ABI-flags GNU toolchains declares EF_MIPS_ARCH_32
with an unspecified ABI field and no PT_MIPS_ABIFLAGS record. The lane
refused them before provider selection, and Ghidra's own header
auto-selection picks a 64-bit R6 language for exactly these files.

Admit ELF32 ET_EXEC headers with EF_MIPS_ARCH_32 or EF_MIPS_ARCH_32R2,
an unspecified ABI field, EF_MIPS_ABI2 clear and no observed ABI flags
record. o32 is recorded as an inferred analysis assumption
(mips_abi_basis), floating-point ABI semantics stay unverified, and the
existing refusals (FP64, NAN2008, ASEs, machine variants, ELF64) are
unchanged. The profile pins -processor MIPS:<LE|BE>:32:default through a
provider-pinned-v1 selection the bridge-reported language_id is checked
against after import; a caller-selected REA_GHIDRA_LANGUAGE_ID still
wins. The lane identity is versioned
elf32-exec-arch32-legacy-pinned-v1.

verify:ghidra:mips gains a recordless legacy round for both byte orders
that strips the ABI record declarations from a real mips32 fixture,
checks REA's interpretation against GNU readelf, asserts the pinned
language after import and the retained assumption limitations through a
snapshot round-trip.

Fixes #1620

* fix(ghidra): require an inspected absence for the recordless MIPS lane

Address review: undefined ABI flags means the record was not inspected,
not that none exists. Only an inspected absence (null) establishes the
recordless legacy admission; a merely unobserved record keeps its
specific refusal. Also document the legacy admission and its pinned
language in the MIPS provider guide.

* fix(ghidra): tighten the recordless MIPS admission boundaries

Address review: a recordless-looking header whose ABI-record inspection
left declaration sources unexamined (carved ELF with a partial section
table and no program-header record) no longer establishes the recordless
admission; it is refused with the inspection limitation retained. An
EF_MIPS_ARCH_32 header that declares an ABI is refused rather than
relabeled through the 32R2 declared-ABI lane.

* fix(ghidra): reject EF_MIPS_ABI_ON32 from the recordless MIPS lane

Address review: bit 0x40 declares the old n32 ABI. A recordless header
carrying it was admitted with the inferred-o32 assumption even though
the header itself declares n32; the recordless shape now requires the
bit clear and refuses it with a specific constraint.

* fix(ghidra): keep recordless relocatable MIPS objects off the legacy lane

The recordless admission now requires ET_EXEC: the lane and its
verification cover executable firmware images, and a recordless ET_REL
object must stay on the declared-ABI path instead of inheriting the
inferred-o32 assumption under an executable lane id. The ABI-record
requirement paragraph is scoped to the declared-ABI lane so it no longer
contradicts the recordless admission documented above it.

* fix(ghidra): name the ET_EXEC scope for recordless objects and probe the legacy lane

A recordless relocatable object is not an incomplete inspection, so its
refusal now names the ET_EXEC scope of the legacy admission instead of
claiming unavailable ABI headers remain unknown. The recordless legacy
verifier round reads the probe pair and applies the full assertMipsProbe
oracle like the standard round, and its report keeps the independent
GNU readelf receipt beside the fixture facts.

* fix(ghidra): retain the concrete ELF32 legacy compiler pin

* fix(ghidra): bind legacy profiles to fully admitted targets

---------

Co-authored-by: BaaRai <baarai@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:42:38 +08:00
c6503edfc4 ci: pin GitHub Actions to full commit SHAs (#1901)
* ci: pin GitHub Actions to full commit SHAs

Replace every third-party `uses: owner/repo@vN` reference in
.github/workflows with the 40-character commit SHA that the tag
currently resolves to, keeping the exact release as a trailing comment.
Each pin was checked with `git ls-remote --tags`: the floating major tag
and the commented release dereference to the same commit. Local
reusable workflows are unchanged.

Re-pin reactivecircus/android-emulator-runner from the SHA of the
annotated `v2` tag object (4c44018e) to the commit it points to
(a421e438, v2.38.0); Actions resolves both, but only the commit SHA is
an immutable reference that tooling can compare with the release.

Update the Windows Ghidra workflow trust-boundary test to require
SHA-pinned setup-node and upload-artifact references instead of the
previous floating tags.

Validation: actionlint v1.7.7 (-shellcheck= -pyflakes=, as in CI), YAML
parse of all workflows, no remaining unpinned third-party `uses:`, and
the workflow boundary/conformance tests.

* style(ci): format action identity assertion

Retain formatting edits produced before the user stopped validation.

Source inspection and git diff --check only. Tests, builds and typechecks skipped by explicit user request. GitHub review writes paused globally.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 21:38:46 +08:00
5dd2ce3125 fix(ida): let one-shot commands exit after retaining cleanup (#1841)
* fix(ida): let one-shot commands exit after retaining cleanup

When a headless IDA request outlives timeoutMs (for example idb_open on
a large binary still running auto-analysis), REA cannot prove the
worker stopped and correctly retains the upstream connection and
private workspace. The retained stdio child and its pipes stayed
referenced, so a one-shot CLI printed its cleanup_incomplete result and
then never exited, keeping the supervisor, worker and database alive.

Detach the retained connection's child process and pipes from the event
loop when cleanup fails. A long-lived MCP server can still retry
through the retained connection; a one-shot CLI exits with status 1,
and the supervisor observes end of input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): report an unverified upstream worker after failed cleanup

Upstream ida-pro-mcp spawns headless workers in their own session and
deliberately leaves them running when its supervisor exits, so neither
a retained connection nor REA exiting proves that a timed-out worker
stopped. When cleanup fails before the owned worker's release is
confirmed, the cleanup diagnostics now name its private session and
state that it may still be running until it finishes and reaches its
idle timeout. Failures after a confirmed release, or before any open
was attempted, do not report a worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): name the inventory sessions that block worker release

When idb_close confirmed the requested worker gone but the follow-up
inventory still found a different peer on the private input, the
upstream_worker diagnostic named the released session and could
misdirect manual cleanup. Record the session ids of the inventory
entries that actually blocked release and report those; fall back to
the requested private session only when no inventory evidence exists
(an incomplete open or an unanswered request). The field becomes
session_ids, since more than one entry can block release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): keep blocker evidence and backend in cleanup diagnostics

The release diagnostic reset its observed blockers on every close, so a
retry whose inventory was unavailable fell back to the already-released
session; and it kept only session ids, describing a blocking GUI
session with detached-worker guidance.

Blocker evidence is now replaced only by a successful inventory, and
the diagnostic becomes upstream_sessions with each session's id and
backend, with guidance that distinguishes workers from GUI sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): stop naming a worker after its close was confirmed

When idb_close confirmed the requested worker's release but the
following inventory failed, no blocker evidence existed yet and the
diagnostic fell back to the confirmed-absent worker. After a confirmed
close the diagnostic now reports an empty session list, meaning any
remaining same-input sessions are unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ida): group cleanup diagnostic cases in their own suite

The added cases pushed the reaped-worker describe block past the
150-line limit enforced by lint:built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): keep ownership and pre-close peers in cleanup diagnostics

Blocking sessions dropped the parsed ownership fact, so an external
unowned worker looked the same as REA's own; and recovery inventory
recorded same-input peers only when the requested worker was missing,
so a peer seen beside it was lost if the following close failed.

Each reported session now carries owned (null when no inventory has
reported it), and recovery records every same-input session until a
later successful inventory replaces that evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): drop a confirmed worker from retained cleanup blockers

Recovery inventory now records the requested worker beside any peers,
so once idb_close confirmed its release a failing follow-up inventory
still reported it as unreleased. Remove the confirmed session from the
retained blockers at close confirmation and keep the remaining peers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ida): retry cleanup through a detached stdio connection

The retry coverage used a recording connection whose detach() only
counts calls, so it could not show that detaching keeps a retained
connection usable. Run a real stdio supervisor in a long-lived host,
fail the first post-close inventory, and require the retry through the
same detached connection to confirm release and remove the workspace.
The test fails if detach() destroys the pipes instead of unref-ing them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ida): close the stdio supervisor in failed retry tests

Merge main and give its new IdaNativeProtocol connection double the
required detach() method. The real-stdio retry test now closes its
connection in an unconditional teardown, so a failing assertion cannot
leave the supervisor waiting on stdin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ida): share the cleanup diagnostic expectation in session tests

Repeated upstream_sessions assertions and the unrelated live peer
literal pushed repository duplication over the jscpd threshold. Build
the expected diagnostic with one helper and reuse one peer constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ida): distinguish observed ownership from unreported input bindings

* fix(ida): keep requested-ID conflicts in recovery cleanup

Recovery inventory kept only same-input entries, unlike the startup and
post-close filters. After an explicitly rejected open, an entry under
the requested session ID on another input was dropped, so cleanup could
close the connection, remove the private workspace and report success
while that session stayed registered. Match the requested ID as well so
the entry is retained as a blocker and cleanup stays incomplete.

Also split the cleanup diagnostics suite under the lint:built function
length limit, and make two startup-inventory fixtures pass a defined
session id so the test file typechecks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Xingchuan Ma <xingchuan.ma@msn.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-13 21:36:52 +08:00
169 changed files with 8119 additions and 1181 deletions
-112
View File
@@ -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 }}
+3 -3
View File
@@ -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
View File
@@ -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 }}
+4 -4
View File
@@ -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: |
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+4 -2
View File
@@ -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
+28 -8
View File
@@ -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
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+13 -13
View File
@@ -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
+3 -3
View File
@@ -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: .
+2 -2
View File
@@ -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
+9 -9
View File
@@ -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 }}
-4
View File
@@ -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.
+1 -1
View File
@@ -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.
+107 -9
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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.
+65
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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.
+9
View File
@@ -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:
+3 -1
View File
@@ -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)
+33
View File
@@ -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.
+43
View File
@@ -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.
+6 -1
View File
@@ -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
+229 -1
View File
@@ -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
+16 -7
View File
@@ -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
+10
View File
@@ -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",
+3
View File
@@ -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",
+4
View File
@@ -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);
}
}
+20
View File
@@ -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")
+1
View File
@@ -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"),
+1
View File
@@ -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"),
+1
View File
@@ -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"),
+152
View File
@@ -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"]]
: [
+105
View File
@@ -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.",
}),
);
+3
View File
@@ -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"),
+50 -1
View File
@@ -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",
);
+112
View File
@@ -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`,
);
};
+25
View File
@@ -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");
}
}
}
+40 -4
View File
@@ -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);
+32 -7
View File
@@ -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",
});
};
+16
View File
@@ -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
View File
@@ -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) {
+2 -1
View File
@@ -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;
+224
View File
@@ -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-" });
+271 -10
View File
@@ -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();
}
});
+118 -20
View File
@@ -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";
+40 -15
View File
@@ -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);
+27 -23
View File
@@ -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> => {
+7 -3
View File
@@ -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;
+187
View File
@@ -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")
);
}
};
+117 -1
View File
@@ -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);
});
});
+1
View File
@@ -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;
+31 -1
View File
@@ -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 };
+2
View File
@@ -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")
+30 -4
View File
@@ -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,
+60 -28
View File
@@ -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}`}`;
};
+8 -1
View File
@@ -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>;
+120 -33
View File
@@ -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
View File
@@ -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);
});
+21
View File
@@ -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);
});
});
+8 -9
View File
@@ -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 =>
+2 -2
View File
@@ -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,
+12 -2
View File
@@ -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
View File
@@ -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(
+35 -2
View File
@@ -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;
+8
View File
@@ -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) => {
+19
View File
@@ -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({
+89
View File
@@ -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
>;
+48
View File
@@ -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.",
},
]);
}
},
);
+28 -14
View File
@@ -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