Compare commits

...
Author SHA1 Message Date
morluto ff5af6a7cf fix(plugins): resolve review findings and integrate main 2026-10-14 07:51:46 +08:00
20e4f83097 fix(ghidra): fail doctor when the JVM os.arch selects an unvalidated decompiler directory (#2792)
* fix(ghidra): fail doctor when the JVM os.arch selects an unvalidated decompiler directory

probeJava now also reads the JVM os.arch property. When it maps to a
different native decompiler directory than the one validated from the
host architecture (for example an x86_64 JDK under Rosetta on Apple
silicon selecting os/mac_x86_64 while only mac_arm_64 was built), the
native_decompiler check fails with a remediation pointing at a
matching-arch JDK instead of reporting a healthy installation that
fails every analysis. Unknown os.arch values keep the previous
behavior.

* fix(ghidra): tighten JVM arch remediation and cover os.arch extraction

Address review findings on the JVM os.arch decompiler-directory check:
- The remediation no longer suggests building the decompiler for the
  JVM-selected directory, which could never clear the check; it only
  advises pointing JAVA_HOME at a matching-architecture JDK.
- Add a test running the real Java probe against captured
  -XshowSettings:properties output, asserting os.arch reaches the
  installation diagnostics.
- Document the JDK-architecture prerequisite in the Ghidra setup guide.

* fix(ghidra): refuse unrecognized JVM native architectures

* docs(ghidra): align doctor prerequisites with JVM architecture admission

* fix(ghidra): reject unreported JVM architecture

* test(ghidra): separate JVM and executable admission cases

* fix(ghidra): defer decompiler arch validation when Java is absent

* fix(ghidra): reject JVM native architecture override

* fix(ghidra): validate JVM platform overrides

* fix(ghidra): validate JVM os.name and helper-environment platform overrides

* test(ci): isolate report fixtures and declare PowerPC Java bounds

* test(ghidra): provide observed JVM platform in PowerPC fixture

* fix(ghidra): parse quoted JVM environment options before admission

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 07:31:11 +08:00
97aff28201 fix(electron): reject non-executable paths and contain launch rejections (#2705)
* fix(electron): reject non-executable paths and contain launch rejections

An executable_path that runs but never prints the inspector line made
Playwright's electron.launch() leave three waits unawaited. They rejected
unhandled and Node ended the CLI or the MCP server (#2269).

Check the resolved executable_path is a regular file, and on non-Windows
hosts that it passes X_OK, before any launch. Wrap only the launch call
in a scoped unhandledRejection listener that absorbs rejections whose
stack is in playwright-core/lib and re-throws anything else.

Tested on Linux with Node 22.23.1, 24.18.0 and 26.11.1.

Refs #2269

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* fix(electron): absorb only this launch's orphaned rejections

The listener around the launch dropped every rejection with a
playwright-core stack, so an unrelated Playwright failure during the
launch window would vanish. Hold those rejections instead. When the
launch settles, drop only the ones whose message is part of the launch's
own error (the orphans of a failed launch). Raise every other one again
through the unhandledRejection path, so the process still fails. A launch
that succeeds raises all of them. Rejections without a Playwright stack
still fail at once.

One setImmediate turn before the decision lets rejections queued by the
settling tick arrive first. It is a single turn, not a timer. A shared
set of absorbed errors stops overlapping launches from raising each
other's orphans. The listener is removed on every path.

Add child-process tests that drive the built provider through its launch
seam, since Vitest's worker has its own unhandledRejection listener.

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* test(electron): distinguish launch success from capture ownership

* test(electron): assert cleanup ownership failures around overlapping launches

* fix(comparison): recheck historical calls-only changes (#2223)

* fix(comparison): preserve incomplete function call evidence

Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty.

Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls.

* fix(comparison): recheck historical calls-only changes

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>

* fix(android): make DEX descriptor pattern portable

* fix(ci): isolate Linux Hopper process fixture inventory

* fix(browser): avoid replaying observed launch rejections

* style: format comparison and IDA sources

* test(electron): allow cold macOS ownership setup

* test(electron): allow cold macOS setup in the early-exit path test

The test that runs an executable which exits at once builds the Swift
ownership helper twice on macOS: in the CLI process, and again in the
MCP server process on its first tool call that prepares. A cold Swift
cache can spend up to 120 seconds on a build. The test had only the
30 second default.

Give the test 180 seconds, as e1579ec31 did for
electronLaunchRejections.test.ts and 65a809a0d did for
webSourceLocationCli.test.ts. cli.run has no deadline of its own, so
it needs no timeoutMs.

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

* test(ci): isolate report fixtures and declare PowerPC Java bounds

---------

Co-authored-by: Claude Opus 5.5 <opus@fox.invalid>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com>
2026-10-14 07:30:14 +08:00
641a8395ee fix(cli): refuse a JavaScript integrity policy the binary analyze lane cannot honor (#2808)
* fix(cli): refuse an integrity policy the binary analyze lane cannot honor

`analyze` declares --integrity-policy for its JavaScript lane and forwards it
only there. On a binary target the flag was accepted and then dropped:
inspect_artifact-style integrity handling does not exist on this path, and
binary_overview is a strictObject with no input at all, so the request could
never take effect and the run proceeded as if the caller had not asked.

The same branch already refuses source_paths on a binary target with a typed
AnalysisInputError, and the sibling `inspect` command refuses the flag outright,
so the refusal is the established handling here. The policy option carries a
default, so the check keys off a value that differs from the owner schema's own
default rather than the flag's presence; otherwise every binary analyze would be
refused. The default is read from the schema instead of being restated.

Verified with 5 integrity policy CLI cases, check:fast and
verify:test-discovery (1023 files) on macOS arm64, and by running the built CLI
for the no-flag, explicit-default and record-and-continue cases. The regression
case failed with the command reaching the binary lane before the change.

* fix(cli): reject explicit default integrity policies for binaries

* fix(cli): keep an omitted integrity policy absent on the binary lane

`javascriptApplicationOptions.shape.integrityPolicy` fills an omission with its
default, and `.optional()` keeps reporting that parsed value, so
`options.integrityPolicy !== undefined` was true for every `analyze <target>`
call: ordinary binary analysis was rejected before the target was even read.

List `z.undefined()` first in a union so an omitted option stays absent while
the owner still validates a supplied value, and reapply the owner's metadata so
help and `--schema` keep describing the option.

Validation: tests/boundary/cli/javascriptIntegrityPolicyCli.test.ts reports
1 failed | 5 passed without this change (the case "still routes a binary
analyze target without an integrity policy" returned invalid_request for
integrity_policy instead of reaching the target) and 6 passed with it;
`npm run check:fast` passes on macOS 26.5.1 arm64 with Node.js 24.18.0.

* fix(cli): omit integrity defaults from binary option schema

* test(ci): isolate report fixtures and declare PowerPC Java bounds

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 07:28:00 +08:00
morluto 3f17e75b4b test(release): split plugin conformance groups 2026-10-14 03:02:59 +08:00
morluto 7cb6c30039 fix(deps): keep package lockfiles aligned 2026-10-14 03:01:07 +08:00
morluto 9544c80eba fix(release): align shrinkwrap and release lock checks 2026-10-14 02:59:41 +08:00
morlutoandGitHub f45cb4b92a Merge branch 'main' into codex/review-fixes-pr-1977 2026-10-14 02:45:55 +08:00
OpenAI Codex bc6eb789ce fix(plugins): publish npm source dependencies 2026-10-14 02:23:13 +08:00
morluto 9de80b4deb fix(plugins): separate source and package launcher modules 2026-10-14 01:34:42 +08:00
morluto a85bfed8dc fix(plugins): validate Codex companion launch metadata 2026-10-14 01:23:15 +08:00
morluto b7dabdbc4f chore(licenses): refresh npm dependency inventory 2026-10-13 23:36:26 +08:00
OpenAI Codex 9d22eea7d2 fix(plugins): stop fallback server process trees 2026-10-13 23:33:15 +08:00
morluto 45bae55787 fix(plugins): load Codex MCP server from root config 2026-10-13 23:13:34 +08:00
morluto 987cb3b96b chore: sync captured review base for PR 2815 2026-10-13 23:09:50 +08:00
morluto 961f410094 chore(plugins): sync the captured final review base 2026-10-13 21:50:53 +08:00
morluto 802a1b0b75 test(plugins): guard packaged launcher ownership and checkout identity 2026-10-13 21:47:29 +08:00
morluto 42ce2c75ab fix(plugin): keep the shared launcher owner outside shipped source trees 2026-10-13 21:47:15 +08:00
morluto 3206274e0b fix(plugins): reuse the portable Node package-runner bootstrap 2026-10-13 21:45:00 +08:00
morluto 3457e65be5 docs(plugins): preserve contributor readiness and recovery guidance 2026-10-13 21:41:20 +08:00
morluto 11404c05f4 fix(plugins): distribute canonical skills from the exact npm release 2026-10-13 21:41:20 +08:00
GamerDubzandClaude Opus 5.5 a74d0502b9 docs(skill): use the plugin's pinned version for package-runner checks
The plugin recovery section left the package-runner version to the skill's
generic @latest fallback, so a doctor run could describe a newer release
than the server an installed plugin starts. Tell the agent to use the
version pinned in the plugin manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:16:31 +02:00
GamerDubzandClaude Opus 5.5 351ad8282a docs(plugin): check engine readiness through the connected plugin server
The plugin guidance said a package-runner doctor report describes the
server the plugin starts. Doctor reads the invoking shell's environment,
while the plugin server inherits the client's, so the two can disagree
about settings such as GHIDRA_INSTALL_DIR. Point plugin users and the
bundled recovery guide at binary_session tool availability, and say what
the terminal check does and does not cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:09:04 +02:00
morluto 07680f66e2 fix(release): preserve contributor version-block conformance checks 2026-10-13 20:56:14 +08:00
morluto eaa9fe5cd5 test(release): validate every owned documentation version block 2026-10-13 20:55:14 +08:00
GamerDubzandClaude Opus 5.5 9d658d767a test(release): check every versioned example block in the installation guide
The conformance test read only the first Release Please version block, so
adding the plugin readiness block ahead of the MCP Registry example left
the registry example unchecked. Check every block, and require each known
example to sit inside exactly one, so a stale version or a lost marker in
any of them fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:52:24 +02:00
morluto 22de1ff010 chore(plugin): reconcile main while retaining release decisions 2026-10-13 20:31:51 +08:00
GamerDubzandClaude Opus 5.5 2fb79a99c8 fix(release): keep the launched package name when comparing plugin manifests
The version-only comparison blanked the whole launch argument, so a release
that swapped rea-agents for another package passed the notes check as a
version bump. Mask only the version after the last "@" and test a changed
package name.

The plugin readiness command in the installation guide now uses the pinned
release version, so its report describes the server the plugin starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:51:56 +02:00
GamerDubzandClaude Opus 5.5 2da5e86289 fix(release): allow only version changes to plugin manifests after the notes target
The publication history check admitted the plugin manifests whole, so a
release could change plugin behavior, such as the bundled skill path or the
server environment, and still pass as release-only. Compare each manifest
across the release with its version and pinned server launch blanked, and
treat any other difference as a product change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:25:24 +02:00
GamerDubz 6ebae721f6 Merge remote-tracking branch 'origin/main' into feat/claude-code-plugin
# Conflicts:
#	docs/installation.md
2026-10-12 18:12:04 +02:00
GamerDubz 677997bd59 Merge remote-tracking branch 'origin/main' into feat/claude-code-plugin 2026-10-12 15:06:48 +02:00
GamerDubzandClaude Opus 5.5 b22d5245b1 fix(release): require the full package specifier in plugin launch validation
The checkpoint stripped an optional rea-agents@ prefix before comparing,
so a manifest launching a bare release number passed even though npx
would fetch a package named after that number. Compare the whole
specifier and report the launched value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:48:37 +02:00
GamerDubzandClaude Opus 5.5 5806c94008 fix(release): reject release candidates with stale plugin manifests
The publication checkpoint validated package, lockfile, server and
generated metadata versions but not the plugin manifests, so a candidate
could be tagged with a manifest version or pinned server launch left at
the previous release. Validate both fields in both manifests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:31:47 +02:00
GamerDubzandClaude Opus 5.5 c224ce482a docs(skill): make connection recovery aware of plugin installs
The bundled skill said installing it never registers the MCP server and
sent every missing-tools case to rea doctor --client and rea setup. For a
plugin-only install those report missing and setup would add a second rea
server. Tell the agent how to recognise a plugin install and to use the
client's own plugin and MCP listings instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:07:49 +02:00
GamerDubzandClaude Opus 5.5 102661ca6f fix(plugin): pin the plugin server launch to the release version
Both manifests started `rea-agents@latest`, so a server start could run a
different package version from the installed plugin and bundled skill, and
would execute whatever npm's latest tag pointed at. Pin the launch to the
manifest version and have Release Please rewrite it with each release.

Release Please reserializes JSON extra-files with JSON.stringify, which the
formatter would reject in every release PR. Store both manifests in that
form, exempt them from the formatter, and assert the form in conformance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:50:02 +02:00
GamerDubzandClaude Opus 5.5 2f134aaa7e docs(plugin): run the plugin readiness check through npx
A plugin-only install has no rea executable on PATH, so the documented
rea doctor command failed with "command not found". Show the package
runner form instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:09:56 +02:00
GamerDubzandClaude Opus 5.5 e8e265a0bc docs(plugin): tell plugin users how to check readiness without rea setup
rea doctor's client and skill checks only read state that rea setup
manages, so a plugin-only install reports missing and recommends the
second registration the plugin section warns against. Say so, and point
plugin users at the client's own plugin listing and provider-scoped
doctor checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:00:14 +02:00
GamerDubzandClaude Opus 5.5 d3359ebef0 fix(plugin): give the Codex plugin server REA's 30-second startup timeout
The Codex manifest left startup_timeout_sec at the client default, unlike
the registration rea setup writes, so a cold npx start could be cut off
before tools appeared. Carry the owned timeout in the Codex server entry
and assert it against MCP_STARTUP_POLICY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:50:28 +02:00
GamerDubzandClaude Opus 5.5 2aea370e1c fix(plugin): declare the capabilities Codex plugin validation requires
Codex's plugin validator rejects a manifest whose interface omits
capabilities. Declare them in .codex-plugin/plugin.json and assert the
required interface fields in the release configuration test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:37:44 +02:00
GamerDubzandClaude Opus 5.5 a34e48b35b test(release): cover plugin manifests in the publication history check
The release-only allowlist gained both plugin manifests without a test
naming them. The publication history test now commits both manifests in
the release commit, so it fails with "Product changed after notes target"
if either path is dropped from the allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:37:10 +02:00
GamerDubz d206a19c7d Merge remote-tracking branch 'origin/main' into feat/claude-code-plugin 2026-10-12 05:18:13 +02:00
GamerDubzandClaude Opus 5.5 15a5e08338 fix(release): treat the plugin manifests as release-version metadata
Release PRs now bump .claude-plugin/plugin.json and .codex-plugin/plugin.json,
but the publication history check rejected them as post-notes product
changes and the release configuration test still expected the previous
extra-files list. Admit both manifests in the release-only allowlist, expect
them in the Release Please configuration, and assert that each manifest
version matches package.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:07:33 +02:00
GamerDubzandClaude Opus 5.5 4c945f570d feat(plugin): add a Codex plugin manifest and self-hosted marketplaces
Add .codex-plugin/plugin.json so Codex loads REA with its own display
metadata, and make the repository a single-plugin marketplace for both
clients through .agents/plugins/marketplace.json and
.claude-plugin/marketplace.json. Users can now add morluto/rea as a
marketplace and install the plugin without waiting for a directory listing.

Release Please bumps the Codex manifest version alongside the Claude one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:01:17 +02:00
GamerDubzandClaude Opus 5.5 6649e8e7ba feat(plugin): make the repository installable as a Claude Code plugin
Add .claude-plugin/plugin.json at the repository root so Claude Code plugin
directories and marketplaces can list REA. The manifest bundles the authored
reverse-engineer-anything skill from .agents/skills/ and registers the
existing stdio server through npx; it adds no second distribution artifact.

Release Please now bumps the manifest version with each release, which is
when installed plugins refresh the bundled skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:53:19 +02:00
67 changed files with 6207 additions and 464 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "rea",
"interface": {
"displayName": "REA"
},
"plugins": [
{
"name": "rea",
"source": {
"source": "npm",
"package": "rea-agents",
"version": "6.4.0"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
@@ -17,6 +17,9 @@ readiness or provider commands.
Installing this skill supplies instructions; it does not register the REA MCP
server, install analysis engines, or add tools to an already-running agent.
The exception is a Claude Code or Codex plugin install: the plugin bundles
this skill and registers its own `rea` server, outside the state that
`rea setup` and `rea doctor` manage.
If REA tools are available and their registration is not known to be stale,
proceed directly to the target. Do not run diagnostics or setup before every
@@ -13,7 +13,7 @@ main and an older release expose the same arguments.
| Observation | Meaning and next step |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No REA tools in the active session | Inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
| No REA tools in the active session | If REA was installed as a client plugin, follow the plugin section below. Otherwise inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
| Missing, malformed or stale registration | Prepare a scoped setup plan; apply only authorized changes with backups. |
| Aligned registration, but no connected tools | Restart/reconnect the client. Doctor checks registration; it cannot establish the current agent's connection. Inspect the client's launch error if reconnection fails. |
| `MCP tool call requires approval, but approval policy is never` | Codex rejected the call before REA executed it. Do not install an engine or repeat setup to solve this client-policy decision. See the approval section below. |
@@ -34,6 +34,33 @@ answers part of the question, and name the unavailable evidence separately.
Do not substitute another target. Label observations from ordinary shell tools
as such; only actual REA output supplies REA Evidence.
## REA installed as a client plugin
A Claude Code or Codex plugin owns its `rea` server and this skill. Doctor's
`--client` and `--skill` checks and `rea setup` read and write only
setup-owned state, so for a plugin-only install they report `missing`, and
applying setup would add a second `rea` server. If this skill was loaded from
a plugin, or the client lists a `rea` plugin, do not prepare a setup plan for
missing tools. Instead:
- Check that the plugin is installed and enabled with the client's own
listing: `claude plugin list` and `/mcp` in Claude Code, `codex plugin list`
in Codex.
- Restart or reconnect the client, then read its launch error for the `rea`
server if tools are still absent. The first start downloads the pinned
package through `npx` and can exceed the client's startup timeout; a second
start uses the cache.
- Check analysis engines through the connected server's
`binary_session.result.tool_availability`. `rea doctor --provider <id> --json`
from a shell reports that shell's environment, which can differ from the
environment the client gave the plugin server. If you still run it through
the package runner, use the version pinned in the plugin manifest's server
entry (`rea-agents@<version>`), not `@latest`, so it matches the server the
plugin starts.
Use the setup repair below only when the user wants a setup-owned registration
instead of the plugin, and remove or disable the plugin first.
## Repair registration when needed
For Codex:
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "rea",
"description": "Reverse engineer anything with agents, from app behavior down to native binaries.",
"owner": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"plugins": [
{
"name": "rea",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"source": {
"source": "npm",
"package": "rea-agents",
"version": "6.4.0"
},
"category": "development"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "rea",
"version": "6.4.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"author": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"homepage": "https://rea.tools/",
"repository": "https://github.com/morluto/rea",
"license": "MIT",
"keywords": [
"reverse-engineering",
"binary-analysis",
"decompiler",
"mcp-server"
],
"skills": "./skills",
"mcpServers": {
"rea": {
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
"6.4.0"
]
}
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "rea",
"version": "6.4.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"author": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"homepage": "https://rea.tools/",
"repository": "https://github.com/morluto/rea",
"license": "MIT",
"keywords": [
"reverse-engineering",
"binary-analysis",
"decompiler",
"mcp-server"
],
"skills": "./skills",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "REA",
"shortDescription": "Reverse engineer anything with agents",
"longDescription": "Use REA to explain shipped behavior from native binaries, managed assemblies, Electron and JavaScript bundles, packages, firmware, and browser targets, with evidence that separates observations from inferences.",
"developerName": "morluto",
"category": "Developer Tools",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"websiteURL": "https://rea.tools/",
"defaultPrompt": [
"Use REA to explain how this binary implements a feature.",
"Use REA to compare two versions of this application.",
"Use REA to recover the sources from this JavaScript bundle."
]
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ runs:
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
LOCKFILES_HASH: ${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}
LOCKFILES_HASH: ${{ hashFiles('npm-shrinkwrap.json', '.npmrc') }}
run: |
cache_path="$(npm config get cache)"
runtime_arch="$(node -p 'process.arch')"
@@ -21,7 +21,7 @@ runs:
if: inputs.build_cache_artifact == ''
shell: bash
env:
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'npm-shrinkwrap.json') }}
run: |
npm run --silent test:prepare -- --dry=json |
node --input-type=module -e '
+2 -2
View File
@@ -133,9 +133,9 @@ jobs:
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache/
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }}
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'npm-shrinkwrap.json') }}-${{ github.sha }}
# Turbo validates each task's inputs after restoring this snapshot.
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'npm-shrinkwrap.json') }}-
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
+1 -1
View File
@@ -25,7 +25,7 @@ on:
- "scripts/lib/mcp-verifier-results.mjs"
- "scripts/rea.mjs"
- "package.json"
- "package-lock.json"
- "npm-shrinkwrap.json"
- ".nvmrc"
permissions:
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"rea": {
"command": "node",
"args": ["./scripts/plugin-mcp-launcher.mjs", "6.4.0"],
"cwd": ".",
"startup_timeout_sec": 30
}
}
}
+3 -1
View File
@@ -9,7 +9,9 @@
"docs/api/**",
"node_modules/**",
"CHANGELOG.md",
"package-lock.json",
"npm-shrinkwrap.json",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
"_reference/**",
"third_party/**"
]
+3
View File
@@ -131,6 +131,9 @@ npm ci
npm run check:fast
```
`npm-shrinkwrap.json` is the sole dependency lock for development and the
published plugins. Do not add a root `package-lock.json`; npm would ignore it.
`npm ci` installs the exact dependencies and prepares the Husky hooks without
building the project. Run `npm run build:cached` when you need the standalone
CLI or MCP server; it compiles the runtime and bundles the authored skill.
+1 -1
View File
@@ -15,7 +15,7 @@ notices supplied with the software you actually obtain or redistribute.
| Material | Use and licensing records |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `package-lock.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. |
| npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `npm-shrinkwrap.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. |
| NativeAOT parser | REA's derived parser uses the MIT-licensed [ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot/tree/effeb734fc570c32650f88b159608979dc7b423e) work of Washi and the NativeAOT layout definitions in [dotnet/runtime](https://github.com/dotnet/runtime/tree/a3b2d40328be0be3f8dd43f0e5cc925b8827b044). Preserve [Washi's MIT notice](licenses/nativeaot-parser-MIT.txt) and the [.NET Foundation MIT notice](licenses/dotnet-runtime-MIT.txt). |
| EVM analysis | `evmole@0.9.3`, MIT, copyright 2023 Maxim Andreev. The published npm archive omits its license document; REA retains the [original upstream license](third_party/evmole/LICENSE) and [provenance](third_party/evmole/README.md). It is also included in the runtime notices. |
| Windows native addon | The release addon is compiled with MinGW-w64 and statically links GCC runtime libraries. Preserve the [MinGW-w64 runtime notices](licenses/MinGW-w64-runtime.txt), [GPLv3 text](licenses/GPL-3.0.txt), and [GCC Runtime Library Exception 3.1](licenses/GCC-Runtime-Library-Exception-3.1.txt). The exception governs eligible compiled combinations; it does not relicense the runtime itself under MIT. The Node-API build headers are from Node 22.19.0; its [original license and component notices](licenses/Node-22.19.0-LICENSE.txt) are retained. Node itself is supplied by the user. Snapshot provenance and release-toolchain review requirements are in [licenses/README.md](licenses/README.md). |
+90 -1
View File
@@ -493,7 +493,13 @@ copying license secrets into logs, or killing unrelated Hopper processes.
REA connects to an existing Ghidra installation on Linux x64/arm64, macOS x64/arm64,
or experimental Windows x64 P0.
It accepts Ghidra 12.1.x and the 64-bit full JDK declared by that installation's
`application.java.min` and `application.java.max`. Current 12.1 releases require
`application.java.min` and `application.java.max`. The JDK's reported platform must also
map to the same native decompiler directory as the host: a JDK running under
emulation (for example x86_64 under Rosetta on Apple silicon) or reporting a
non-host `os.name` fails the doctor
even when it is an otherwise accepted 64-bit full JDK. Select a matching JDK
through `JAVA_HOME`; adding another decompiler in the JVM-selected directory
does not satisfy this host-validated admission rule. Current 12.1 releases require
JDK 21 or newer and set no maximum. The bridge is verified with Ghidra 12.1.4
and JDK 21. Each installation must include the native decompiler for the host
architecture in `Ghidra/Features/Decompiler/os/<platform>/` or the corresponding
@@ -556,6 +562,11 @@ registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagno
Doctor validates the platform, architecture, Ghidra 12.1.x application version,
`support/analyzeHeadless` or `support/analyzeHeadless.bat`, the installation's
Java major range, 64-bit JDK bitness, and the presence of `javac`/`javac.exe`.
It also checks that the reported JVM `os.arch` and `os.name` map to the same native decompiler
directory validated for the host; unrecognized reported architectures or a
non-host operating system are refused. LaunchSupport overrides of either
property, including through helper-environment JVM option variables, are
rejected at launch.
When Java is found through `PATH`, setup records its observed JDK home so GUI
MCP clients do not depend on an incidental shell path. Setup shows every exact
environment entry in its plan, writes only after approval, and never downloads,
@@ -794,6 +805,84 @@ read-only operations, digest identity, and runtime cleanup. The separate
ordinary user. It requires the built Windows native bundle and an existing
Ghidra/JDK installation; neither is inferred from startup alone.
## Agent plugins and marketplaces
REA's npm package is the shared Claude Code and Codex plugin artifact. It
contains `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and the
canonical generated `skills/` tree. Both manifests select `./skills` and run a plain Node bootstrap pinned to
that exact package version. The client must have a supported standalone `node`
on its PATH. Claude expands its documented `CLAUDE_PLUGIN_ROOT` in the script
argument; Codex resolves `cwd: "."` from the installed plugin root before
launching the relative script. The shared bootstrap preserves the pinned
package-runner arguments and stdio, handles Windows npm shims, and uses an owned
empty working directory so an unbuilt local checkout cannot shadow the exact
registry release. The existing release skill
generator also pins CLI recovery commands to the same version.
The repository's single-plugin marketplaces are declared in
`.claude-plugin/marketplace.json` and `.agents/plugins/marketplace.json`.
Both select an exact `rea-agents` npm version instead of mutable source-checkout
contents. Release Please updates their selectors alongside the plugin manifests.
No plugin-only publication or generated skill files committed to Git are needed.
This distribution becomes installable after the first qualified npm release
containing these plugin manifests. Earlier published packages are not changed
by this source proposal. For local source development, run `npm ci && npm run build:cached` to generate
the runtime and packaged skill before loading the repository as a plugin.
On Windows, also build the Windows x64 native addon following the
[Windows source-checkout guide](https://github.com/morluto/rea/blob/main/native/windows/README.md#build);
the registry fallback requires that addon and the built process supervisor to
own and clean up the package-runner process tree. The source plugin always
launches its pinned registry release, even when the checkout has been built. The authored
`.agents/skills/` tree is not the plugin's shipped skill layout.
In Claude Code:
```text
/plugin marketplace add morluto/rea
/plugin install rea@rea
```
In Codex, add the marketplace and then install REA from the plugin list:
```bash
codex plugin marketplace add morluto/rea
```
Each release updates both manifest versions and their pinned server version
together, which is when installed copies refresh the bundled skill and move to
the new server. A plugin does not run `rea setup`, so provider
configuration still follows the sections above. Use the plugin or a
`rea setup` registration for a client, not both, to avoid two `rea` servers.
`rea doctor` does not see plugin-owned state. Its client and skill checks read
only the registrations and skill locations that `rea setup` manages, so for a
plugin-only install `rea doctor --client <id>` and `rea doctor --skill` report
`missing` and recommend `rea setup`. Do not follow that remediation. Check the
plugin with the client instead (`claude plugin list` and `/mcp` in Claude
Code, `codex plugin list` in Codex).
For analysis engines, ask the connected server: have the agent call
`binary_session` and read `result.tool_availability`, which reports what the
server the plugin started can use, with reasons and remediation. See
[MCP contracts](mcp-contracts.md).
A plugin does not put `rea` on your `PATH`. To check an engine from a
terminal, use the package runner at the version the plugin pins:
<!-- x-release-please-start-version -->
```bash
npx -y rea-agents@6.4.0 doctor --provider ghidra --json
```
<!-- x-release-please-end -->
If your installed plugin is older, use the version in its manifest instead.
That report describes the terminal's environment, not the plugin server's.
The server inherits the client's environment, so a setting such as
`GHIDRA_INSTALL_DIR` that exists only in your terminal does not reach it.
## MCP Registry
REA is published in the official MCP Registry as `io.github.morluto/rea`. Registry
+5 -2
View File
@@ -240,8 +240,11 @@ Review and test this synchronization PR, then use a merge commit so the release
tag remains in main's ancestry. Keep the released tag unchanged.
The synchronization must include `.release-please-manifest.json`,
`package.json`, both root versions in `package-lock.json`, `CHANGELOG.md`,
`server.json`, and the versioned documentation examples. Run
`package.json`, both root versions in `npm-shrinkwrap.json`, `CHANGELOG.md`,
`server.json`, both plugin manifests (`.claude-plugin/plugin.json` and
`.codex-plugin/plugin.json`), the Codex server declaration (`.mcp.json`), both
marketplaces (`.claude-plugin/marketplace.json` and
`.agents/plugins/marketplace.json`), and the versioned documentation examples. Run
`npm run docs:generate`, `npm run docs:check`, and the release configuration
tests against the combined tree. After the merge, verify that the released
tag is an ancestor of main:
+1 -1
View File
@@ -111,7 +111,7 @@ or acquire that reduction automatically.
Use the repository's pinned Node/npm and locked dependencies. Build each selected
revision, then copy its `dist/`, `bridge/`, `scripts/`, `skills/`, `package.json`
and `package-lock.json` to a separate runtime directory before editing production
and `npm-shrinkwrap.json` to a separate runtime directory before editing production
sources. That copy needs the matching locked dependencies. The initial frozen
baseline is `42b0ed126df7d5b908ac369715378d5e519051b7`; this study does not
silently substitute a newer `main` or the previous Code Mode experiment.
@@ -216,7 +216,7 @@ def main():
'authority': args.authority,
'runtime_files_sha256': runtime_digest,
'runtime_dispatcher_sha256': file_digest(repository / 'scripts/rea.mjs'),
'package_lock_sha256': file_digest(repository / 'package-lock.json'),
'package_lock_sha256': file_digest(repository / 'npm-shrinkwrap.json'),
'experiment_sources_sha256': {
name: hashlib.sha256((experiment / name).read_bytes()).hexdigest()
for name in ['agent-trial.py', 'codex_context.py', 'provider-environment.json']
+1 -1
View File
@@ -147,7 +147,7 @@ const fingerprints = {
resolve(values["runtime-root"], "scripts/rea.mjs"),
),
package_lock_sha256: await fileSha(
resolve(values["runtime-root"], "package-lock.json"),
resolve(values["runtime-root"], "npm-shrinkwrap.json"),
),
};
const clientVersion = JSON.parse(
@@ -109,7 +109,7 @@ class ContextCaptureTests(unittest.TestCase):
runtime = root / 'runtime'
(runtime / 'scripts').mkdir(parents=True)
(runtime / 'scripts' / 'rea.mjs').write_text('')
(runtime / 'package-lock.json').write_text('{}')
(runtime / 'npm-shrinkwrap.json').write_text('{}')
auth_home = root / 'source-auth'
auth_home.mkdir()
(auth_home / 'auth.json').write_text('synthetic-credential')
File diff suppressed because it is too large Load Diff
+2333 -197
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -47,6 +47,7 @@
"packageManager": "npm@11.16.0",
"files": [
"dist",
"npm-shrinkwrap.json",
"install.sh",
"bridge/hopper_bridge.py",
"bridge/cutter/rea_bridge.py",
@@ -67,12 +68,16 @@
"bridge/android/ReaJadxBridge.java",
"bridge/mitmproxy/capture.py",
"scripts/rea.mjs",
"scripts/plugin-mcp-launcher.mjs",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs",
"scripts/hopper-demo-x11.py",
"scripts/prepack.mjs",
"scripts/prepare.mjs",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".mcp.json",
"skills",
"README.md",
"DISCLAIMER.md",
@@ -351,11 +356,6 @@
"yaml": "2.9.0",
"zod": "4.4.3"
},
"overrides": {
"vitepress": {
"vite": "6.4.4"
}
},
"devDependencies": {
"@fast-check/vitest": "0.5.0",
"@stryker-mutator/core": "10.0.0",
@@ -373,6 +373,7 @@
"lint-staged": "16.4.0",
"oxfmt": "0.72.0",
"oxlint": "1.87.0",
"release-please": "17.3.0",
"rolldown": "1.2.12",
"turbo": "2.11.7",
"typescript": "7.0.2",
+30
View File
@@ -31,6 +31,36 @@
"type": "json",
"path": "server.json",
"jsonpath": "$.packages[0].version"
},
{
"type": "json",
"path": ".claude-plugin/plugin.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": ".claude-plugin/plugin.json",
"jsonpath": "$.mcpServers.rea.args[1]"
},
{
"type": "json",
"path": ".codex-plugin/plugin.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": ".mcp.json",
"jsonpath": "$.mcpServers.rea.args[1]"
},
{
"type": "json",
"path": ".claude-plugin/marketplace.json",
"jsonpath": "$.plugins[0].source.version"
},
{
"type": "json",
"path": ".agents/plugins/marketplace.json",
"jsonpath": "$.plugins[0].source.version"
}
]
}
+3 -3
View File
@@ -17,7 +17,7 @@ const installedPackageKey = (name) => `node_modules/${name}`;
export const dependencyInstallProblems = (rootLock, installedLock) => {
const rootPackage = rootLock.packages?.[""];
if (rootPackage === undefined)
return ["package-lock.json does not contain a root package entry"];
return ["npm-shrinkwrap.json does not contain a root package entry"];
const installedPackages = installedLock.packages ?? {};
return Object.keys(directDependencies(rootPackage))
.sort()
@@ -26,7 +26,7 @@ export const dependencyInstallProblems = (rootLock, installedLock) => {
const expected = rootLock.packages?.[key]?.version;
const actual = installedPackages[key]?.version;
if (expected === undefined)
return [`${name}: missing resolved version in package-lock.json`];
return [`${name}: missing resolved version in npm-shrinkwrap.json`];
if (actual === undefined)
return [`${name}: missing from the installed dependency lockfile`];
return actual === expected
@@ -39,7 +39,7 @@ const main = async () => {
const root = process.cwd();
try {
const [rootLock, installedLock] = await Promise.all([
readJson(resolve(root, "package-lock.json")),
readJson(resolve(root, "npm-shrinkwrap.json")),
readJson(resolve(root, "node_modules/.package-lock.json")),
]);
const problems = dependencyInstallProblems(rootLock, installedLock);
+2 -2
View File
@@ -14,7 +14,7 @@ const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
await readFile(join(root, "npm-shrinkwrap.json"), "utf8"),
);
const skill = await readFile(
join(root, ".agents/skills/reverse-engineer-anything/SKILL.md"),
@@ -29,7 +29,7 @@ const versionOf = (name) => {
throw new Error(`Missing locked ${name} version`);
return value;
};
const source = `/** Generated from package.json and package-lock.json; do not edit. */
const source = `/** Generated from package.json and npm-shrinkwrap.json; do not edit. */
export const PACKAGE_METADATA = {
name: ${JSON.stringify(packageJson.name)},
version: ${JSON.stringify(packageJson.version)}, // x-release-please-version
+2 -2
View File
@@ -11,7 +11,7 @@ for (const argument of arguments_)
throw new Error(`Unknown inventory option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
await readFile(join(root, "npm-shrinkwrap.json"), "utf8"),
);
const entries = Object.entries(lock.packages)
.filter(([path]) => path !== "")
@@ -38,7 +38,7 @@ const runtimeFingerprint = createHash("sha256")
.digest("hex");
const source = [
"REA npm dependency inventory",
"Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.",
"Prepared from the root npm-shrinkwrap.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: ${entries.length}; runtime: ${runtime.length}.`,
+59 -3
View File
@@ -4,6 +4,37 @@ import { z } from "zod";
const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
const versionSchema = z.object({ version: z.string() });
const manifestSchema = z.object({ ".": z.string() });
const PLUGIN_MANIFEST_PATHS = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
];
const inlinePluginManifestSchema = versionSchema.extend({
mcpServers: z.object({
rea: z.object({
command: z.literal("node"),
args: z.tuple([
z.literal("${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs"),
z.string(),
]),
}),
}),
});
const codexPluginManifestSchema = versionSchema.extend({
mcpServers: z.literal("./.mcp.json"),
});
const codexServerConfigSchema = z.object({
mcpServers: z.object({
rea: z.object({
command: z.literal("node"),
args: z.tuple([
z.literal("./scripts/plugin-mcp-launcher.mjs"),
z.string(),
]),
cwd: z.literal("."),
startup_timeout_sec: z.number().int().positive(),
}),
}),
});
const sectionsSchema = z.object({
versioning: z.string().optional(),
"changelog-sections": z.array(
@@ -161,14 +192,14 @@ async function validateCandidate(git, sha, version, commits) {
const lock = await readJson(
git,
sha,
"package-lock.json",
"npm-shrinkwrap.json",
versionSchema.extend({
packages: z.object({ "": versionSchema }),
}),
);
versions.push(
["package-lock.json", lock.version],
["package-lock.json packages.root", lock.packages[""].version],
["npm-shrinkwrap.json", lock.version],
["npm-shrinkwrap.json packages.root", lock.packages[""].version],
);
const server = await readJson(
git,
@@ -189,6 +220,21 @@ async function validateCandidate(git, sha, version, commits) {
["server.json", server.version],
["server.json rea-agents package", npmPackage.version],
);
// A stale manifest keeps the previous plugin cache key or launches the
// previous server, so both are version authority like server.json.
const launches = [];
for (const path of PLUGIN_MANIFEST_PATHS) {
const manifest =
path === ".codex-plugin/plugin.json"
? await readJson(git, sha, path, codexPluginManifestSchema)
: await readJson(git, sha, path, inlinePluginManifestSchema);
versions.push([path, manifest.version]);
const server =
path === ".codex-plugin/plugin.json"
? await readJson(git, sha, ".mcp.json", codexServerConfigSchema)
: manifest;
launches.push([path, server.mcpServers.rea.args[1]]);
}
// Catalogs are generated and checked from this candidate by the release job;
// they are not version authority stored in the immutable source tree.
const generated = await git([
@@ -210,6 +256,16 @@ async function validateCandidate(git, sha, version, commits) {
);
}
}
// Plugin JSON stores only a scalar version. The launcher owns the fixed
// package name and constructs the complete registry specifier at runtime.
const requiredLaunch = version;
for (const [location, launched] of launches) {
if (launched !== requiredLaunch) {
throw new Error(
`${location}@${sha} launches version ${launched ?? "no version"}; release branch requires ${requiredLaunch}.`,
);
}
}
const notes = releaseNotes(
await git(["show", `${sha}:CHANGELOG.md`]),
version,
+65 -1
View File
@@ -365,6 +365,60 @@ export function verifyReleaseNotes(raw, text, body) {
};
}
const VERSION_ONLY_PLUGIN_METADATA = new Set([
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".mcp.json",
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
]);
/** Serialize plugin metadata with only its release-owned version fields blanked. */
function pluginMetadataWithoutVersions(path, text) {
const manifest = JSON.parse(text);
if (manifest === null || typeof manifest !== "object") return text;
if (path.endsWith("marketplace.json")) {
if (Array.isArray(manifest.plugins))
for (const plugin of manifest.plugins)
if (
plugin?.name === "rea" &&
plugin?.source?.source === "npm" &&
plugin.source.package === "rea-agents" &&
typeof plugin.source.version === "string"
)
plugin.source.version = "";
return JSON.stringify(manifest);
}
if ("version" in manifest) manifest.version = "";
const launch = manifest.mcpServers?.rea?.args;
// Only the scalar pinned version is release-owned; the launcher owns the
// fixed package name and the executable/path remain product behavior.
if (Array.isArray(launch) && typeof launch[1] === "string") launch[1] = "";
return JSON.stringify(manifest);
}
/**
* Plugin metadata also carries behavior, so a release may change only
* its version, pinned server launch, and exact REA marketplace version. Any other difference, an unreadable
* side, or a manifest added by the release counts as a product change.
*/
async function changesOnlyPluginVersions(run, path, from, to) {
try {
return (
pluginMetadataWithoutVersions(
path,
await run("git", ["show", `${from}:${path}`]),
) ===
pluginMetadataWithoutVersions(
path,
await run("git", ["show", `${to}:${path}`]),
)
);
} catch {
return false;
}
}
/** Reject stale inventories and product changes made after the editorial target. */
export async function verifyInventoryHistory(run, raw, source) {
const inventory = releaseInventorySchema.parse(raw);
@@ -416,6 +470,7 @@ export async function verifyInventoryHistory(run, raw, source) {
".release-please-manifest.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"server.json",
"src/generatedPackageMetadata.ts",
"docs/installation.md",
@@ -433,7 +488,16 @@ export async function verifyInventoryHistory(run, raw, source) {
)
.split("\0")
.filter(Boolean);
const product = paths.filter((path) => !allowed.has(path));
const product = [];
for (const path of paths) {
if (allowed.has(path)) continue;
if (
VERSION_ONLY_PLUGIN_METADATA.has(path) &&
(await changesOnlyPluginVersions(run, path, inventory.target, resolved))
)
continue;
product.push(path);
}
if (product.length)
throw new Error(
`Product changed after notes target; regenerate inventory: ${product.join(", ")}`,
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { access, mkdtemp, rm } from "node:fs/promises";
import { constants, tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
// Keep the package name at the launcher boundary so release automation updates
// only a scalar version in plugin JSON, never a compound package specifier.
// Git plugins launch the registry release. Windows source plugins additionally
// require a build and native addon for the owned process supervisor.
const [version, ...extra] = process.argv.slice(2);
if (
extra.length !== 0 ||
!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/u.test(version ?? "")
) {
process.stderr.write(
"REA plugin requires one exact rea-agents@VERSION pin.\n",
);
process.exitCode = 1;
} else {
await launch(`rea-agents@${version}`);
}
async function launch(packageSpecifier) {
// npm installs already carry the release-pinned runtime. Run it in this
// process so the plugin keeps the caller's working directory and the
// runtime retains ownership of provider and worker cleanup.
const { default: bundledPackage } = await import("../package.json", {
with: { type: "json" },
});
const sourceCheckout = await isSourceCheckout();
let bundledRuntimeMatchesPin =
!sourceCheckout &&
packageSpecifier === `${bundledPackage.name}@${bundledPackage.version}`;
if (bundledRuntimeMatchesPin) {
try {
await access(new URL("../dist/mcp/main.js", import.meta.url));
} catch (cause) {
if (!isMissing(cause)) throw cause;
bundledRuntimeMatchesPin = false;
}
}
if (bundledRuntimeMatchesPin) {
process.argv = [
process.execPath,
fileURLToPath(new URL("./rea.mjs", import.meta.url)),
"mcp",
];
await import("./rea.mjs");
return;
}
const runnerModule = await import(
sourceCheckout
? "../src/application/PackageRunnerCommand.mjs"
: "../dist/application/PackageRunnerCommand.mjs"
);
const { packageRunnerCommand } = runnerModule;
const [command, ...args] = packageRunnerCommand(
"npx",
packageSpecifier,
process.platform,
);
// npm can satisfy the exact pin from a same-version local checkout. An
// empty owned cwd prevents a Git-installed plugin or caller project from
// shadowing the registry package with unbuilt/different local files.
let workingDirectory;
try {
workingDirectory = await mkdtemp(join(tmpdir(), "rea-plugin-launcher-"));
} catch (cause) {
process.stderr.write(
`REA plugin could not create its working directory: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
if (process.platform === "win32") {
await launchWindowsFallback(command, args, workingDirectory);
return;
}
const child = spawn(command, args, {
cwd: workingDirectory,
// A dedicated POSIX process group gives this launcher ownership of the
// package runner and any server it starts.
detached: true,
stdio: "inherit",
shell: false,
});
let spawnFailed = false;
const signals = new Map();
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
const forward = () => {
if (child.exitCode !== null || child.signalCode !== null) return;
try {
if (process.platform !== "win32" && child.pid !== undefined)
process.kill(-child.pid, signal);
else child.kill(signal);
} catch (cause) {
if (!isMissingProcess(cause))
process.stderr.write(
`REA plugin could not forward ${signal}: ${cause.message}\n`,
);
}
};
signals.set(signal, forward);
process.on(signal, forward);
}
const stopForwarding = () => {
for (const [signal, listener] of signals) process.off(signal, listener);
};
child.once("error", (cause) => {
spawnFailed = true;
stopForwarding();
process.stderr.write(
`REA plugin could not start ${command}: ${cause.message}\n`,
);
process.exitCode = 1;
});
child.once("close", async (code, signal) => {
stopForwarding();
let cleanupFailed = false;
try {
await rm(workingDirectory, { recursive: true });
} catch (cause) {
cleanupFailed = true;
process.stderr.write(
`REA plugin retained its working directory at ${workingDirectory}: ${cause.message}\n`,
);
}
if (spawnFailed) return;
if (signal !== null && process.platform !== "win32") {
// Preserve signal termination after the owned child has closed its streams.
process.kill(process.pid, signal);
} else {
process.exitCode =
code === 0 && cleanupFailed
? 1
: (code ?? 128 + (constants.signals[signal] ?? 1));
}
});
}
async function isSourceCheckout() {
// Published artifacts exclude src/. Also reject Git worktrees (whose .git is
// a file) and source exports where Git metadata has been stripped.
for (const path of ["../.git", "../src"]) {
try {
await access(new URL(path, import.meta.url));
return true;
} catch (cause) {
if (!isMissing(cause)) throw cause;
}
}
return false;
}
async function launchWindowsFallback(command, args, workingDirectory) {
let spawnOwnedProviderProcess;
try {
({ spawnOwnedProviderProcess } =
await import("../dist/process/ProviderProcess.js"));
} catch (cause) {
await rm(workingDirectory, { recursive: true }).catch(() => {});
process.stderr.write(
`REA Windows source plugins require npm ci && npm run build:cached and the Windows x64 native addon before loading the plugin. The owned process supervisor is unavailable: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
let owned;
try {
owned = await spawnOwnedProviderProcess({
command,
arguments: args,
runId: randomUUID(),
cwd: workingDirectory,
stdin: "pipe",
platform: "win32",
});
} catch (cause) {
await rm(workingDirectory, { recursive: true }).catch(() => {});
process.stderr.write(
`REA plugin could not start the owned Windows process tree: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
const child = owned.process;
child.stdout?.pipe(process.stdout);
child.stderr?.pipe(process.stderr);
if (child.stdin === null || child.stdin === undefined) {
const cleanup = await owned.cleanup?.();
if (cleanup?.cleaned === true)
await rm(workingDirectory, { recursive: true }).catch(() => {});
else
process.stderr.write(
`REA plugin retained its Windows process ownership directory at ${workingDirectory} because cleanup was not verified.\n`,
);
process.stderr.write(
"REA plugin Windows process supervisor did not provide protocol stdin.\n",
);
process.exitCode = 1;
return;
}
process.stdin.pipe(child.stdin);
let cleanupFailed = false;
let cleanupPromise;
const signals = new Map();
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
const forward = () => {
if (child.exitCode !== null || child.signalCode !== null) return;
cleanupPromise ??= owned
.cleanup?.()
.then((result) => {
if (!result.cleaned) {
cleanupFailed = true;
process.stderr.write(
`REA plugin could not verify Windows process-tree cleanup: ${result.reason}\n`,
);
}
})
.catch((cause) => {
cleanupFailed = true;
process.stderr.write(
`REA plugin Windows process-tree cleanup failed: ${cause.message}\n`,
);
});
};
signals.set(signal, forward);
process.on(signal, forward);
}
child.once("error", (cause) => {
process.stderr.write(
`REA plugin Windows process supervisor failed: ${cause.message}\n`,
);
process.exitCode = 1;
});
child.once("close", async (code) => {
for (const [signal, listener] of signals) process.off(signal, listener);
await cleanupPromise;
if (!cleanupFailed) {
try {
await rm(workingDirectory, { recursive: true });
} catch (cause) {
cleanupFailed = true;
process.stderr.write(
`REA plugin retained its working directory at ${workingDirectory}: ${cause.message}\n`,
);
}
}
process.exitCode = code === 0 && cleanupFailed ? 1 : (code ?? 1);
});
}
function isMissing(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
);
}
function isMissingProcess(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ESRCH"
);
}
+10 -5
View File
@@ -3,13 +3,14 @@ import {
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readFile, rm } from "node:fs/promises";
import { readFile, realpath, rm } from "node:fs/promises";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import * as prompts from "./verify-package-prompts.mjs";
@@ -246,7 +247,7 @@ const verifyMcpInlineArtifactEvidence = async (
}
};
/** Connect to the packaged MCP server and verify catalog and Evidence composition. */
/** Check CLI diagnostics, then verify catalog and Evidence through the installed plugin. */
export async function verifyPackageMcp({
cli,
environment,
@@ -267,9 +268,13 @@ export async function verifyPackageMcp({
diagnosed.inventory?.tools?.expected
)
throw new Error("packaged production MCP doctor failed");
const scriptsDirectory = dirname(await realpath(cli));
const transport = new StdioClientTransport({
command: cli,
args: ["mcp"],
command: process.execPath,
args: [
join(scriptsDirectory, "plugin-mcp-launcher.mjs"),
PRODUCT_IDENTITY.packageVersion,
],
env: {
...environment,
},
+70
View File
@@ -25,6 +25,23 @@ export async function verifyPackagePack({ root, workspace }) {
const packedManifest = JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout,
);
const packedLock = JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/npm-shrinkwrap.json"]))
.stdout,
);
if (
packedManifest.overrides !== undefined ||
![2, 3].includes(packedLock.lockfileVersion) ||
packedLock.version !== packedManifest.version ||
packedLock.packages?.[""].version !== packedManifest.version ||
JSON.stringify(packedLock.packages?.[""].dependencies) !==
JSON.stringify(packedManifest.dependencies) ||
JSON.stringify(packedLock.packages?.[""].optionalDependencies) !==
JSON.stringify(packedManifest.optionalDependencies)
)
throw new Error(
"package is not installable under the Claude plugin dependency policy",
);
if (
packedManifest.scripts?.postinstall !== undefined ||
packedManifest.dependencies?.["node-pty"] !== undefined ||
@@ -42,6 +59,10 @@ export async function verifyPackagePack({ root, workspace }) {
throw new Error(
"package included authored skill sources instead of only the generated bundle",
);
if (packedFiles.some((path) => path.startsWith("package/src/")))
throw new Error(
"Packaged runtime must not masquerade as a source checkout",
);
if (packedFiles.some((path) => path.startsWith("package/.cache/")))
throw new Error("package included development cache or test metadata");
const skill = (
@@ -60,6 +81,55 @@ export async function verifyPackagePack({ root, workspace }) {
throw new Error(
"packaged skill metadata did not match the catalog shipped in the package",
);
for (const path of [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
]) {
const manifest = JSON.parse(
(await exec("tar", ["-xOf", tarball, `package/${path}`])).stdout,
);
const serverConfig = path.startsWith(".codex")
? JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/.mcp.json"])).stdout,
)
: manifest;
const expectedArgs = [
`${path.startsWith(".claude") ? "${CLAUDE_PLUGIN_ROOT}/" : "./"}scripts/plugin-mcp-launcher.mjs`,
packedManifest.version,
];
if (
manifest.version !== packedManifest.version ||
manifest.skills !== "./skills" ||
(path.startsWith(".codex")
? manifest.mcpServers !== "./.mcp.json" ||
serverConfig.mcpServers?.rea?.cwd !== "." ||
serverConfig.mcpServers?.rea?.startup_timeout_sec !== 30
: false) ||
serverConfig.mcpServers?.rea?.command !== "node" ||
JSON.stringify(serverConfig.mcpServers?.rea?.args) !==
JSON.stringify(expectedArgs)
)
throw new Error(
`Packaged plugin ${path} does not match the exact runtime and canonical skill release`,
);
}
if (!packedFiles.includes("package/.mcp.json"))
throw new Error("Packaged Codex MCP server declaration is missing");
for (const path of [
"scripts/plugin-mcp-launcher.mjs",
"dist/application/PackageRunnerCommand.mjs",
])
if (!packedFiles.includes(`package/${path}`))
throw new Error(
`Packaged plugin launcher dependency is missing: ${path}`,
);
if (
skill.includes(`${packedManifest.name}@latest`) ||
!skill.includes(`${packedManifest.name}@${packedManifest.version}`)
)
throw new Error(
"Packaged plugin skill recovery is not pinned to the shipped runtime",
);
if (
packedFiles.some(
(path) => path.includes("__pycache__") || path.endsWith(".pyc"),
@@ -2,6 +2,7 @@ import { basename, isAbsolute, resolve } from "node:path";
import { valid } from "semver";
import { PRODUCT_IDENTITY } from "../identity.js";
import { packageRunnerCommand } from "./PackageRunnerCommand.mjs";
/** Package runners whose pinned launcher setup can register. */
export type PackageRunner = "npx" | "bunx";
@@ -53,14 +54,12 @@ export const withoutPackageRunnerIdentity = (
export const packageRunnerRegistrationCommand = (
runner: PackageRunner,
platform: NodeJS.Platform = process.platform,
): readonly string[] => {
const command =
runner === "npx"
? ["npx", "-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]
: ["bunx", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"];
// Both runners may be .cmd shims on Windows, which clients cannot spawn directly.
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
};
): readonly string[] =>
packageRunnerCommand(
runner,
PRODUCT_IDENTITY.registrationPackageSpecifier,
platform,
);
/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */
export const npxRegistrationCommand = (
@@ -0,0 +1,6 @@
/** Exact host argv for a pinned registry package's MCP entry point. */
export function packageRunnerCommand(
runner: "npx" | "bunx",
packageSpecifier: string,
platform: NodeJS.Platform,
): readonly string[];
+16
View File
@@ -0,0 +1,16 @@
/** Host argv shared by setup registrations and the source-only plugin launcher. */
export const packageRunnerCommand = (runner, packageSpecifier, platform) => {
if (runner !== "npx" && runner !== "bunx")
throw new Error("Unsupported package runner");
// Windows cmd.exe consumes these tokens itself. Package registrations use
// exact registry references, never shell expressions or arbitrary operands.
if (!/^[@A-Za-z0-9_./+-]+$/u.test(packageSpecifier))
throw new Error("Package runner requires a shell-safe registry reference");
const command =
runner === "npx"
? ["npx", "-y", packageSpecifier, "mcp"]
: ["bunx", packageSpecifier, "mcp"];
// npm's .cmd shim is not a native executable. Disable cmd AutoRun, matching
// the setup registration policy, and keep all dynamic values out of syntax.
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
};
@@ -1,5 +1,6 @@
import { dirname } from "node:path";
import { realpath } from "node:fs/promises";
import { constants } from "node:fs";
import { access, realpath, stat } from "node:fs/promises";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type {
@@ -43,11 +44,35 @@ const resolveInputPath = async (
}
};
/** Resolve the executable and require a launchable regular file before any spawn. */
const resolveExecutablePath = async (
selected: string,
): ReturnType<typeof resolveInputPath> => {
const resolved = await resolveInputPath("executable_path", selected);
if (!resolved.ok) return resolved;
try {
if (!(await stat(resolved.value)).isFile())
throw new Error("not a regular file");
if (process.platform !== "win32")
await access(resolved.value, constants.X_OK);
return resolved;
} catch (cause: unknown) {
return err({
issue: {
path: ["executable_path"],
reason: "invalid_value",
message: `executable_path ${selected} is not an executable file: ${cause instanceof Error ? cause.message : String(cause)}`,
},
cause,
});
}
};
const canonicalizeInput = async (
input: ElectronActiveObservationInput,
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
const [executable, application, root] = await Promise.all([
resolveInputPath("executable_path", input.executable_path),
resolveExecutablePath(input.executable_path),
resolveInputPath("application_path", input.application_path),
input.application_root === undefined
? Promise.resolve(ok(undefined))
@@ -96,13 +96,16 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
if (options.signal?.aborted)
throw new BrowserObservationError(OPERATION, "cancelled");
application = await this.launch({
const launchOptions: Parameters<typeof electron.launch>[0] = {
executablePath: paths.executable,
cwd: paths.root,
env: safeElectronEnvironment(this.environment, runId),
args: ["-r", hookPath, paths.application, ...input.args],
timeout: Math.max(1, startupDeadline - performance.now()),
});
};
application = await containLaunchRejections(() =>
this.launch(launchOptions),
);
const leaderPid = application.process().pid;
if (
typeof leaderPid !== "number" ||
@@ -377,6 +380,88 @@ const safeElectronEnvironment = (
["REA_PROCESS_RUN_ID", runId] as const,
]);
const PLAYWRIGHT_INTERNAL_FRAME = /playwright-core[\\/]lib[\\/]/;
interface LaunchRejectionScope {
failure?: unknown;
}
const activeLaunches = new Set<LaunchRejectionScope>();
const heldLaunchRejections = new Map<
Promise<unknown>,
{
readonly reason: Error;
readonly candidates: Set<LaunchRejectionScope>;
readonly hadExternalListener: boolean;
}
>();
const holdLaunchRejection = (
reason: unknown,
promise: Promise<unknown>,
): void => {
if (
!(reason instanceof Error) ||
!PLAYWRIGHT_INTERNAL_FRAME.test(reason.stack ?? "")
)
throw reason;
if (!heldLaunchRejections.has(promise))
heldLaunchRejections.set(promise, {
reason,
candidates: new Set(activeLaunches),
hadExternalListener: process
.listeners("unhandledRejection")
.some((listener) => listener !== holdLaunchRejection),
});
};
const forwardLaunchRejection = (
reason: Error,
hadExternalListener: boolean,
): void => {
// Listeners present at the original event already observed it. When the
// holding listener was the only listener, restore Node's fatal behavior.
if (!hadExternalListener)
process.nextTick(() => {
throw reason;
});
};
/** Playwright leaves rejected launch promises unhandled; absorb only those that repeat this launch's failure. */
const containLaunchRejections = async <T>(
launch: () => Promise<T>,
): Promise<T> => {
const scope: LaunchRejectionScope = {};
if (activeLaunches.size === 0)
process.on("unhandledRejection", holdLaunchRejection);
activeLaunches.add(scope);
try {
return await launch();
} catch (cause: unknown) {
scope.failure = cause;
throw cause;
} finally {
await new Promise<void>((resolve) => setImmediate(resolve));
activeLaunches.delete(scope);
if (activeLaunches.size === 0)
process.off("unhandledRejection", holdLaunchRejection);
for (const [
promise,
{ reason, candidates, hadExternalListener },
] of heldLaunchRejections) {
if (!candidates.delete(scope)) continue;
if (
scope.failure instanceof Error &&
reason.message !== "" &&
scope.failure.message.includes(reason.message)
)
heldLaunchRejections.delete(promise);
else if (candidates.size === 0) {
heldLaunchRejections.delete(promise);
forwardLaunchRejection(reason, hadExternalListener);
}
}
}
};
const providerError = (cause: unknown): AnalysisError =>
cause instanceof AnalysisError
? cause
+34 -2
View File
@@ -45,6 +45,24 @@ const registerOverviewCommands = (
.describe("Load and update a local analysis snapshot"),
...nativeOpenOptionFields,
});
// `analyze` declares the JavaScript integrity policy for its JavaScript
// lane. Preserve whether the caller supplied it until the target lane is
// known, then apply the JavaScript default only in that lane. The owner
// field fills an omission with that default, and an `.optional()` wrapper
// keeps reporting it, so every binary target would look like an explicit
// policy. Remove the owner's default before making the CLI option optional;
// reapply its metadata so help and `--schema` keep describing the option.
const ownerIntegrityPolicy =
javascriptApplicationOptions.shape.integrityPolicy;
const defaultIntegrityPolicy = ownerIntegrityPolicy.parse(undefined);
const policyMetadata = ownerIntegrityPolicy.meta();
const omittableIntegrityPolicy = ownerIntegrityPolicy
.removeDefault()
.optional();
const laneIntegrityPolicy =
policyMetadata === undefined
? omittableIntegrityPolicy
: omittableIntegrityPolicy.meta(policyMetadata);
cli.command(CLI_COMMANDS.analyze, {
description: "Get an overview of an app",
args: z.object({
@@ -54,7 +72,7 @@ const registerOverviewCommands = (
.describe("App, program, or analysis database path"),
}),
options: overviewOptions.extend({
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy,
integrityPolicy: laneIntegrityPolicy,
sourcePath: javascriptApplicationOptions.shape.sourcePath,
}),
run: async ({ args, options }) => {
@@ -67,7 +85,8 @@ const registerOverviewCommands = (
runCliJavaScriptApplicationAnalysis(
{
input_path: resolve(args.path),
integrity_policy: options.integrityPolicy,
integrity_policy:
options.integrityPolicy ?? defaultIntegrityPolicy,
...(options.sourcePath === undefined
? {}
: { source_paths: options.sourcePath }),
@@ -90,6 +109,19 @@ const registerOverviewCommands = (
},
],
);
if (options.integrityPolicy !== undefined)
throw new AnalysisInputError(
"analyze_javascript_application",
undefined,
[
{
path: ["integrity_policy"],
reason: "invalid_value",
message:
"An integrity policy requires the static JavaScript ASAR/directory analysis lane; binary_overview accepts no input.",
},
],
);
return runDirectAnalysis(
args.path,
"binary_overview",
+1 -1
View File
@@ -1,4 +1,4 @@
/** Generated from package.json and package-lock.json; do not edit. */
/** Generated from package.json and npm-shrinkwrap.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "6.4.0", // x-release-please-version
+6 -1
View File
@@ -15,10 +15,13 @@ import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = (
overrides: Partial<GhidraInstallationHost> = {},
platform: NodeJS.Platform = "linux",
): GhidraInstallationHost => ({
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: platform === "win32" ? "Windows 11" : "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
@@ -45,7 +48,7 @@ const inspection = (
? {}
: { installDir: options.installDir }),
},
installationHost(overrides),
installationHost(overrides, options.platform ?? "linux"),
);
const doctorHost = (
@@ -129,6 +132,8 @@ describe("Ghidra doctor integration", () => {
{ installDir: INSTALL },
{
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "17.0.12",
major: 17,
home: "/usr/lib/jvm/jdk-17",
+2
View File
@@ -12,5 +12,7 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = {
home: "/jdk-21",
bits: 64,
runtime: "jdk",
osArch: "amd64",
osName: "Linux",
}),
};
+238 -3
View File
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js";
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
import {
ghidraInstallationDiagnostics,
ghidraJavaEnvironment,
inspectGhidraInstallation,
type GhidraExecutableHeaderProbe,
@@ -16,6 +18,14 @@ import {
ghidraJavaRequirement,
} from "./GhidraInstallationPolicy.js";
// The real Java probe shells out via spawnSync; stub it so one test can run
// the real probeJava against captured `-XshowSettings:properties` output.
// Every other test overrides probeJava, so the stub never fires for them.
vi.mock("node:child_process", async (importOriginal) => {
const original = await importOriginal<typeof import("node:child_process")>();
return { ...original, spawnSync: vi.fn() };
});
const INSTALL = "/opt/ghidra";
const PROPERTIES = `${INSTALL}/Ghidra/application.properties`;
const HEADLESS = `${INSTALL}/support/analyzeHeadless`;
@@ -32,6 +42,8 @@ const headerProbe = (bytes: Buffer): GhidraExecutableHeaderProbe => ({
});
const JAVA: GhidraJavaObservation = {
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: SUPPORTED_GHIDRA_JAVA_MAJOR,
home: "/usr/lib/jvm/jdk-21",
@@ -174,6 +186,11 @@ describe("Ghidra installation inspection", () => {
{ environment: {}, installDir: INSTALL, platform, architecture },
host({
executable: (path) => path === HEADLESS || path === decompiler,
probeJava: () => ({
...JAVA,
osArch: architecture === "arm64" ? "aarch64" : "amd64",
osName: platform === "darwin" ? "Mac OS X" : "Linux",
}),
}),
),
).toMatchObject({
@@ -202,6 +219,7 @@ describe("Ghidra installation inspection", () => {
return {
...JAVA,
home: "C:\\Java\\jdk-21",
osName: "Windows 11",
};
},
};
@@ -721,7 +739,10 @@ describe("Ghidra FAT native decompiler admission", () => {
platform: "darwin",
architecture: "arm64",
},
host({ executableHeader: () => headerProbe(bytes) }),
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: () => headerProbe(bytes),
}),
);
expect(result.status).toBe(status);
expect(
@@ -739,7 +760,10 @@ describe("Ghidra FAT native decompiler admission", () => {
platform: "darwin",
architecture: "arm64",
},
host({ executableHeader: () => headerProbe(bytes) }),
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: () => headerProbe(bytes),
}),
);
expect(result.status).toBe("available");
expect(
@@ -788,7 +812,14 @@ describe("Ghidra native decompiler admission", () => {
])("distinguishes $name", ({ options, executableHeader }) => {
const result = inspectGhidraInstallation(
options,
host({ executableHeader }),
host({
probeJava: () => ({
...JAVA,
osArch: options.architecture === "arm64" ? "aarch64" : "amd64",
osName: options.platform === "darwin" ? "Mac OS X" : "Linux",
}),
executableHeader,
}),
);
expect(result.status).toBe("unavailable");
expect(
@@ -849,6 +880,7 @@ describe("Ghidra native decompiler admission", () => {
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: (path: string) =>
path === MAC_DECOMPILER ? headerProbe(bytes) : undefined,
}),
@@ -872,6 +904,7 @@ describe("Ghidra native decompiler admission", () => {
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
executableHeader: (path: string) =>
path === MAC_DECOMPILER ? headerProbe(elf(2, 1, 62)) : undefined,
}),
@@ -889,3 +922,205 @@ describe("Ghidra native decompiler admission", () => {
});
});
});
describe("Ghidra JVM native directory admission", () => {
it("rejects a JDK whose os.arch selects a different native decompiler directory", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch: "x86_64", osName: "Mac OS X" }),
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
code: "executable_missing",
detail: expect.stringContaining(
"JVM reports os.arch=x86_64, so Ghidra selects its native decompiler from os/mac_x86_64",
),
remediation: expect.stringContaining("JAVA_HOME"),
});
});
it("accepts a JDK whose os.arch selects the validated native decompiler directory", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch: "aarch64", osName: "Mac OS X" }),
}),
);
expect(result.status).toBe("available");
});
it("rejects a successful Java probe that omits os.arch", () => {
const { osArch: _observedArchitecture, ...withoutArchitecture } = JAVA;
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({ probeJava: () => withoutArchitecture }),
);
expect(result.status).toBe("unavailable");
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("did not report os.arch"),
});
expect(projectGhidraDoctorInspection(result).available).toBe(false);
});
it.each(["riscv64", "sparcv9", ""])(
"refuses an unrecognized JVM os.arch (%s) instead of assuming alignment",
(osArch) => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
probeJava: () => ({ ...JAVA, osArch }),
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("unrecognized os.arch="),
});
},
);
it("rejects a JDK whose os.name selects a different host OS", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "darwin",
architecture: "arm64",
},
host({
probeJava: () => ({
...JAVA,
osArch: "aarch64",
osName: "Windows 11",
}),
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
code: "executable_missing",
detail: expect.stringContaining("JVM reports os.name=Windows 11"),
remediation: expect.stringContaining("JAVA_HOME"),
});
});
});
describe("Ghidra JVM OS admission", () => {
it("rejects a successful Java probe that omits os.name", () => {
const { osName: _observedOsName, ...withoutOsName } = JAVA;
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "linux",
architecture: "x64",
},
host({ probeJava: () => withoutOsName }),
);
expect(result.status).toBe("unavailable");
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("did not report os.name"),
});
expect(projectGhidraDoctorInspection(result).available).toBe(false);
});
it.each(["SunOS", "FreeBSD", ""])(
"refuses an unrecognized JVM os.name (%s) instead of assuming alignment",
(osName) => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "linux",
architecture: "x64",
},
host({
probeJava: () => ({ ...JAVA, osName }),
}),
);
expect(result.status).toBe("unavailable");
if (result.status !== "unavailable") return;
expect(
result.checks.find(({ name }) => name === "native_decompiler"),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("os.name="),
});
},
);
});
describe("Ghidra Java probe os.arch extraction", () => {
it("extracts os.arch through the real Java probe into the installation diagnostics", () => {
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: "",
stderr: [
"Property settings:",
" file.encoding = UTF-8",
" java.home = /usr/lib/jvm/jdk-21",
" java.version = 21.0.11",
" java.vm.name = OpenJDK 64-Bit Server VM",
" os.arch = x86_64",
" os.name = Linux",
" sun.arch.data.model = 64",
"",
].join("\n"),
} as never);
try {
// No host override: the real system host runs the real probeJava
// against the stubbed spawnSync.
const inspection = inspectGhidraInstallation({
environment: {},
installDir: INSTALL,
javaHome: "/usr/lib/jvm/jdk-21",
platform: "linux",
architecture: "x64",
});
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
java_os_arch: "x86_64",
java_os_name: "Linux",
});
} finally {
vi.mocked(spawnSync).mockReset();
}
});
});
+126
View File
@@ -203,6 +203,17 @@ interface GhidraInstallationObservation {
readonly javaCommand: string;
/** Observed java.vm.name; omitted when the probe cannot establish VM identity. */
readonly javaVmName?: string;
/**
* Observed JVM os.arch; omitted when the probe cannot establish it. Ghidra
* selects its native decompiler directory from this value.
*/
readonly javaOsArch?: string;
/**
* Observed JVM os.name; omitted when the probe cannot establish it. Ghidra
* selects its native decompiler OS directory from the JVM OS/architecture
* pair, so a non-host value selects an unvalidated directory.
*/
readonly javaOsName?: string;
readonly checks: readonly GhidraInstallationCheck[];
}
@@ -244,6 +255,17 @@ export interface GhidraJavaObservation {
readonly bits: number;
readonly runtime: "jdk" | "jre";
readonly vmName?: string;
/**
* Observed JVM os.arch property, which Ghidra uses to select its native
* decompiler directory. Omitted when the probe cannot establish it.
*/
readonly osArch?: string;
/**
* Observed JVM os.name property, which Ghidra uses with os.arch to select
* its native decompiler OS directory. Omitted when the probe cannot
* establish it.
*/
readonly osName?: string;
}
/** A bounded executable-header probe: a small prefix plus the whole file size. */
@@ -324,6 +346,12 @@ export const inspectGhidraInstallation = (
...(coordinates.java?.vmName === undefined
? {}
: { javaVmName: coordinates.java.vmName }),
...(coordinates.java?.osArch === undefined
? {}
: { javaOsArch: coordinates.java.osArch }),
...(coordinates.java?.osName === undefined
? {}
: { javaOsName: coordinates.java.osName }),
checks,
};
if (failed !== undefined)
@@ -555,6 +583,8 @@ export const ghidraInstallationDiagnostics = (
java_home: inspection.javaHome,
java_version: inspection.javaVersion,
java_vm_name: inspection.javaVmName ?? null,
java_os_arch: inspection.javaOsArch ?? null,
java_os_name: inspection.javaOsName ?? null,
platform: inspection.platform,
architecture: inspection.architecture,
checks: inspection.checks.map((check) => ({
@@ -632,6 +662,61 @@ const nativeDecompilerCheck = ({
architecture,
host.executableHeader?.(path),
);
// Ghidra resolves its native decompiler directory from the JVM's os.arch,
// not the host process architecture. A JDK running under emulation (for
// example x86_64 under Rosetta on Apple silicon) selects a directory that
// was never validated, so every analysis fails after a passing doctor.
// Ghidra resolves the OS directory from the JVM os.name/os.arch pair, so a
// JVM reporting a non-host os.name (for example through -Dos.name=Windows)
// selects a different OS directory even when os.arch matches.
const osArch = coordinates.java?.osArch;
const osName = coordinates.java?.osName;
const selectedDirectory = jvmOsArchNativeDirectory(platform, osArch);
const validatedDirectory = NATIVE_PLATFORMS[platform]?.[architecture];
// Without a JVM observation the java check reports the missing runtime;
// the decompiler directory cannot be selected, so this check defers to it.
if (coordinates.java !== undefined) {
if (selectedDirectory === undefined)
return installationCheck({
name: "native_decompiler",
passed: false,
code: "executable_missing",
detail:
osArch === undefined
? "The Java probe did not report os.arch; the JVM-selected native decompiler directory is unknown and cannot be matched to the validated host directory"
: `JVM reports unrecognized os.arch=${osArch}; its selected native decompiler directory is unknown and cannot be matched to the validated host directory`,
remediation: `Point JAVA_HOME at a JDK whose reported architecture maps to the validated Ghidra decompiler (${validatedDirectory ?? "unsupported host"}). REA does not build or install native tools.`,
});
if (
validatedDirectory !== undefined &&
selectedDirectory !== validatedDirectory &&
path !== null
)
return installationCheck({
name: "native_decompiler",
passed: false,
code: "executable_missing",
detail: `JVM reports os.arch=${osArch}, so Ghidra selects its native decompiler from os/${selectedDirectory}, but the validated decompiler targets ${validatedDirectory} (host ${platform}/${architecture})`,
remediation: `Point JAVA_HOME at a JDK whose architecture matches the built decompiler (${validatedDirectory}). REA does not build or install native tools.`,
});
if (osName === undefined)
return installationCheck({
name: "native_decompiler",
passed: false,
code: "executable_missing",
detail:
"The Java probe did not report os.name; the JVM-selected native decompiler OS directory is unknown and cannot be matched to the validated host platform",
remediation: `Point JAVA_HOME at a JDK whose reported operating system matches the host platform (${platform}). REA does not build or install native tools.`,
});
if (!jvmOsNameMatchesPlatform(platform, osName) && path !== null)
return installationCheck({
name: "native_decompiler",
passed: false,
code: "executable_missing",
detail: `JVM reports os.name=${osName}, which does not match the validated host platform ${platform} (host ${platform}/${architecture})`,
remediation: `Point JAVA_HOME at a JDK whose reported operating system matches the host platform (${platform}). REA does not build or install native tools.`,
});
}
return installationCheck({
name: "native_decompiler",
passed: path !== null && compatibility?.status !== "incompatible",
@@ -646,6 +731,43 @@ const nativeDecompilerCheck = ({
});
};
/**
* Whether a JVM os.name value selects the host platform's native OS directory.
* Unknown values never align with the validated host directory; admission
* must not treat them as matching.
*/
const jvmOsNameMatchesPlatform = (
platform: NodeJS.Platform,
osName: string,
): boolean => {
const normalized = osName.toLowerCase();
if (platform === "win32") return normalized.startsWith("windows");
if (platform === "darwin")
return normalized.startsWith("mac") || normalized.startsWith("darwin");
if (platform === "linux") return normalized.startsWith("linux");
return false;
};
/**
* Map a JVM os.arch value to the Ghidra native directory that JVM selects.
* Unknown values stay unknown; admission must not treat them as aligned with
* the validated host directory.
*/
const jvmOsArchNativeDirectory = (
platform: NodeJS.Platform,
osArch: string | undefined,
): string | undefined => {
if (osArch === undefined) return undefined;
const normalized = osArch.toLowerCase();
const node: NodeJS.Architecture | undefined =
normalized === "x86_64" || normalized === "amd64" || normalized === "x64"
? "x64"
: normalized === "aarch64" || normalized === "arm64"
? "arm64"
: undefined;
return node === undefined ? undefined : NATIVE_PLATFORMS[platform]?.[node];
};
const versionCheck = (
properties: GhidraApplicationProperties | undefined,
): GhidraInstallationCheck => {
@@ -781,6 +903,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
const version = javaProperty(output, "java.version");
const home = javaProperty(output, "java.home");
const vmName = javaProperty(output, "java.vm.name");
const osArch = javaProperty(output, "os.arch");
const osName = javaProperty(output, "os.name");
const bits = Number.parseInt(
javaProperty(output, "sun.arch.data.model") ?? "0",
10,
@@ -791,6 +915,8 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
major: javaMajor(version),
home,
...(vmName === null ? {} : { vmName }),
...(osArch === null ? {} : { osArch }),
...(osName === null ? {} : { osName }),
bits,
runtime: executablePath(
(process.platform === "win32" ? win32 : posix).join(
+62 -2
View File
@@ -2,11 +2,43 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { dirname, join } from "node:path";
import { execFileOutput } from "../process/ExecFileOutput.js";
/** JVM option environments remove quotes, including quotes inside an option. */
const jvmEnvironmentArguments = (value: string): readonly string[] => {
const arguments_: string[] = [];
let argument = "";
let quote: "'" | '"' | undefined;
for (const character of value) {
if (quote !== undefined) {
if (character === quote) quote = undefined;
else argument += character;
} else if (character === "'" || character === '"') quote = character;
else if (/\s/u.test(character)) {
if (argument !== "") arguments_.push(argument);
argument = "";
} else argument += character;
}
if (quote !== undefined)
throw new Error(
"Invalid quoted Ghidra LaunchSupport JVM environment option",
);
if (argument !== "") arguments_.push(argument);
return arguments_;
};
const rejectJvmPlatformOverrides = (arguments_: readonly string[]): void => {
if (
arguments_.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument))
)
throw new Error(
"Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.",
);
};
/** Launch coordinates obtained from Ghidra's own release configuration helper. */
export const ghidraJavaLaunch = async (options: {
readonly platform: NodeJS.Platform;
readonly analyzeHeadlessPath: string;
readonly javaHome: string;
readonly javaHome?: string;
readonly javaVmName?: string;
readonly homeRoot: string;
readonly tempRoot: string;
@@ -21,7 +53,17 @@ export const ghidraJavaLaunch = async (options: {
}> => {
const supportRoot = dirname(options.analyzeHeadlessPath);
const installRoot = dirname(supportRoot);
const command = join(options.javaHome, "bin", "java");
const selectedJavaHome = options.javaHome ?? options.environment.JAVA_HOME;
const command =
selectedJavaHome === undefined
? options.platform === "win32"
? "java.exe"
: "java"
: join(
selectedJavaHome,
"bin",
options.platform === "win32" ? "java.exe" : "java",
);
const isolatedProperties = [
`-Duser.home=${options.homeRoot}`,
`-Djava.io.tmpdir=${options.tempRoot}`,
@@ -48,6 +90,9 @@ export const ghidraJavaLaunch = async (options: {
[...supportArguments, "-envvars"],
{
env: environment,
// Wait for the helper's exit on abort so a cancelled or timed-out
// startup never leaves the probe running without an owning process.
stopSignal: "SIGTERM",
...(options.signal === undefined ? {} : { signal: options.signal }),
},
);
@@ -65,11 +110,25 @@ export const ghidraJavaLaunch = async (options: {
if ((environment[key] ?? "") === "")
environment[key] = line.slice(separator + 1);
}
// The merged environment becomes the analysis JVM's environment, and the
// JVM applies these options variables automatically. An injected
// -Dos.arch/-Dos.name there selects an unvalidated native directory without
// appearing in vmArguments, so reject the effective values as well.
for (const key of [
"JAVA_TOOL_OPTIONS",
"_JAVA_OPTIONS",
"JDK_JAVA_OPTIONS",
] as const) {
rejectJvmPlatformOverrides(jvmEnvironmentArguments(environment[key] ?? ""));
}
const configuredArguments = await execFileOutput(
command,
[...supportArguments, "-vmargs"],
{
env: environment,
// Wait for the helper's exit on abort so a cancelled or timed-out
// startup never leaves the probe running without an owning process.
stopSignal: "SIGTERM",
...(options.signal === undefined ? {} : { signal: options.signal }),
},
);
@@ -83,6 +142,7 @@ export const ghidraJavaLaunch = async (options: {
)
)
throw new Error("Invalid Ghidra LaunchSupport JVM argument");
rejectJvmPlatformOverrides(vmArguments);
return {
command,
environment,
+41 -36
View File
@@ -172,6 +172,8 @@ export interface GhidraHeadlessLauncherOptions {
readonly mipsSoftFloat?: true;
/** Spawn seam for provider-boundary lifecycle tests. */
readonly spawnProcess?: typeof spawnOwnedProviderProcess;
/** LaunchSupport probe seam for Java-free launcher fixtures. */
readonly javaLaunch?: typeof ghidraJavaLaunch;
}
/** Launch Ghidra without copying scripts into or modifying its installation. */
@@ -303,43 +305,46 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
throw new GhidraLaunchError(
"POSIX Ghidra requires its inspected JDK home",
);
const inspectedJavaLaunch = await (
this.options.javaLaunch ?? ghidraJavaLaunch
)({
platform,
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
...(this.options.javaHome === undefined
? {}
: { javaHome: this.options.javaHome }),
...(this.options.javaVmName === undefined
? {}
: { javaVmName: this.options.javaVmName }),
homeRoot: paths.homeRoot,
tempRoot: paths.tempRoot,
arguments: headlessArguments,
environment,
onStderr: (mode, stderr) => {
if (stderr === "") return;
const bytes = Buffer.byteLength(stderr);
const redacted = redactCapturedTransportCredential(
{ text: stderr, bytes, observedBytes: bytes },
session.token,
"[REDACTED]",
);
const bounded = boundedUtf8Text(
redacted,
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
);
launchSupport.push({
mode,
stderr: bounded.text,
stderr_bytes: bytes,
stderr_truncated: bounded.truncated,
});
},
...(options.signal === undefined ? {} : { signal: options.signal }),
});
const command =
platform !== "win32" && this.options.javaHome !== undefined
? await ghidraJavaLaunch({
platform,
analyzeHeadlessPath: this.options.analyzeHeadlessPath,
javaHome: this.options.javaHome,
...(this.options.javaVmName === undefined
? {}
: { javaVmName: this.options.javaVmName }),
homeRoot: paths.homeRoot,
tempRoot: paths.tempRoot,
arguments: headlessArguments,
environment,
onStderr: (mode, stderr) => {
if (stderr === "") return;
const bytes = Buffer.byteLength(stderr);
const redacted = redactCapturedTransportCredential(
{ text: stderr, bytes, observedBytes: bytes },
session.token,
"[REDACTED]",
);
const bounded = boundedUtf8Text(
redacted,
GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES,
);
launchSupport.push({
mode,
stderr: bounded.text,
stderr_bytes: bytes,
stderr_truncated: bounded.truncated,
});
},
...(options.signal === undefined
? {}
: { signal: options.signal }),
})
: { ...scriptCommand, environment };
platform === "win32"
? { ...scriptCommand, environment }
: inspectedJavaLaunch;
started = await (this.options.spawnProcess ?? spawnOwnedProviderProcess)({
command: command.command,
arguments: command.arguments,
+17 -9
View File
@@ -37,12 +37,16 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = (): GhidraInstallationHost => ({
platform: "linux",
const installationHost = (
platform: NodeJS.Platform = "linux",
): GhidraInstallationHost => ({
platform,
architecture: "x64",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: platform === "win32" ? "Windows 11" : "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
@@ -102,7 +106,7 @@ describe("Ghidra provider", () => {
},
},
});
const windows = provider({ ...installationHost(), platform: "win32" });
const windows = provider(installationHost("win32"));
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
it("discovers the exact installation once without launching Ghidra", () => {
@@ -112,6 +116,8 @@ describe("Ghidra provider", () => {
probeJava: () => {
probeCount += 1;
return {
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
@@ -193,7 +199,7 @@ describe("Ghidra provider", () => {
describe("Ghidra raw-binary profile", () => {
it("admits raw 6502 on Windows and binds its 16-bit language without mutation authority", async () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
const target: BinaryTarget = {
path: "/tmp/6502.bin",
sha256: "a".repeat(64),
@@ -260,7 +266,7 @@ describe("Ghidra raw-binary profile", () => {
expect(shifted.value.profile?.digest).not.toBe(
resolved.value.profile.digest,
);
const windows = provider({ ...installationHost(), platform: "win32" });
const windows = provider(installationHost("win32"));
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
});
@@ -302,7 +308,7 @@ describe("Ghidra Mach-O slice support", () => {
describe("Ghidra platform support", () => {
it("commits the PE role and managed classification to profile identity", async () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
const application = peTarget("x86_64");
const library = {
...application,
@@ -343,7 +349,7 @@ describe("Ghidra platform support", () => {
});
it("keeps Windows annotation mutation unavailable independently of native controls", () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
expect(
ghidra
.capabilities()
@@ -363,7 +369,7 @@ describe("Ghidra platform support", () => {
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
});
onTestFinished(() => native.mockRestore());
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
const nativeApplication = peTarget("x86_64");
for (const architecture of ["x86", "x86_64"] as const)
@@ -454,7 +460,7 @@ describe("Ghidra Windows PE fixture admission", () => {
await promisify(execFile)(process.execPath, [
join(root, "scripts/create-ghidra-windows-fixture.mjs"),
]);
const ghidra = provider({ ...installationHost(), platform: "win32" });
const ghidra = provider(installationHost("win32"));
for (const architecture of ["x86", "x86_64"] as const) {
for (const dll of [false, true]) {
const path = join(
@@ -1818,6 +1824,8 @@ describe("compatible Ghidra builds", () => {
readText: () =>
"application.version=12.1.2\napplication.java.min=21\napplication.java.max=\n",
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "27",
major: 27,
home: "/usr/lib/jvm/java-27-openjdk",
+2
View File
@@ -43,6 +43,8 @@ const fixture = (version = "12.1.4") => {
`application.version=${version}\napplication.java.min=21\n`,
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
@@ -1,4 +1,4 @@
import { writeFile } from "node:fs/promises";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
@@ -68,6 +68,130 @@ cliTest(
},
);
const isWindows = process.platform === "win32";
const errorBody = (result: {
readonly content: readonly { readonly type: string }[];
}) => {
const content = result.content.find((item) => item.type === "text");
if (content?.type !== "text" || !("text" in content))
throw new Error("Missing error response");
return JSON.parse(String(content.text));
};
const createElectronApplication = async (root: string) => {
const application = join(root, "app");
await mkdir(application);
await writeFile(
join(application, "package.json"),
JSON.stringify({ name: "fixture", main: "main.js" }),
);
await writeFile(join(application, "main.js"), "");
return application;
};
cliTest.skipIf(isWindows)(
"rejects a non-executable Electron executable_path before launch through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-electron-not-executable-");
const application = await createElectronApplication(root);
const executable = join(root, "not-an-executable.txt");
await writeFile(executable, "plain text");
await chmod(executable, 0o644);
const request = {
executable_path: executable,
application_path: application,
actions: [],
};
const requestPath = join(root, "input.json");
await writeFile(requestPath, JSON.stringify(request));
const response = await cli.run({
arguments: ["capture-electron-scenario", requestPath, "--format", "json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode).toBe(1);
expect(response.json).toMatchObject({
code: "invalid_request",
details: {
issues: [{ path: ["executable_path"], reason: "invalid_value" }],
},
});
const { call } = await connectLocalToolsMcp();
const result = await call("capture_electron_scenario", request);
expect(result.isError).toBe(true);
expect(errorBody(result)).toMatchObject({
error: {
code: "invalid_request",
details: {
issues: [{ path: ["executable_path"], reason: "invalid_value" }],
},
},
});
const session = await call("binary_session", {});
expect(session.isError, JSON.stringify(session)).not.toBe(true);
},
);
cliTest.skipIf(isWindows)(
"returns a typed failure when an executable exits before Electron starts and keeps the MCP server and its Evidence",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-electron-early-exit-");
const application = await createElectronApplication(root);
const executable = join(root, "exits-immediately");
await writeFile(executable, "#!/bin/sh\nexit 0\n");
await chmod(executable, 0o755);
const request = {
executable_path: executable,
application_path: application,
actions: [],
};
const requestPath = join(root, "input.json");
await writeFile(requestPath, JSON.stringify(request));
const response = await cli.run({
arguments: ["capture-electron-scenario", requestPath, "--format", "json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode).toBe(1);
expect(response.json).toMatchObject({
code: "execution_failure",
details: { operation: "capture_electron_scenario" },
});
expect(response.stdout).toContain("Process failed to launch");
const { call } = await connectLocalToolsMcp();
const analysis = await call("analyze_javascript_application", {
input_path: application,
});
expect(analysis.isError, JSON.stringify(analysis)).not.toBe(true);
const retained = async () => {
const bundle = await call("get_evidence_bundle", {});
expect(bundle.isError, JSON.stringify(bundle)).not.toBe(true);
const structured = bundle.structuredContent as {
result: { records: { evidence_id: string }[] };
};
return structured.result.records.map((record) => record.evidence_id);
};
const before = await retained();
expect(before).toHaveLength(1);
const result = await call("capture_electron_scenario", request);
expect(result.isError).toBe(true);
expect(errorBody(result)).toMatchObject({
error: {
code: "execution_failure",
details: { operation: "capture_electron_scenario" },
},
});
const session = await call("binary_session", {});
expect(session.isError, JSON.stringify(session)).not.toBe(true);
expect(await retained()).toEqual(before);
},
180_000,
);
cliTest(
"rejects a file URL path field without resolving an invented local path",
async ({ cli }) => {
@@ -15,6 +15,19 @@ const run = async (arguments_: readonly string[]): Promise<unknown> => {
return JSON.parse(stdout);
};
const runFailure = async (arguments_: readonly string[]): Promise<unknown> => {
try {
await run(arguments_);
} catch (cause: unknown) {
if (typeof cause !== "object" || cause === null || !("stdout" in cause))
throw cause;
const stdout = cause.stdout;
if (typeof stdout !== "string") throw cause;
return JSON.parse(stdout);
}
throw new Error("Expected the CLI command to fail");
};
it.each(["analyze-javascript-application", "analyze"])(
"%s carries the integrity policy into shared JavaScript analysis",
async (command) => {
@@ -46,3 +59,48 @@ it("rejects the JavaScript integrity policy on binary-only inspection", async ()
stdout: expect.stringContaining("Unknown flag: --integrityPolicy"),
});
});
it.each(["fail", "record-and-continue"] as const)(
"rejects the explicitly selected %s integrity policy on a binary analyze target",
async (policy) => {
const failure = await runFailure([
"analyze",
"unused.bin",
"--integrity-policy",
policy,
]);
expect(failure).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["integrity_policy"],
reason: "invalid_value",
message:
"An integrity policy requires the static JavaScript ASAR/directory analysis lane; binary_overview accepts no input.",
},
],
},
});
},
);
it("still routes a binary analyze target without an integrity policy", async () => {
await expect(run(["analyze", "unused.bin"])).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining("target does not exist"),
});
});
it("advertises the optional integrity policy without an undefined union", async () => {
const schema = (await run(["analyze", "--schema"])) as {
options?: { properties?: { integrityPolicy?: unknown } };
};
const policy = schema.options?.properties?.integrityPolicy;
expect(policy).toMatchObject({
type: "string",
enum: ["fail", "record-and-continue"],
description: "Behavior when declared application integrity does not match",
});
expect(JSON.stringify(policy)).not.toContain("undefined");
});
@@ -130,7 +130,7 @@ printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache'
"${{ github.event.repository.default_branch }}",
);
expect(policy?.env?.LOCKFILES_HASH).toBe(
"${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}",
"${{ hashFiles('npm-shrinkwrap.json', '.npmrc') }}",
);
expect(policy?.run).toContain(
'cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"',
@@ -634,7 +634,7 @@ it.each([
{ code: true, package: true, evm: true, browser: true },
],
[
"package-lock.json",
"npm-shrinkwrap.json",
{ code: true, package: true, evm: true, browser: true },
],
[
@@ -23,7 +23,7 @@ const fixture = async (
const root = await createTestTempDirectory("rea-deps-");
await mkdir(join(root, "node_modules"));
await writeFile(
join(root, "package-lock.json"),
join(root, "npm-shrinkwrap.json"),
JSON.stringify({
packages: {
"": {
@@ -103,7 +103,7 @@ describe("dependency install freshness", () => {
expect(seeded.status, `${seeded.stdout}${seeded.stderr}`).toBe(0);
const rootLock = asRecord(
JSON.parse(await readFile(join(root, "package-lock.json"), "utf8")),
JSON.parse(await readFile(join(root, "npm-shrinkwrap.json"), "utf8")),
);
const lockPackages = asRecord(rootLock?.packages);
const rootPackage = asRecord(lockPackages?.[""]);
@@ -191,7 +191,7 @@ const createBuildCacheFixture = async (): Promise<string> => {
throw new Error("package is missing its cached build command");
const paths = [
".nvmrc",
"package-lock.json",
"npm-shrinkwrap.json",
"turbo.json",
"tsconfig.json",
"tsconfig.build.json",
@@ -55,6 +55,8 @@ const host: GhidraInstallationHost = {
readText: () => "application.version=12.1.3\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.12",
major: 21,
home: "/jdk-21",
@@ -1,5 +1,5 @@
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, expect, it } from "vitest";
import { z } from "zod";
@@ -19,6 +19,32 @@ const verifier = fileURLToPath(
new URL("../../../scripts/verify-release-checkpoint.mjs", import.meta.url),
);
const pluginManifestPaths = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginManifest = (version: string, launch = version) => ({
version,
mcpServers: {
rea: {
command: "node",
args: ["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs", launch],
},
},
});
const codexServerConfig = (version: string, launch = version) => ({
mcpServers: {
rea: {
command: "node",
args: ["./scripts/plugin-mcp-launcher.mjs", launch],
cwd: ".",
startup_timeout_sec: 30,
},
},
});
async function metadata(
directory: string,
version: string,
@@ -27,9 +53,17 @@ async function metadata(
) {
await mkdir(join(directory, "docs"), { recursive: true });
await mkdir(join(directory, "src"), { recursive: true });
for (const path of pluginManifestPaths)
await mkdir(join(directory, dirname(path)), { recursive: true });
const files = {
".claude-plugin/plugin.json": pluginManifest(version),
".codex-plugin/plugin.json": {
version,
mcpServers: "./.mcp.json",
},
".mcp.json": codexServerConfig(version),
"package.json": { name: "rea-agents", version },
"package-lock.json": { version, packages: { "": { version } } },
"npm-shrinkwrap.json": { version, packages: { "": { version } } },
".release-please-manifest.json": { ".": version },
"server.json": {
version,
@@ -534,7 +568,7 @@ it("validates candidate options before inspecting the working directory", async
});
it.each([
"package-lock.json",
"npm-shrinkwrap.json",
"server.json",
"src/generatedPackageMetadata.ts",
])("rejects inconsistent version metadata in %s", async (path) => {
@@ -545,7 +579,7 @@ it.each([
path.endsWith(".ts")
? 'export const METADATA = {\n version: "5.1.0",\n};\n'
: JSON.stringify(
path === "package-lock.json"
path === "npm-shrinkwrap.json"
? { version: "6.0.0", packages: { "": { version: "5.1.0" } } }
: path === "server.json"
? {
@@ -565,6 +599,51 @@ it.each([
});
});
it.each(
pluginManifestPaths.map((path) => ({
path,
stale: "version",
manifest:
path === ".codex-plugin/plugin.json"
? { version: "5.1.0", mcpServers: "./.mcp.json" }
: pluginManifest("5.1.0"),
failure: "declares 5.1.0; release branch requires 6.0.0",
})),
)("rejects a plugin $stale in $path", async ({ path, manifest, failure }) => {
const f = await fixture();
await candidate(f, "6.0.0");
await writeFile(join(f.directory, path), JSON.stringify(manifest));
await git(f.directory, ["add", "."]);
await git(f.directory, ["commit", "-m", "chore: stale plugin manifest"]);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(failure),
});
});
it.each([
{
path: ".claude-plugin/plugin.json",
manifest: pluginManifest("6.0.0", "5.1.0"),
},
{
path: ".mcp.json",
manifest: codexServerConfig("6.0.0", "5.1.0"),
},
])("reads the release pin from $path", async ({ path, manifest }) => {
const f = await fixture();
await candidate(f, "6.0.0");
await writeFile(join(f.directory, path), JSON.stringify(manifest));
await git(f.directory, ["add", "."]);
await git(f.directory, ["commit", "-m", "chore: stale plugin launch"]);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
`launches version 5.1.0; release branch requires 6.0.0`,
),
});
});
it("checks a publish merge using its pre-release first parent baseline", async () => {
const f = await fixture();
const sourceSha = await candidate(f, "6.0.0");
+182 -2
View File
@@ -6,7 +6,7 @@ import {
readFile,
writeFile,
} from "node:fs/promises";
import { delimiter, join } from "node:path";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, expect, it } from "vitest";
import { z } from "zod";
@@ -43,6 +43,59 @@ interface ReleaseNotesSeed {
let seed: ReleaseNotesSeed | undefined;
let seedWorkspaceRoot: string | undefined;
const pluginManifests = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginServerConfig = ".mcp.json";
const pluginMarketplaces = [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
] as const;
const pluginMarketplace = (version: string, packageName = "rea-agents") => ({
plugins: [
{ name: "rea", source: { source: "npm", package: packageName, version } },
],
});
const pluginServer = (version: string, command = "node") => ({
mcpServers: {
rea: {
command,
args: ["./scripts/plugin-mcp-launcher.mjs", version],
startup_timeout_sec: 30,
},
},
});
const pluginManifest = (
version: string,
{ skills = ["./skill"], command = "node" } = {},
) => ({
version,
skills,
mcpServers: {
rea: {
command,
args: ["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs", version],
},
},
});
const pluginManifestFor = (
path: (typeof pluginManifests)[number],
version: string,
options: { skills?: string[]; command?: string } = {},
) =>
path === ".codex-plugin/plugin.json"
? {
version,
skills: options.skills ?? ["./skill"],
mcpServers: "./.mcp.json",
}
: pluginManifest(version, options);
beforeAll(async () => {
const workspace = await createTestWorkspace("rea-release-notes-seed-");
seedWorkspaceRoot = workspace.root;
@@ -58,6 +111,24 @@ beforeAll(async () => {
join(directory, ".release-please-manifest.json"),
JSON.stringify({ ".": "6.3.0" }),
);
for (const path of pluginManifests) {
await mkdir(join(directory, dirname(path)), { recursive: true });
await writeFile(
join(directory, path),
JSON.stringify(pluginManifestFor(path, "6.3.0")),
);
}
await writeFile(
join(directory, pluginServerConfig),
JSON.stringify(pluginServer("6.3.0")),
);
for (const path of pluginMarketplaces) {
await mkdir(join(directory, dirname(path)), { recursive: true });
await writeFile(
join(directory, path),
JSON.stringify(pluginMarketplace("6.3.0")),
);
}
await git(directory, ["add", "."]);
await git(directory, ["commit", "-m", "chore: baseline"]);
const base = await git(directory, ["rev-parse", "HEAD"]);
@@ -368,9 +439,30 @@ it("rejects lost grouped credits, fabricated references, bots, and stale PR bodi
it("binds publication to complete recorded history and the reviewed base", async () => {
const f = await fixture();
await git(f.directory, ["add", "CHANGELOG.md"]);
for (const path of pluginManifests)
await writeFile(
join(f.directory, path),
JSON.stringify(pluginManifestFor(path, "6.4.0")),
);
await writeFile(
join(f.directory, pluginServerConfig),
JSON.stringify(pluginServer("6.4.0")),
);
for (const path of pluginMarketplaces)
await writeFile(
join(f.directory, path),
JSON.stringify(pluginMarketplace("6.4.0")),
);
await git(f.directory, [
"add",
"CHANGELOG.md",
...pluginManifests,
pluginServerConfig,
...pluginMarketplaces,
]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
const source = await git(f.directory, ["rev-parse", "HEAD"]);
// Release PRs bump the plugin manifest version and pinned launch only.
await f.invoke(["check", "--manifest", f.manifest, "--source", source]);
await writeFile(join(f.directory, "product.txt"), "later product change");
await git(f.directory, ["add", "product.txt"]);
@@ -391,6 +483,74 @@ it("binds publication to complete recorded history and the reviewed base", async
});
});
it.each(pluginManifests)(
"treats a release that changes the skill path in %s as a product change",
async (path) => {
const f = await fixture();
await writeFile(
join(f.directory, path),
JSON.stringify(
pluginManifestFor(path, "6.4.0", { skills: ["./another-skill"] }),
),
);
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
it("accepts Release Please lockfile refreshes without allowing product changes", async () => {
const f = await fixture();
await writeFile(
join(f.directory, "package-lock.json"),
'{"lockfileVersion":3}',
);
await writeFile(
join(f.directory, "npm-shrinkwrap.json"),
'{"lockfileVersion":3}',
);
await git(f.directory, [
"add",
"CHANGELOG.md",
"package-lock.json",
"npm-shrinkwrap.json",
]);
await git(f.directory, ["commit", "-m", "chore: release lockfiles"]);
await f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]);
});
it.each([
{
path: ".claude-plugin/plugin.json",
content: pluginManifest("6.4.0", { command: "other-launcher" }),
},
{
path: pluginServerConfig,
content: pluginServer("6.4.0", "other-launcher"),
},
])(
"keeps launcher behavior in $path outside version-only metadata",
async ({ path, content }) => {
const f = await fixture();
await writeFile(join(f.directory, path), JSON.stringify(content));
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
it("checks the live merged PR and exact Git snapshot before either publication route", async () => {
const f = await fixture();
await mkdir(join(f.directory, "docs/releases"), { recursive: true });
@@ -536,3 +696,23 @@ it("fails closed on partial GitHub errors and non-advancing pagination without s
});
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(pluginMarketplaces)(
"treats a marketplace package change in %s as product behavior",
async (path) => {
const f = await fixture();
await writeFile(
join(f.directory, path),
JSON.stringify(pluginMarketplace("6.4.0", "another-package")),
);
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
@@ -78,7 +78,7 @@ const writeTaskFixture = async (
}),
);
await writeFile(
join(root, "package-lock.json"),
join(root, "npm-shrinkwrap.json"),
JSON.stringify({
name: "rea-static-task-cache-fixture",
version: "1.0.0",
@@ -288,3 +288,38 @@ it("invalidates checker task hashes for Node, ignore, and VitePress config chang
`${task} must ignore vitest.config.ts`,
).toBe(initial.get(task));
});
it.each(["compile", "typecheck"])(
"invalidates %s for JavaScript owners, declarations, and the authoritative lock",
async (task) => {
const root = await createTestTempDirectory("rea-mjs-cache-inputs-");
const cache = await createTestTempDirectory("rea-mjs-cache-store-");
await mkdir(join(root, "src/application"), { recursive: true });
await writeTaskFixture(root, [task], {
[task]: 'node -e "process.exit(0)"',
});
const owner = join(root, "src/application/PackageRunnerCommand.mjs");
const declaration = join(
root,
"src/application/PackageRunnerCommand.d.mts",
);
await writeFile(owner, "export const command = 1;\n");
await writeFile(declaration, "export declare const command: number;\n");
await workflowGit(root, ["init", "--quiet"]);
await workflowGit(root, ["add", "."]);
const initial = taskHash(root, task, cache);
expect(taskHash(root, task, cache)).toBe(initial);
await writeFile(owner, "export const command = 2;\n");
expect(taskHash(root, task, cache)).not.toBe(initial);
await writeFile(owner, "export const command = 1;\n");
await writeFile(declaration, "export declare const command: string;\n");
expect(taskHash(root, task, cache)).not.toBe(initial);
await writeFile(declaration, "export declare const command: number;\n");
expect(taskHash(root, task, cache)).toBe(initial);
const lockPath = join(root, "npm-shrinkwrap.json");
const lock = JSON.parse(await readFile(lockPath, "utf8"));
lock.packages["node_modules/fixture"] = { version: "1.0.0" };
await writeFile(lockPath, JSON.stringify(lock));
expect(taskHash(root, task, cache)).not.toBe(initial);
},
);
@@ -28,7 +28,7 @@ test("generates locked inventory and rejects stale notice metadata or preserved
optional: true,
};
await writeFile(
resolve(root, "package-lock.json"),
resolve(root, "npm-shrinkwrap.json"),
JSON.stringify({
packages: {
"": { name: "fixture", version: "1.0.0" },
+2
View File
@@ -106,6 +106,8 @@ export const ghidraInstallationHost = (): GhidraInstallationHost => ({
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
+2
View File
@@ -102,6 +102,8 @@ const installationHost: GhidraInstallationHost = {
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: "amd64",
osName: "Linux",
version: "21.0.11",
major: 21,
home: "/fixture/jdk-21",
+3 -1
View File
@@ -94,7 +94,9 @@ const statusCapability = (
describe("server and catalog identity", () => {
it("derives package and SDK versions from canonical package metadata", async () => {
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageLock = JSON.parse(await readFile("package-lock.json", "utf8"));
const packageLock = JSON.parse(
await readFile("npm-shrinkwrap.json", "utf8"),
);
expect(PACKAGE_METADATA).toMatchObject({
name: packageJson.name,
version: packageJson.version,
@@ -0,0 +1,323 @@
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
processTest,
type TestProcesses,
} from "../../support/process/processFixture.js";
const script = `
import { PlaywrightElectronActiveProvider } from "./dist/browser/PlaywrightElectronActiveProvider.js";
import { electronActiveObservationInputSchema } from "./dist/domain/javascript/electronActiveObservation.js";
const [scenario, executable, root] = JSON.parse(process.argv[1]);
let observedRejections = 0;
if (scenario === "oneFailureWithManySuccesses" || scenario === "externalListenerSeesUnrelatedOnce")
process.on("unhandledRejection", () => { observedRejections += 1; });
const orphanStack = (message, file) =>
message + "\\n at waitForLine (/app/node_modules/playwright-core/lib/server/" + file + ":247:21)";
const playwrightError = (message, file = "electron/electron.js") => {
const error = new Error(message);
error.stack = "Error: " + orphanStack(message, file);
return error;
};
const rejectLater = (error, ms) => setTimeout(() => void Promise.reject(error), ms);
const fakeApplication = { process: () => ({ pid: undefined }), close: async () => undefined };
const launchFailure = (message) =>
new Error("electron.launch: " + message + "\\nCall log:\\n - <launching>");
const after = (ms, action) => new Promise((resolve, reject) => setTimeout(() => {
try { resolve(action()); } catch (error) { reject(error); }
}, ms));
const orphans = (message) => {
for (let index = 0; index < 3; index += 1) void Promise.reject(playwrightError(message));
};
let during = -1;
const launches = {
realLaunch: undefined,
realWithUnrelatedPlaywrightRejection: async (options) => {
void Promise.reject(playwrightError("unrelated playwright failure", "page.js"));
return (await import("playwright-core"))._electron.launch(options);
},
failureWithOrphans: async () => {
during = process.listenerCount("unhandledRejection");
orphans("Process failed to launch!");
await after(10, () => { throw launchFailure("Process failed to launch!"); });
},
failureInOneTick: async () => {
orphans("Process failed to launch!");
throw launchFailure("Process failed to launch!");
},
failureWithUnrelatedPlaywrightRejection: async () => {
orphans("Process failed to launch!");
void Promise.reject(playwrightError("Target page, context or browser has been closed", "page.js"));
await after(10, () => { throw launchFailure("Process failed to launch!"); });
},
externalListenerSeesUnrelatedOnce: async () => {
void Promise.reject(playwrightError("unrelated playwright failure", "page.js"));
await after(10, () => { throw launchFailure("Process failed to launch!"); });
},
pendingWithUnrelatedRejection: () => {
setInterval(() => {}, 1000);
rejectLater(new Error("unrelated application failure"), 20);
return new Promise(() => {});
},
success: async () => {
during = process.listenerCount("unhandledRejection");
return fakeApplication;
},
successWithPlaywrightRejection: async () => {
void Promise.reject(playwrightError("stray playwright failure", "page.js"));
await after(10, () => fakeApplication);
return fakeApplication;
},
};
const input = (executablePath) => ({
...electronActiveObservationInputSchema.parse({
executable_path: executablePath,
application_path: root,
}),
application_root: root,
});
const capture = (launch, executablePath = executable) =>
new PlaywrightElectronActiveProvider({}, launch).capture(input(executablePath));
const before = process.listenerCount("unhandledRejection");
let results;
if (scenario === "concurrentFailures" || scenario === "failureThenSuccess" || scenario === "successThenFailure" || scenario === "oneFailureWithManySuccesses") {
const failing = (message, orphanAt, settleAt) => async () => {
during = Math.max(during, process.listenerCount("unhandledRejection"));
await after(orphanAt, () => orphans(message));
await after(settleAt - orphanAt, () => { throw launchFailure(message); });
};
const succeeding = (settleAt) => () => {
during = Math.max(during, process.listenerCount("unhandledRejection"));
return after(settleAt, () => fakeApplication);
};
const pair = {
concurrentFailures: [failing("alpha failed", 10, 30), failing("beta failed", 20, 50)],
failureThenSuccess: [failing("alpha failed", 10, 20), succeeding(40)],
successThenFailure: [failing("alpha failed", 20, 40), succeeding(30)],
oneFailureWithManySuccesses: [failing("alpha failed", 10, 80), ...[20, 30, 40, 50].map(succeeding)],
}[scenario];
results = await Promise.all(pair.map((launch) => capture(launch)));
} else {
results = [await capture(launches[scenario] ?? undefined)];
}
await new Promise((resolve) => setTimeout(resolve, 50));
console.log(JSON.stringify({
before,
during,
after: process.listenerCount("unhandledRejection"),
observedRejections,
reasons: results.map((result) => result.ok ? null : result.error.reason ?? null),
ok: results.map((result) => result.ok),
causeReasons: results.map((result) => result.ok ? null : result.error.cause?.reason ?? null),
cleanupResources: results.map((result) => result.ok ? null : result.error.cleanupResources ?? null),
causes: results.map((result) => result.ok ? null : String(result.error.cause?.message).split("\\n")[0]),
}));
`;
// A cold macOS Swift toolchain may spend up to 120 seconds building the
// ownership reader before any child launch reaches its assertion.
vi.setConfig({ testTimeout: 180_000 });
type Outcome = {
readonly before: number;
readonly during: number;
readonly after: number;
readonly ok: readonly boolean[];
readonly causes: readonly (string | null)[];
readonly observedRejections: number;
readonly reasons: readonly (string | null)[];
readonly causeReasons: readonly (string | null)[];
readonly cleanupResources: readonly (readonly string[] | null)[];
};
const run = async (
processes: TestProcesses,
scenario: string,
executable = "/not/launched",
root = "/",
) =>
processes.run(
process.execPath,
[
"--input-type=module",
"--eval",
script,
JSON.stringify([scenario, executable, root]),
],
{ timeoutMs: 150_000 },
);
const outcome = (result: { readonly stdout: string }): Outcome =>
JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "") as Outcome;
const earlyExit = async () => {
const root = await createTestTempDirectory("rea-launch-rejections-");
const executable = join(root, "exits-immediately");
await mkdir(join(root, "app"));
await writeFile(executable, "#!/bin/sh\nexit 0\n");
await chmod(executable, 0o755);
return { executable, root: join(root, "app") };
};
processTest(
"absorbs the orphaned rejections of a failed launch and removes its listener",
async ({ processes }) => {
const result = await run(processes, "failureWithOrphans");
expect(result.exitCode, result.stderr).toBe(0);
expect(outcome(result)).toMatchObject({
during: outcome(result).before + 1,
after: outcome(result).before,
ok: [false],
causes: ["electron.launch: Process failed to launch!"],
});
},
);
processTest(
"absorbs orphans raised in the same tick as the launch failure",
async ({ processes }) => {
const result = await run(processes, "failureInOneTick");
expect(result.exitCode, result.stderr).toBe(0);
expect(outcome(result)).toMatchObject({ ok: [false] });
},
);
processTest.skipIf(process.platform === "win32")(
"absorbs the orphans of a real Playwright launch that exits at once",
async ({ processes }) => {
const { executable, root } = await earlyExit();
const result = await run(processes, "realLaunch", executable, root);
expect(result.exitCode, result.stderr).toBe(0);
expect(outcome(result)).toMatchObject({
ok: [false],
causes: ["electron.launch: Process failed to launch!"],
});
expect(outcome(result).after).toBe(outcome(result).before);
},
);
processTest.skipIf(process.platform === "win32")(
"still fails on an unrelated Playwright rejection during a real failing launch",
async ({ processes }) => {
const { executable, root } = await earlyExit();
const result = await run(
processes,
"realWithUnrelatedPlaywrightRejection",
executable,
root,
);
expect(result.exitCode, result.stderr).toBe(1);
expect(result.stderr).toContain("unrelated playwright failure");
expect(result.stdout).toBe("");
},
);
processTest(
"does not replay an unrelated orphan to a listener that already received it",
async ({ processes }) => {
const result = await run(processes, "externalListenerSeesUnrelatedOnce");
expect(result.exitCode, result.stderr).toBe(0);
expect(outcome(result)).toMatchObject({
observedRejections: 1,
ok: [false],
causes: ["electron.launch: Process failed to launch!"],
});
},
);
processTest(
"still fails on an unrelated Playwright rejection beside a failing launch's own orphans",
async ({ processes }) => {
const result = await run(
processes,
"failureWithUnrelatedPlaywrightRejection",
);
expect(result.exitCode, result.stderr).toBe(1);
expect(result.stderr).toContain(
"Target page, context or browser has been closed",
);
expect(result.stdout).toBe("");
},
);
processTest(
"still fails at once on an unrelated rejection while a launch is pending",
async ({ processes }) => {
const result = await run(processes, "pendingWithUnrelatedRejection");
expect(result.exitCode, result.stderr).toBe(1);
expect(result.stderr).toContain("unrelated application failure");
expect(result.stdout).toBe("");
},
);
processTest(
"removes its listener after a successful launch",
async ({ processes }) => {
const result = await run(processes, "success");
expect(result.exitCode, result.stderr).toBe(0);
expect(outcome(result)).toMatchObject({
during: outcome(result).before + 1,
after: outcome(result).before,
});
},
);
processTest(
"re-raises a Playwright rejection when the launch succeeds",
async ({ processes }) => {
const result = await run(processes, "successWithPlaywrightRejection");
expect(result.exitCode, result.stderr).toBe(1);
expect(result.stderr).toContain("stray playwright failure");
expect(result.stdout).toBe("");
},
);
processTest.for([
"concurrentFailures",
"failureThenSuccess",
"successThenFailure",
])(
"keeps overlapping launches from re-raising each other's orphans (%s)",
async (scenario, { processes }) => {
const result = await run(processes, scenario);
expect(result.exitCode, result.stderr).toBe(0);
expect(outcome(result).after).toBe(outcome(result).before);
},
);
processTest(
"holds each orphan once across many overlapping successful launches",
async ({ processes }) => {
const result = await run(processes, "oneFailureWithManySuccesses");
expect(result.exitCode, result.stderr).toBe(0);
const observed = outcome(result);
expect(observed.during).toBe(observed.before + 1);
expect(observed.after).toBe(observed.before);
expect(observed.observedRejections).toBe(3);
expect(observed.ok).toEqual([false, false, false, false, false]);
expect(observed.causes[0]).toBe("electron.launch: alpha failed");
expect(observed.reasons).toEqual([
null,
"cleanup_failed",
"cleanup_failed",
"cleanup_failed",
"cleanup_failed",
]);
expect(observed.causeReasons).toEqual([
null,
...Array.from({ length: 4 }, () => "process_ownership_unavailable"),
]);
expect(observed.cleanupResources).toEqual([
[],
...Array.from({ length: 4 }, () => ["owned_process_group"]),
]);
},
);
@@ -0,0 +1,237 @@
import { spawn } from "node:child_process";
import { copyFile, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it, onTestFinished } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const launcher = fileURLToPath(
new URL("../../../scripts/plugin-mcp-launcher.mjs", import.meta.url),
);
const start = (path: string, version = "6.4.0-rc.1", entry = launcher) => {
const child = spawn(process.execPath, [entry, version], {
env: {
...process.env,
PATH: `${path}${delimiter}${process.env.PATH ?? ""}`,
},
stdio: "pipe",
});
let stdout = "";
let stderr = "";
child.stdout.on(
"data",
(bytes: Buffer) => (stdout += bytes.toString("utf8")),
);
child.stderr.on(
"data",
(bytes: Buffer) => (stderr += bytes.toString("utf8")),
);
const closed = new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) =>
resolve({ code, signal, stdout, stderr }),
);
});
onTestFinished(async () => {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
await closed;
});
return { child, closed };
};
const fakeRunner = async (body: string) => {
const root = await createTestTempDirectory("rea-plugin-launcher-");
const bin = join(root, "plugin with spaces", "bin");
await mkdir(bin, { recursive: true });
await writeFile(join(bin, "npx"), `#!${process.execPath}\n${body}\n`, {
mode: 0o755,
});
return bin;
};
it.skipIf(process.platform === "win32")(
"passes the exact pin and protocol streams through the source-only plugin launcher",
async () => {
const bin = await fakeRunner(`
process.stderr.write(JSON.stringify(process.argv.slice(2)));
process.stdin.pipe(process.stdout);
process.stdin.on("end", () => { process.exitCode = 7; });
`);
const { child, closed } = start(bin, "6.4.0-rc.1");
child.stdin.end('{"jsonrpc":"2.0","id":1}\n');
expect(await closed).toEqual({
code: 7,
signal: null,
stdout: '{"jsonrpc":"2.0","id":1}\n',
stderr: '["-y","rea-agents@6.4.0-rc.1","mcp"]',
});
},
);
it.skipIf(process.platform === "win32")(
"forwards termination to the live package-runner child before exiting",
async () => {
const bin = await fakeRunner(`
process.on("SIGTERM", () => { process.stderr.write("stopped"); process.exit(0); });
process.stdout.write("ready");
process.stdin.resume();
`);
const { child, closed } = start(bin);
await new Promise<void>((resolve, reject) => {
child.stdout.once("data", () => resolve());
child.once("error", reject);
});
child.kill("SIGTERM");
expect(await closed).toMatchObject({ code: 0, stderr: "stopped" });
},
);
it.skipIf(process.platform === "win32")(
"stops a package runner and its long-lived server with stdin still open",
async () => {
const root = await createTestTempDirectory("rea-plugin-process-tree-");
const runnerMarker = join(root, "runner-stopped");
const serverMarker = join(root, "server-stopped");
const bin = await fakeRunner(`
const { appendFileSync } = require("node:fs");
const { spawn } = require("node:child_process");
const server = spawn(process.execPath, ["-e", ${JSON.stringify(`
const { appendFileSync } = require("node:fs");
process.on("SIGTERM", () => {
appendFileSync(${JSON.stringify(serverMarker)}, "stopped\\n");
process.exit(0);
});
process.stdin.resume();
process.stdout.write("server-ready\\n");
`)}], { stdio: "inherit" });
let stopping = false;
process.on("SIGTERM", () => {
stopping = true;
appendFileSync(${JSON.stringify(runnerMarker)}, "stopped\\n");
});
process.stdin.resume();
server.once("close", () => { if (stopping) process.exit(0); });
`);
const { child, closed } = start(bin);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("package server did not become ready")),
5_000,
);
child.stdout.on("data", (bytes: Buffer) => {
if (bytes.toString("utf8").includes("server-ready\n")) {
clearTimeout(timeout);
resolve();
}
});
child.once("error", reject);
});
// Keep the protocol pipe open: shutdown must reach the package runner and
// its server process independently of EOF on stdin.
child.kill("SIGTERM");
expect(await closed).toMatchObject({ code: 0, signal: null });
await expect(readFile(runnerMarker, "utf8")).resolves.toBe("stopped\n");
await expect(readFile(serverMarker, "utf8")).resolves.toBe("stopped\n");
},
);
it.skipIf(process.platform === "win32")(
"isolates local package resolution and removes the owned runner directory",
async () => {
const bin = await fakeRunner("process.stdout.write(process.cwd());");
const { child, closed } = start(bin);
child.stdin.end();
const result = await closed;
expect(result.code).toBe(0);
expect(result.stdout).toContain("rea-plugin-launcher-");
expect(result.stdout).not.toBe(process.cwd());
await expect(lstat(result.stdout)).rejects.toMatchObject({
code: "ENOENT",
});
},
);
it("rejects a shell expression instead of treating it as the pinned package", async () => {
const root = await createTestTempDirectory("rea-plugin-invalid-pin-");
const { child, closed } = start(root, "6.4.0&echo wrong");
child.stdin.end();
expect(await closed).toMatchObject({
code: 1,
stdout: "",
stderr: "REA plugin requires one exact rea-agents@VERSION pin.\n",
});
});
const pluginFixture = async () => {
const root = await createTestTempDirectory("rea-plugin-release-origin-");
await mkdir(join(root, "scripts"));
await mkdir(join(root, "dist/mcp"), { recursive: true });
const entry = join(root, "scripts/plugin-mcp-launcher.mjs");
await copyFile(launcher, entry);
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "rea-agents",
version: "6.4.0",
type: "module",
}),
);
await writeFile(join(root, "dist/mcp/main.js"), "");
await writeFile(
join(root, "scripts/rea.mjs"),
'process.stdout.write("bundled-runtime");',
);
return { root, entry };
};
it
.skipIf(process.platform === "win32")
.each(["directory", "worktree file", "exported source"])(
"uses the registry pin even when a %s checkout has same-version dist",
async (kind) => {
const { root, entry } = await pluginFixture();
await mkdir(join(root, "src/application"), { recursive: true });
await copyFile(
fileURLToPath(
new URL(
"../../../src/application/PackageRunnerCommand.mjs",
import.meta.url,
),
),
join(root, "src/application/PackageRunnerCommand.mjs"),
);
if (kind === "directory") await mkdir(join(root, ".git"));
else if (kind === "worktree file")
await writeFile(join(root, ".git"), "gitdir: /fixture/worktree");
const bin = await fakeRunner(
"process.stdout.write(JSON.stringify(process.argv.slice(2)));",
);
const { child, closed } = start(bin, "6.4.0", entry);
child.stdin.end();
expect(await closed).toMatchObject({
code: 0,
stdout: '["-y","rea-agents@6.4.0","mcp"]',
stderr: "",
});
},
);
it("uses the matching installed runtime without another package-runner install", async () => {
const { root, entry } = await pluginFixture();
const { child, closed } = start(root, "6.4.0", entry);
child.stdin.end();
expect(await closed).toMatchObject({
code: 0,
stdout: "bundled-runtime",
stderr: "",
});
});
@@ -50,9 +50,16 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
process.stderr.write("failed " + probe + "\\n");
process.exitCode = 3;
} else if (probe === "-envvars") {
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : "GHIDRA_MAXMEM=1G\\n");
process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : mode.startsWith("env-injection:") ? mode.slice("env-injection:".length) + "\\n" : "GHIDRA_MAXMEM=1G\\n");
} else {
process.stdout.write(mode === "vm-malformed" ? "invalid JVM argument\\n" : "-Dfile.encoding=UTF8\\n");
const vmArguments = mode === "vm-malformed"
? "invalid JVM argument\\n"
: mode === "os-arch-override"
? "-Dos.arch=x86_64\\n"
: mode === "os-name-override"
? "-Dos.name=Windows\\n"
: "-Dfile.encoding=UTF8\\n";
process.stdout.write(vmArguments);
}
`,
{ mode: 0o755 },
@@ -215,6 +222,57 @@ describe.skipIf(process.platform === "win32")(
},
);
it.each(["os-arch-override", "os-name-override"])(
"rejects LaunchSupport %s before starting analysis",
async (mode) => {
const { session, launcher, analysisArguments } = await fixture(mode);
const launched = await launcher.launch(session);
expect(launched.ok).toBe(false);
expect(analysisArguments).toEqual([]);
if (launched.ok)
throw new Error("Expected a JVM platform override failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining(
"may not override os.arch or os.name",
),
});
},
);
it.each([
["JAVA_TOOL_OPTIONS", "-Dos.arch=x86_64"],
["JAVA_TOOL_OPTIONS", '"-Dos.arch=x86_64"'],
["_JAVA_OPTIONS", "'-Dos.name=Windows'"],
["JDK_JAVA_OPTIONS", '-D"os.name"=Windows'],
["JDK_JAVA_OPTIONS", "-D'os.arch'=x86_64"],
])(
"rejects helper-environment platform injection from %s=%s before analysis",
async (key, value) => {
const { session, launcher, analysisArguments } = await fixture(
`env-injection:${key}=${value}`,
);
const launched = await launcher.launch(session);
expect(launched.ok).toBe(false);
expect(analysisArguments).toEqual([]);
if (launched.ok)
throw new Error("Expected a JVM platform override failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining(
"may not override os.arch or os.name",
),
});
},
);
it("admits a quoted non-platform option without mistaking its value for a platform argument", async () => {
const { session, launcher, analysisArguments } = await fixture(
'env-injection:JAVA_TOOL_OPTIONS=-Ddescription="text -Dos.arch=x86_64"',
);
const launched = await launcher.launch(session);
if (!launched.ok) throw launched.error;
expect(analysisArguments).toHaveLength(1);
});
it("bounds retained UTF-8 warnings and reports original byte counts", async () => {
const { session, launcher } = await fixture("large");
const launched = await launcher.launch(session);
@@ -290,6 +348,13 @@ describe.skipIf(process.platform === "win32")(
"application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
osArch: process.arch === "arm64" ? "aarch64" : "amd64",
osName:
process.platform === "darwin"
? "Mac OS X"
: process.platform === "win32"
? "Windows 11"
: "Linux",
version: "21.0.12",
major: 21,
home: "/fixture/jdk",
@@ -524,6 +524,13 @@ describe("Ghidra Windows launcher runtime", () => {
),
javaHome: "C:\\Java\\jdk-21",
bridgeScriptPath: join(parent, "ReaGhidraBridge.java"),
// The preflight validates the inspected JVM's LaunchSupport output;
// these fixtures assert batch-spawn behavior without a working JDK.
javaLaunch: async () => ({
command: "C:\\Java\\jdk-21\\bin\\java.exe",
arguments: [],
environment: {},
}),
spawnProcess,
});
const launched = await launcher.launch({
@@ -569,6 +576,13 @@ describe("Ghidra Windows launcher runtime", () => {
platform: "win32",
javaHome,
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
// The preflight validates the inspected JVM's LaunchSupport output;
// this fixture captures the batch launcher without a working JDK.
javaLaunch: async () => ({
command: `${javaHome}\\bin\\java.exe`,
arguments: [],
environment: {},
}),
});
const launched = await launcher.launch({
runtimeRoot,
+253 -11
View File
@@ -1,13 +1,28 @@
import { readFile } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import { GenericJson } from "release-please/build/src/updaters/generic-json.js";
import { Version } from "release-please/build/src/version.js";
import { describe, expect, it } from "vitest";
import { SUPPORTED_NODE_VERSION_RANGE } from "../../src/domain/runtimeVersion.js";
import { MCP_STARTUP_POLICY } from "../../src/mcpStartupPolicy.js";
const readJson = async (path: string): Promise<unknown> =>
JSON.parse(await readFile(path, "utf8")) as unknown;
const versionedDocumentation = ["docs/installation.md"] as const;
// Each file lists a fragment of every example Release Please must keep
// current, so a lost marker is caught as well as a stale version.
const versionedDocumentation = [
{
path: "docs/installation.md",
examples: ["doctor --provider ghidra --json", '"command": "npx"'],
},
] as const;
const pluginManifests = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginServerConfig = ".mcp.json";
describe("release configuration", () => {
it("keeps doctor and setup runtime support aligned with package engines", async () => {
@@ -24,7 +39,7 @@ describe("release configuration", () => {
packages: {
".": {
"extra-files": [
...versionedDocumentation.map((path) => ({
...versionedDocumentation.map(({ path }) => ({
type: "generic",
path,
})),
@@ -42,6 +57,35 @@ describe("release configuration", () => {
path: "server.json",
jsonpath: "$.packages[0].version",
},
...pluginManifests.flatMap((path) => [
{
type: "json",
path,
jsonpath: "$.version",
},
...(path.startsWith(".claude")
? [
{
type: "json",
path,
jsonpath: "$.mcpServers.rea.args[1]",
},
]
: []),
]),
{
type: "json",
path: pluginServerConfig,
jsonpath: "$.mcpServers.rea.args[1]",
},
...[
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
].map((path) => ({
type: "json",
path,
jsonpath: "$.plugins[0].source.version",
})),
],
},
},
@@ -49,18 +93,27 @@ describe("release configuration", () => {
});
it.each(versionedDocumentation)(
"marks the versioned package example in %s",
async (path) => {
"marks every versioned package example in $path",
async ({ path, examples }) => {
const content = await readFile(path, "utf8");
const packageJson = (await readJson("package.json")) as {
version: string;
};
const versionBlock =
/<!-- x-release-please-start-version -->[\s\S]*?<!-- x-release-please-end -->/u.exec(
content,
)?.[0];
const versionBlocks = [
...content.matchAll(
/<!-- x-release-please-start-version -->[\s\S]*?<!-- x-release-please-end -->/gu,
),
].map(([block]) => block);
expect(versionBlock).toContain(`rea-agents@${packageJson.version}`);
for (const example of examples)
expect(
versionBlocks.filter((block) => block.includes(example)),
example,
).toHaveLength(1);
for (const block of versionBlocks)
expect(block.match(/rea-agents@[^\s"`]+/gu)).toEqual([
`rea-agents@${packageJson.version}`,
]);
},
);
@@ -71,10 +124,13 @@ describe("release configuration", () => {
expect(await readJson(".release-please-manifest.json")).toEqual({
".": packageJson.version,
});
expect(await readJson("package-lock.json")).toMatchObject({
expect(await readJson("npm-shrinkwrap.json")).toMatchObject({
version: packageJson.version,
packages: { "": { version: packageJson.version } },
});
await expect(access("package-lock.json")).rejects.toMatchObject({
code: "ENOENT",
});
const changelog = await readFile("CHANGELOG.md", "utf8");
expect(/^## \[([^\]]+)\]/mu.exec(changelog)?.[1]).toBe(packageJson.version);
});
@@ -120,3 +176,189 @@ describe("release configuration", () => {
});
});
});
describe("plugin manifests", () => {
it("distributes both plugins with generated canonical skills from the exact npm release", async () => {
const pkg = (await readJson("package.json")) as {
version: string;
files: string[];
};
expect(pkg.files).toEqual(
expect.arrayContaining([
...pluginManifests,
pluginServerConfig,
"skills",
]),
);
for (const path of pluginManifests)
expect(await readJson(path)).toMatchObject({ skills: "./skills" });
for (const path of [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
])
expect(await readJson(path)).toMatchObject({
plugins: [
{
source: {
source: "npm",
package: "rea-agents",
version: pkg.version,
},
},
],
});
});
it.each(pluginManifests)(
"pins the plugin manifest %s and Codex's server config path",
async (path) => {
const packageJson = (await readJson("package.json")) as {
name: string;
version: string;
};
expect(await readJson(path)).toMatchObject({
version: packageJson.version,
...(path.startsWith(".codex")
? { mcpServers: `./${pluginServerConfig}` }
: {}),
});
},
);
});
describe("plugin release pins", () => {
it("advances every plugin pin through Release Please JSON extra-files", async () => {
const packageJson = (await readJson("package.json")) as {
version: string;
};
const currentVersion = Version.parse(packageJson.version);
const nextVersion = `${currentVersion.major}.${currentVersion.minor + 1}.0`;
const version = Version.parse(nextVersion);
const config = (await readJson("release-please-config.json")) as {
packages: {
".": {
"extra-files": Array<{
type: string;
path: string;
jsonpath?: string;
}>;
};
};
};
const updated = new Map<string, string>();
for (const extraFile of config.packages["."]["extra-files"]) {
if (extraFile.type !== "json" || extraFile.jsonpath === undefined)
continue;
const content =
updated.get(extraFile.path) ?? (await readFile(extraFile.path, "utf8"));
updated.set(
extraFile.path,
new GenericJson(extraFile.jsonpath, version).updateContent(content),
);
}
const updatedJson = new Map<string, unknown>(
[...updated].map(
([path, content]) => [path, JSON.parse(content) as unknown] as const,
),
);
expect(updatedJson.get(".claude-plugin/plugin.json")).toMatchObject({
version: nextVersion,
mcpServers: {
rea: {
args: [
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
nextVersion,
],
},
},
});
expect(updatedJson.get(".codex-plugin/plugin.json")).toMatchObject({
version: nextVersion,
});
expect(updatedJson.get(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
args: ["./scripts/plugin-mcp-launcher.mjs", nextVersion],
startup_timeout_sec: 30,
},
},
});
for (const path of [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
])
expect(updatedJson.get(path)).toMatchObject({
plugins: [
{
source: {
source: "npm",
package: "rea-agents",
version: nextVersion,
},
},
],
});
});
it("pins the plugin server launch from the shared MCP declaration", async () => {
const packageJson = (await readJson("package.json")) as {
version: string;
};
expect(await readJson(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
command: "node",
args: ["./scripts/plugin-mcp-launcher.mjs", packageJson.version],
cwd: ".",
},
},
});
});
it.each(pluginManifests)(
"stores %s in the form Release Please rewrites it to",
async (path) => {
// Release Please reserializes JSON extra-files, so any other layout
// would change formatting in every release PR.
const content = await readFile(path, "utf8");
expect(content).toBe(`${JSON.stringify(JSON.parse(content), null, 2)}
`);
},
);
it("declares the interface fields Codex plugin validation requires", async () => {
const manifest = (await readJson(".codex-plugin/plugin.json")) as {
interface: Record<string, unknown>;
};
for (const field of [
"displayName",
"shortDescription",
"longDescription",
"developerName",
"category",
])
expect(manifest.interface[field]).toEqual(expect.stringMatching(/\S/u));
expect(manifest.interface.capabilities).toEqual(
expect.arrayContaining([expect.stringMatching(/\S/u)]),
);
expect(manifest.interface.defaultPrompt).toEqual(
expect.arrayContaining([expect.stringMatching(/\S/u)]),
);
});
it("carries the owned Codex startup timeout in the companion server entry", async () => {
expect(await readJson(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
},
},
});
});
});
+2 -1
View File
@@ -1,10 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true,
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.mjs"],
"exclude": [
"src/**/*.fixture.ts",
"src/**/*.test.ts",
+19 -19
View File
@@ -9,12 +9,12 @@
"dependsOn": ["metadata:check"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/check-dependency-install.mjs",
"scripts/clean-build-output.mjs",
"scripts/bundle-runtime.mjs",
"scripts/generate-package-metadata.mjs",
"src/**/*.ts",
"src/**/*.{ts,mjs,mts}",
"!src/**/*.test.ts",
"!src/**/*.fixture.ts",
"tsconfig.json",
@@ -25,8 +25,8 @@
"typecheck": {
"inputs": [
"package.json",
"package-lock.json",
"src/**/*.ts",
"npm-shrinkwrap.json",
"src/**/*.{ts,mjs,mts}",
"tests/**/*.ts",
"tsconfig.json",
"tsconfig.build.json",
@@ -39,7 +39,7 @@
"metadata:check": {
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-package-metadata.mjs",
"scripts/lib/generated-file.mjs",
".agents/skills/reverse-engineer-anything/SKILL.md",
@@ -52,7 +52,7 @@
".gitignore",
".oxlintrc.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
@@ -73,7 +73,7 @@
".gitignore",
".oxlintrc.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
@@ -94,9 +94,9 @@
".gitignore",
"knip.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.ts",
"src/**/*.{ts,mjs,mts}",
"tests/**/*.ts",
"tsconfig.json",
"tsconfig.build.json",
@@ -109,7 +109,7 @@
".gitignore",
".jscpd.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/ci/static-check-report.mjs",
"src/**",
"tests/**"
@@ -120,7 +120,7 @@
"inputs": [
"bridge/**/*.{java,py}",
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.ts",
"src/**/*.{ts,mjs,mts}",
"tests/**/*.ts"
]
},
@@ -141,7 +141,7 @@
],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-*.mjs",
"scripts/check-doc-facts.mjs",
"scripts/lib/*.mjs",
@@ -172,7 +172,7 @@
"dependsOn": ["compile"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-mcp-tool-catalog.mjs",
"scripts/lib/generated-file.mjs"
],
@@ -182,7 +182,7 @@
"dependsOn": ["compile"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-skill-metadata.mjs",
"scripts/lib/generated-file.mjs",
".agents/skills/reverse-engineer-anything/**"
@@ -193,7 +193,7 @@
"dependsOn": ["compile"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-product-catalog.mjs",
"scripts/lib/**/*.mjs",
".oxfmtrc.json"
@@ -204,7 +204,7 @@
"dependsOn": ["artifacts:skills"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-completion-ledger.mjs",
"scripts/lib/generated-file.mjs",
"scripts/verify/managed/**",
@@ -219,7 +219,7 @@
"dependsOn": ["compile"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-error-schema.mjs",
"scripts/lib/generated-file.mjs",
"scripts/lib/format-generated-file.mjs",
@@ -231,7 +231,7 @@
"dependsOn": ["artifacts:mcp-catalog"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-mcp-tool-catalog.mjs",
"scripts/lib/generated-file.mjs",
{
@@ -244,7 +244,7 @@
"dependsOn": ["artifacts:managed-evidence"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-completion-ledger.mjs",
"scripts/lib/generated-file.mjs",
"scripts/verify/managed/**",