Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff5af6a7cf | ||
|
|
20e4f83097 | ||
|
|
97aff28201 | ||
|
|
641a8395ee | ||
|
|
3f17e75b4b | ||
|
|
7cb6c30039 | ||
|
|
9544c80eba | ||
|
|
f45cb4b92a | ||
|
|
bc6eb789ce | ||
|
|
9de80b4deb | ||
|
|
a85bfed8dc | ||
|
|
b7dabdbc4f | ||
|
|
9d22eea7d2 | ||
|
|
45bae55787 | ||
|
|
987cb3b96b | ||
|
|
961f410094 | ||
|
|
802a1b0b75 | ||
|
|
42ce2c75ab | ||
|
|
3206274e0b | ||
|
|
3457e65be5 | ||
|
|
11404c05f4 | ||
|
|
a74d0502b9 | ||
|
|
351ad8282a | ||
|
|
07680f66e2 | ||
|
|
eaa9fe5cd5 | ||
|
|
9d658d767a | ||
|
|
22de1ff010 | ||
|
|
2fb79a99c8 | ||
|
|
2da5e86289 | ||
|
|
6ebae721f6 | ||
|
|
677997bd59 | ||
|
|
b22d5245b1 | ||
|
|
5806c94008 | ||
|
|
c224ce482a | ||
|
|
102661ca6f | ||
|
|
2f134aaa7e | ||
|
|
e8e265a0bc | ||
|
|
d3359ebef0 | ||
|
|
2aea370e1c | ||
|
|
a34e48b35b | ||
|
|
d206a19c7d | ||
|
|
15a5e08338 | ||
|
|
4c945f570d | ||
|
|
6649e8e7ba |
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 '
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
- "scripts/lib/mcp-verifier-results.mjs"
|
||||
- "scripts/rea.mjs"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "npm-shrinkwrap.json"
|
||||
- ".nvmrc"
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "node",
|
||||
"args": ["./scripts/plugin-mcp-launcher.mjs", "6.4.0"],
|
||||
"cwd": ".",
|
||||
"startup_timeout_sec": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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/**"
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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')
|
||||
|
||||
+1007
-117
File diff suppressed because it is too large
Load Diff
Generated
+2333
-197
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}.`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(", ")}`,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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[];
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,5 +12,7 @@ export const ghidraProfileInstallationHost: GhidraInstallationHost = {
|
||||
home: "/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
osArch: "amd64",
|
||||
osName: "Linux",
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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/**",
|
||||
|
||||
Reference in New Issue
Block a user