Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:53:19 +02:00
48 changed files with 4960 additions and 403 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "rea",
"interface": {
"displayName": "REA"
},
"plugins": [
{
"name": "rea",
"source": {
"source": "npm",
"package": "rea-agents",
"version": "6.4.0"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
@@ -17,6 +17,9 @@ readiness or provider commands.
Installing this skill supplies instructions; it does not register the REA MCP
server, install analysis engines, or add tools to an already-running agent.
The exception is a Claude Code or Codex plugin install: the plugin bundles
this skill and registers its own `rea` server, outside the state that
`rea setup` and `rea doctor` manage.
If REA tools are available and their registration is not known to be stale,
proceed directly to the target. Do not run diagnostics or setup before every
@@ -13,7 +13,7 @@ main and an older release expose the same arguments.
| Observation | Meaning and next step |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No REA tools in the active session | Inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
| No REA tools in the active session | If REA was installed as a client plugin, follow the plugin section below. Otherwise inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
| Missing, malformed or stale registration | Prepare a scoped setup plan; apply only authorized changes with backups. |
| Aligned registration, but no connected tools | Restart/reconnect the client. Doctor checks registration; it cannot establish the current agent's connection. Inspect the client's launch error if reconnection fails. |
| `MCP tool call requires approval, but approval policy is never` | Codex rejected the call before REA executed it. Do not install an engine or repeat setup to solve this client-policy decision. See the approval section below. |
@@ -34,6 +34,33 @@ answers part of the question, and name the unavailable evidence separately.
Do not substitute another target. Label observations from ordinary shell tools
as such; only actual REA output supplies REA Evidence.
## REA installed as a client plugin
A Claude Code or Codex plugin owns its `rea` server and this skill. Doctor's
`--client` and `--skill` checks and `rea setup` read and write only
setup-owned state, so for a plugin-only install they report `missing`, and
applying setup would add a second `rea` server. If this skill was loaded from
a plugin, or the client lists a `rea` plugin, do not prepare a setup plan for
missing tools. Instead:
- Check that the plugin is installed and enabled with the client's own
listing: `claude plugin list` and `/mcp` in Claude Code, `codex plugin list`
in Codex.
- Restart or reconnect the client, then read its launch error for the `rea`
server if tools are still absent. The first start downloads the pinned
package through `npx` and can exceed the client's startup timeout; a second
start uses the cache.
- Check analysis engines through the connected server's
`binary_session.result.tool_availability`. `rea doctor --provider <id> --json`
from a shell reports that shell's environment, which can differ from the
environment the client gave the plugin server. If you still run it through
the package runner, use the version pinned in the plugin manifest's server
entry (`rea-agents@<version>`), not `@latest`, so it matches the server the
plugin starts.
Use the setup repair below only when the user wants a setup-owned registration
instead of the plugin, and remove or disable the plugin first.
## Repair registration when needed
For Codex:
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "rea",
"description": "Reverse engineer anything with agents, from app behavior down to native binaries.",
"owner": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"plugins": [
{
"name": "rea",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"source": {
"source": "npm",
"package": "rea-agents",
"version": "6.4.0"
},
"category": "development"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "rea",
"version": "6.4.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"author": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"homepage": "https://rea.tools/",
"repository": "https://github.com/morluto/rea",
"license": "MIT",
"keywords": [
"reverse-engineering",
"binary-analysis",
"decompiler",
"mcp-server"
],
"skills": "./skills",
"mcpServers": {
"rea": {
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
"6.4.0"
]
}
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "rea",
"version": "6.4.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"author": {
"name": "morluto",
"url": "https://github.com/morluto"
},
"homepage": "https://rea.tools/",
"repository": "https://github.com/morluto/rea",
"license": "MIT",
"keywords": [
"reverse-engineering",
"binary-analysis",
"decompiler",
"mcp-server"
],
"skills": "./skills",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "REA",
"shortDescription": "Reverse engineer anything with agents",
"longDescription": "Use REA to explain shipped behavior from native binaries, managed assemblies, Electron and JavaScript bundles, packages, firmware, and browser targets, with evidence that separates observations from inferences.",
"developerName": "morluto",
"category": "Developer Tools",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"websiteURL": "https://rea.tools/",
"defaultPrompt": [
"Use REA to explain how this binary implements a feature.",
"Use REA to compare two versions of this application.",
"Use REA to recover the sources from this JavaScript bundle."
]
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ runs:
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
LOCKFILES_HASH: ${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}
LOCKFILES_HASH: ${{ hashFiles('npm-shrinkwrap.json', '.npmrc') }}
run: |
cache_path="$(npm config get cache)"
runtime_arch="$(node -p 'process.arch')"
@@ -21,7 +21,7 @@ runs:
if: inputs.build_cache_artifact == ''
shell: bash
env:
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'npm-shrinkwrap.json') }}
run: |
npm run --silent test:prepare -- --dry=json |
node --input-type=module -e '
+2 -2
View File
@@ -133,9 +133,9 @@ jobs:
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache/
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }}
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'npm-shrinkwrap.json') }}-${{ github.sha }}
# Turbo validates each task's inputs after restoring this snapshot.
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'npm-shrinkwrap.json') }}-
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
+1 -1
View File
@@ -25,7 +25,7 @@ on:
- "scripts/lib/mcp-verifier-results.mjs"
- "scripts/rea.mjs"
- "package.json"
- "package-lock.json"
- "npm-shrinkwrap.json"
- ".nvmrc"
permissions:
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"rea": {
"command": "node",
"args": ["./scripts/plugin-mcp-launcher.mjs", "6.4.0"],
"cwd": ".",
"startup_timeout_sec": 30
}
}
}
+3 -1
View File
@@ -9,7 +9,9 @@
"docs/api/**",
"node_modules/**",
"CHANGELOG.md",
"package-lock.json",
"npm-shrinkwrap.json",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
"_reference/**",
"third_party/**"
]
+3
View File
@@ -131,6 +131,9 @@ npm ci
npm run check:fast
```
`npm-shrinkwrap.json` is the sole dependency lock for development and the
published plugins. Do not add a root `package-lock.json`; npm would ignore it.
`npm ci` installs the exact dependencies and prepares the Husky hooks without
building the project. Run `npm run build:cached` when you need the standalone
CLI or MCP server; it compiles the runtime and bundles the authored skill.
+1 -1
View File
@@ -15,7 +15,7 @@ notices supplied with the software you actually obtain or redistribute.
| Material | Use and licensing records |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `package-lock.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. |
| npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `npm-shrinkwrap.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. |
| NativeAOT parser | REA's derived parser uses the MIT-licensed [ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot/tree/effeb734fc570c32650f88b159608979dc7b423e) work of Washi and the NativeAOT layout definitions in [dotnet/runtime](https://github.com/dotnet/runtime/tree/a3b2d40328be0be3f8dd43f0e5cc925b8827b044). Preserve [Washi's MIT notice](licenses/nativeaot-parser-MIT.txt) and the [.NET Foundation MIT notice](licenses/dotnet-runtime-MIT.txt). |
| EVM analysis | `evmole@0.9.3`, MIT, copyright 2023 Maxim Andreev. The published npm archive omits its license document; REA retains the [original upstream license](third_party/evmole/LICENSE) and [provenance](third_party/evmole/README.md). It is also included in the runtime notices. |
| Windows native addon | The release addon is compiled with MinGW-w64 and statically links GCC runtime libraries. Preserve the [MinGW-w64 runtime notices](licenses/MinGW-w64-runtime.txt), [GPLv3 text](licenses/GPL-3.0.txt), and [GCC Runtime Library Exception 3.1](licenses/GCC-Runtime-Library-Exception-3.1.txt). The exception governs eligible compiled combinations; it does not relicense the runtime itself under MIT. The Node-API build headers are from Node 22.19.0; its [original license and component notices](licenses/Node-22.19.0-LICENSE.txt) are retained. Node itself is supplied by the user. Snapshot provenance and release-toolchain review requirements are in [licenses/README.md](licenses/README.md). |
+78
View File
@@ -805,6 +805,84 @@ read-only operations, digest identity, and runtime cleanup. The separate
ordinary user. It requires the built Windows native bundle and an existing
Ghidra/JDK installation; neither is inferred from startup alone.
## Agent plugins and marketplaces
REA's npm package is the shared Claude Code and Codex plugin artifact. It
contains `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and the
canonical generated `skills/` tree. Both manifests select `./skills` and run a plain Node bootstrap pinned to
that exact package version. The client must have a supported standalone `node`
on its PATH. Claude expands its documented `CLAUDE_PLUGIN_ROOT` in the script
argument; Codex resolves `cwd: "."` from the installed plugin root before
launching the relative script. The shared bootstrap preserves the pinned
package-runner arguments and stdio, handles Windows npm shims, and uses an owned
empty working directory so an unbuilt local checkout cannot shadow the exact
registry release. The existing release skill
generator also pins CLI recovery commands to the same version.
The repository's single-plugin marketplaces are declared in
`.claude-plugin/marketplace.json` and `.agents/plugins/marketplace.json`.
Both select an exact `rea-agents` npm version instead of mutable source-checkout
contents. Release Please updates their selectors alongside the plugin manifests.
No plugin-only publication or generated skill files committed to Git are needed.
This distribution becomes installable after the first qualified npm release
containing these plugin manifests. Earlier published packages are not changed
by this source proposal. For local source development, run `npm ci && npm run build:cached` to generate
the runtime and packaged skill before loading the repository as a plugin.
On Windows, also build the Windows x64 native addon following the
[Windows source-checkout guide](https://github.com/morluto/rea/blob/main/native/windows/README.md#build);
the registry fallback requires that addon and the built process supervisor to
own and clean up the package-runner process tree. The source plugin always
launches its pinned registry release, even when the checkout has been built. The authored
`.agents/skills/` tree is not the plugin's shipped skill layout.
In Claude Code:
```text
/plugin marketplace add morluto/rea
/plugin install rea@rea
```
In Codex, add the marketplace and then install REA from the plugin list:
```bash
codex plugin marketplace add morluto/rea
```
Each release updates both manifest versions and their pinned server version
together, which is when installed copies refresh the bundled skill and move to
the new server. A plugin does not run `rea setup`, so provider
configuration still follows the sections above. Use the plugin or a
`rea setup` registration for a client, not both, to avoid two `rea` servers.
`rea doctor` does not see plugin-owned state. Its client and skill checks read
only the registrations and skill locations that `rea setup` manages, so for a
plugin-only install `rea doctor --client <id>` and `rea doctor --skill` report
`missing` and recommend `rea setup`. Do not follow that remediation. Check the
plugin with the client instead (`claude plugin list` and `/mcp` in Claude
Code, `codex plugin list` in Codex).
For analysis engines, ask the connected server: have the agent call
`binary_session` and read `result.tool_availability`, which reports what the
server the plugin started can use, with reasons and remediation. See
[MCP contracts](mcp-contracts.md).
A plugin does not put `rea` on your `PATH`. To check an engine from a
terminal, use the package runner at the version the plugin pins:
<!-- x-release-please-start-version -->
```bash
npx -y rea-agents@6.4.0 doctor --provider ghidra --json
```
<!-- x-release-please-end -->
If your installed plugin is older, use the version in its manifest instead.
That report describes the terminal's environment, not the plugin server's.
The server inherits the client's environment, so a setting such as
`GHIDRA_INSTALL_DIR` that exists only in your terminal does not reach it.
## MCP Registry
REA is published in the official MCP Registry as `io.github.morluto/rea`. Registry
+5 -2
View File
@@ -240,8 +240,11 @@ Review and test this synchronization PR, then use a merge commit so the release
tag remains in main's ancestry. Keep the released tag unchanged.
The synchronization must include `.release-please-manifest.json`,
`package.json`, both root versions in `package-lock.json`, `CHANGELOG.md`,
`server.json`, and the versioned documentation examples. Run
`package.json`, both root versions in `npm-shrinkwrap.json`, `CHANGELOG.md`,
`server.json`, both plugin manifests (`.claude-plugin/plugin.json` and
`.codex-plugin/plugin.json`), the Codex server declaration (`.mcp.json`), both
marketplaces (`.claude-plugin/marketplace.json` and
`.agents/plugins/marketplace.json`), and the versioned documentation examples. Run
`npm run docs:generate`, `npm run docs:check`, and the release configuration
tests against the combined tree. After the merge, verify that the released
tag is an ancestor of main:
+1 -1
View File
@@ -111,7 +111,7 @@ or acquire that reduction automatically.
Use the repository's pinned Node/npm and locked dependencies. Build each selected
revision, then copy its `dist/`, `bridge/`, `scripts/`, `skills/`, `package.json`
and `package-lock.json` to a separate runtime directory before editing production
and `npm-shrinkwrap.json` to a separate runtime directory before editing production
sources. That copy needs the matching locked dependencies. The initial frozen
baseline is `42b0ed126df7d5b908ac369715378d5e519051b7`; this study does not
silently substitute a newer `main` or the previous Code Mode experiment.
@@ -216,7 +216,7 @@ def main():
'authority': args.authority,
'runtime_files_sha256': runtime_digest,
'runtime_dispatcher_sha256': file_digest(repository / 'scripts/rea.mjs'),
'package_lock_sha256': file_digest(repository / 'package-lock.json'),
'package_lock_sha256': file_digest(repository / 'npm-shrinkwrap.json'),
'experiment_sources_sha256': {
name: hashlib.sha256((experiment / name).read_bytes()).hexdigest()
for name in ['agent-trial.py', 'codex_context.py', 'provider-environment.json']
+1 -1
View File
@@ -147,7 +147,7 @@ const fingerprints = {
resolve(values["runtime-root"], "scripts/rea.mjs"),
),
package_lock_sha256: await fileSha(
resolve(values["runtime-root"], "package-lock.json"),
resolve(values["runtime-root"], "npm-shrinkwrap.json"),
),
};
const clientVersion = JSON.parse(
@@ -109,7 +109,7 @@ class ContextCaptureTests(unittest.TestCase):
runtime = root / 'runtime'
(runtime / 'scripts').mkdir(parents=True)
(runtime / 'scripts' / 'rea.mjs').write_text('')
(runtime / 'package-lock.json').write_text('{}')
(runtime / 'npm-shrinkwrap.json').write_text('{}')
auth_home = root / 'source-auth'
auth_home.mkdir()
(auth_home / 'auth.json').write_text('synthetic-credential')
File diff suppressed because it is too large Load Diff
+2333 -197
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -47,6 +47,7 @@
"packageManager": "npm@11.16.0",
"files": [
"dist",
"npm-shrinkwrap.json",
"install.sh",
"bridge/hopper_bridge.py",
"bridge/cutter/rea_bridge.py",
@@ -67,12 +68,16 @@
"bridge/android/ReaJadxBridge.java",
"bridge/mitmproxy/capture.py",
"scripts/rea.mjs",
"scripts/plugin-mcp-launcher.mjs",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs",
"scripts/hopper-demo-x11.py",
"scripts/prepack.mjs",
"scripts/prepare.mjs",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".mcp.json",
"skills",
"README.md",
"DISCLAIMER.md",
@@ -351,11 +356,6 @@
"yaml": "2.9.0",
"zod": "4.4.3"
},
"overrides": {
"vitepress": {
"vite": "6.4.4"
}
},
"devDependencies": {
"@fast-check/vitest": "0.5.0",
"@stryker-mutator/core": "10.0.0",
@@ -373,6 +373,7 @@
"lint-staged": "16.4.0",
"oxfmt": "0.72.0",
"oxlint": "1.87.0",
"release-please": "17.3.0",
"rolldown": "1.2.12",
"turbo": "2.11.7",
"typescript": "7.0.2",
+30
View File
@@ -31,6 +31,36 @@
"type": "json",
"path": "server.json",
"jsonpath": "$.packages[0].version"
},
{
"type": "json",
"path": ".claude-plugin/plugin.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": ".claude-plugin/plugin.json",
"jsonpath": "$.mcpServers.rea.args[1]"
},
{
"type": "json",
"path": ".codex-plugin/plugin.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": ".mcp.json",
"jsonpath": "$.mcpServers.rea.args[1]"
},
{
"type": "json",
"path": ".claude-plugin/marketplace.json",
"jsonpath": "$.plugins[0].source.version"
},
{
"type": "json",
"path": ".agents/plugins/marketplace.json",
"jsonpath": "$.plugins[0].source.version"
}
]
}
+3 -3
View File
@@ -17,7 +17,7 @@ const installedPackageKey = (name) => `node_modules/${name}`;
export const dependencyInstallProblems = (rootLock, installedLock) => {
const rootPackage = rootLock.packages?.[""];
if (rootPackage === undefined)
return ["package-lock.json does not contain a root package entry"];
return ["npm-shrinkwrap.json does not contain a root package entry"];
const installedPackages = installedLock.packages ?? {};
return Object.keys(directDependencies(rootPackage))
.sort()
@@ -26,7 +26,7 @@ export const dependencyInstallProblems = (rootLock, installedLock) => {
const expected = rootLock.packages?.[key]?.version;
const actual = installedPackages[key]?.version;
if (expected === undefined)
return [`${name}: missing resolved version in package-lock.json`];
return [`${name}: missing resolved version in npm-shrinkwrap.json`];
if (actual === undefined)
return [`${name}: missing from the installed dependency lockfile`];
return actual === expected
@@ -39,7 +39,7 @@ const main = async () => {
const root = process.cwd();
try {
const [rootLock, installedLock] = await Promise.all([
readJson(resolve(root, "package-lock.json")),
readJson(resolve(root, "npm-shrinkwrap.json")),
readJson(resolve(root, "node_modules/.package-lock.json")),
]);
const problems = dependencyInstallProblems(rootLock, installedLock);
+2 -2
View File
@@ -14,7 +14,7 @@ const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
await readFile(join(root, "npm-shrinkwrap.json"), "utf8"),
);
const skill = await readFile(
join(root, ".agents/skills/reverse-engineer-anything/SKILL.md"),
@@ -29,7 +29,7 @@ const versionOf = (name) => {
throw new Error(`Missing locked ${name} version`);
return value;
};
const source = `/** Generated from package.json and package-lock.json; do not edit. */
const source = `/** Generated from package.json and npm-shrinkwrap.json; do not edit. */
export const PACKAGE_METADATA = {
name: ${JSON.stringify(packageJson.name)},
version: ${JSON.stringify(packageJson.version)}, // x-release-please-version
+2 -2
View File
@@ -11,7 +11,7 @@ for (const argument of arguments_)
throw new Error(`Unknown inventory option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
await readFile(join(root, "npm-shrinkwrap.json"), "utf8"),
);
const entries = Object.entries(lock.packages)
.filter(([path]) => path !== "")
@@ -38,7 +38,7 @@ const runtimeFingerprint = createHash("sha256")
.digest("hex");
const source = [
"REA npm dependency inventory",
"Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.",
"Prepared from the root npm-shrinkwrap.json; refresh this inventory when locked dependencies change.",
"License identifiers are upstream lockfile declarations, not a legal assessment.",
"Runtime includes transitive and optional platform packages; development includes build, test and website tooling.",
`Locked package entries: ${entries.length}; runtime: ${runtime.length}.`,
+59 -3
View File
@@ -4,6 +4,37 @@ import { z } from "zod";
const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
const versionSchema = z.object({ version: z.string() });
const manifestSchema = z.object({ ".": z.string() });
const PLUGIN_MANIFEST_PATHS = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
];
const inlinePluginManifestSchema = versionSchema.extend({
mcpServers: z.object({
rea: z.object({
command: z.literal("node"),
args: z.tuple([
z.literal("${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs"),
z.string(),
]),
}),
}),
});
const codexPluginManifestSchema = versionSchema.extend({
mcpServers: z.literal("./.mcp.json"),
});
const codexServerConfigSchema = z.object({
mcpServers: z.object({
rea: z.object({
command: z.literal("node"),
args: z.tuple([
z.literal("./scripts/plugin-mcp-launcher.mjs"),
z.string(),
]),
cwd: z.literal("."),
startup_timeout_sec: z.number().int().positive(),
}),
}),
});
const sectionsSchema = z.object({
versioning: z.string().optional(),
"changelog-sections": z.array(
@@ -161,14 +192,14 @@ async function validateCandidate(git, sha, version, commits) {
const lock = await readJson(
git,
sha,
"package-lock.json",
"npm-shrinkwrap.json",
versionSchema.extend({
packages: z.object({ "": versionSchema }),
}),
);
versions.push(
["package-lock.json", lock.version],
["package-lock.json packages.root", lock.packages[""].version],
["npm-shrinkwrap.json", lock.version],
["npm-shrinkwrap.json packages.root", lock.packages[""].version],
);
const server = await readJson(
git,
@@ -189,6 +220,21 @@ async function validateCandidate(git, sha, version, commits) {
["server.json", server.version],
["server.json rea-agents package", npmPackage.version],
);
// A stale manifest keeps the previous plugin cache key or launches the
// previous server, so both are version authority like server.json.
const launches = [];
for (const path of PLUGIN_MANIFEST_PATHS) {
const manifest =
path === ".codex-plugin/plugin.json"
? await readJson(git, sha, path, codexPluginManifestSchema)
: await readJson(git, sha, path, inlinePluginManifestSchema);
versions.push([path, manifest.version]);
const server =
path === ".codex-plugin/plugin.json"
? await readJson(git, sha, ".mcp.json", codexServerConfigSchema)
: manifest;
launches.push([path, server.mcpServers.rea.args[1]]);
}
// Catalogs are generated and checked from this candidate by the release job;
// they are not version authority stored in the immutable source tree.
const generated = await git([
@@ -210,6 +256,16 @@ async function validateCandidate(git, sha, version, commits) {
);
}
}
// Plugin JSON stores only a scalar version. The launcher owns the fixed
// package name and constructs the complete registry specifier at runtime.
const requiredLaunch = version;
for (const [location, launched] of launches) {
if (launched !== requiredLaunch) {
throw new Error(
`${location}@${sha} launches version ${launched ?? "no version"}; release branch requires ${requiredLaunch}.`,
);
}
}
const notes = releaseNotes(
await git(["show", `${sha}:CHANGELOG.md`]),
version,
+65 -1
View File
@@ -365,6 +365,60 @@ export function verifyReleaseNotes(raw, text, body) {
};
}
const VERSION_ONLY_PLUGIN_METADATA = new Set([
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".mcp.json",
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
]);
/** Serialize plugin metadata with only its release-owned version fields blanked. */
function pluginMetadataWithoutVersions(path, text) {
const manifest = JSON.parse(text);
if (manifest === null || typeof manifest !== "object") return text;
if (path.endsWith("marketplace.json")) {
if (Array.isArray(manifest.plugins))
for (const plugin of manifest.plugins)
if (
plugin?.name === "rea" &&
plugin?.source?.source === "npm" &&
plugin.source.package === "rea-agents" &&
typeof plugin.source.version === "string"
)
plugin.source.version = "";
return JSON.stringify(manifest);
}
if ("version" in manifest) manifest.version = "";
const launch = manifest.mcpServers?.rea?.args;
// Only the scalar pinned version is release-owned; the launcher owns the
// fixed package name and the executable/path remain product behavior.
if (Array.isArray(launch) && typeof launch[1] === "string") launch[1] = "";
return JSON.stringify(manifest);
}
/**
* Plugin metadata also carries behavior, so a release may change only
* its version, pinned server launch, and exact REA marketplace version. Any other difference, an unreadable
* side, or a manifest added by the release counts as a product change.
*/
async function changesOnlyPluginVersions(run, path, from, to) {
try {
return (
pluginMetadataWithoutVersions(
path,
await run("git", ["show", `${from}:${path}`]),
) ===
pluginMetadataWithoutVersions(
path,
await run("git", ["show", `${to}:${path}`]),
)
);
} catch {
return false;
}
}
/** Reject stale inventories and product changes made after the editorial target. */
export async function verifyInventoryHistory(run, raw, source) {
const inventory = releaseInventorySchema.parse(raw);
@@ -416,6 +470,7 @@ export async function verifyInventoryHistory(run, raw, source) {
".release-please-manifest.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"server.json",
"src/generatedPackageMetadata.ts",
"docs/installation.md",
@@ -433,7 +488,16 @@ export async function verifyInventoryHistory(run, raw, source) {
)
.split("\0")
.filter(Boolean);
const product = paths.filter((path) => !allowed.has(path));
const product = [];
for (const path of paths) {
if (allowed.has(path)) continue;
if (
VERSION_ONLY_PLUGIN_METADATA.has(path) &&
(await changesOnlyPluginVersions(run, path, inventory.target, resolved))
)
continue;
product.push(path);
}
if (product.length)
throw new Error(
`Product changed after notes target; regenerate inventory: ${product.join(", ")}`,
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { access, mkdtemp, rm } from "node:fs/promises";
import { constants, tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
// Keep the package name at the launcher boundary so release automation updates
// only a scalar version in plugin JSON, never a compound package specifier.
// Git plugins launch the registry release. Windows source plugins additionally
// require a build and native addon for the owned process supervisor.
const [version, ...extra] = process.argv.slice(2);
if (
extra.length !== 0 ||
!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/u.test(version ?? "")
) {
process.stderr.write(
"REA plugin requires one exact rea-agents@VERSION pin.\n",
);
process.exitCode = 1;
} else {
await launch(`rea-agents@${version}`);
}
async function launch(packageSpecifier) {
// npm installs already carry the release-pinned runtime. Run it in this
// process so the plugin keeps the caller's working directory and the
// runtime retains ownership of provider and worker cleanup.
const { default: bundledPackage } = await import("../package.json", {
with: { type: "json" },
});
const sourceCheckout = await isSourceCheckout();
let bundledRuntimeMatchesPin =
!sourceCheckout &&
packageSpecifier === `${bundledPackage.name}@${bundledPackage.version}`;
if (bundledRuntimeMatchesPin) {
try {
await access(new URL("../dist/mcp/main.js", import.meta.url));
} catch (cause) {
if (!isMissing(cause)) throw cause;
bundledRuntimeMatchesPin = false;
}
}
if (bundledRuntimeMatchesPin) {
process.argv = [
process.execPath,
fileURLToPath(new URL("./rea.mjs", import.meta.url)),
"mcp",
];
await import("./rea.mjs");
return;
}
const runnerModule = await import(
sourceCheckout
? "../src/application/PackageRunnerCommand.mjs"
: "../dist/application/PackageRunnerCommand.mjs"
);
const { packageRunnerCommand } = runnerModule;
const [command, ...args] = packageRunnerCommand(
"npx",
packageSpecifier,
process.platform,
);
// npm can satisfy the exact pin from a same-version local checkout. An
// empty owned cwd prevents a Git-installed plugin or caller project from
// shadowing the registry package with unbuilt/different local files.
let workingDirectory;
try {
workingDirectory = await mkdtemp(join(tmpdir(), "rea-plugin-launcher-"));
} catch (cause) {
process.stderr.write(
`REA plugin could not create its working directory: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
if (process.platform === "win32") {
await launchWindowsFallback(command, args, workingDirectory);
return;
}
const child = spawn(command, args, {
cwd: workingDirectory,
// A dedicated POSIX process group gives this launcher ownership of the
// package runner and any server it starts.
detached: true,
stdio: "inherit",
shell: false,
});
let spawnFailed = false;
const signals = new Map();
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
const forward = () => {
if (child.exitCode !== null || child.signalCode !== null) return;
try {
if (process.platform !== "win32" && child.pid !== undefined)
process.kill(-child.pid, signal);
else child.kill(signal);
} catch (cause) {
if (!isMissingProcess(cause))
process.stderr.write(
`REA plugin could not forward ${signal}: ${cause.message}\n`,
);
}
};
signals.set(signal, forward);
process.on(signal, forward);
}
const stopForwarding = () => {
for (const [signal, listener] of signals) process.off(signal, listener);
};
child.once("error", (cause) => {
spawnFailed = true;
stopForwarding();
process.stderr.write(
`REA plugin could not start ${command}: ${cause.message}\n`,
);
process.exitCode = 1;
});
child.once("close", async (code, signal) => {
stopForwarding();
let cleanupFailed = false;
try {
await rm(workingDirectory, { recursive: true });
} catch (cause) {
cleanupFailed = true;
process.stderr.write(
`REA plugin retained its working directory at ${workingDirectory}: ${cause.message}\n`,
);
}
if (spawnFailed) return;
if (signal !== null && process.platform !== "win32") {
// Preserve signal termination after the owned child has closed its streams.
process.kill(process.pid, signal);
} else {
process.exitCode =
code === 0 && cleanupFailed
? 1
: (code ?? 128 + (constants.signals[signal] ?? 1));
}
});
}
async function isSourceCheckout() {
// Published artifacts exclude src/. Also reject Git worktrees (whose .git is
// a file) and source exports where Git metadata has been stripped.
for (const path of ["../.git", "../src"]) {
try {
await access(new URL(path, import.meta.url));
return true;
} catch (cause) {
if (!isMissing(cause)) throw cause;
}
}
return false;
}
async function launchWindowsFallback(command, args, workingDirectory) {
let spawnOwnedProviderProcess;
try {
({ spawnOwnedProviderProcess } =
await import("../dist/process/ProviderProcess.js"));
} catch (cause) {
await rm(workingDirectory, { recursive: true }).catch(() => {});
process.stderr.write(
`REA Windows source plugins require npm ci && npm run build:cached and the Windows x64 native addon before loading the plugin. The owned process supervisor is unavailable: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
let owned;
try {
owned = await spawnOwnedProviderProcess({
command,
arguments: args,
runId: randomUUID(),
cwd: workingDirectory,
stdin: "pipe",
platform: "win32",
});
} catch (cause) {
await rm(workingDirectory, { recursive: true }).catch(() => {});
process.stderr.write(
`REA plugin could not start the owned Windows process tree: ${cause.message}\n`,
);
process.exitCode = 1;
return;
}
const child = owned.process;
child.stdout?.pipe(process.stdout);
child.stderr?.pipe(process.stderr);
if (child.stdin === null || child.stdin === undefined) {
const cleanup = await owned.cleanup?.();
if (cleanup?.cleaned === true)
await rm(workingDirectory, { recursive: true }).catch(() => {});
else
process.stderr.write(
`REA plugin retained its Windows process ownership directory at ${workingDirectory} because cleanup was not verified.\n`,
);
process.stderr.write(
"REA plugin Windows process supervisor did not provide protocol stdin.\n",
);
process.exitCode = 1;
return;
}
process.stdin.pipe(child.stdin);
let cleanupFailed = false;
let cleanupPromise;
const signals = new Map();
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
const forward = () => {
if (child.exitCode !== null || child.signalCode !== null) return;
cleanupPromise ??= owned
.cleanup?.()
.then((result) => {
if (!result.cleaned) {
cleanupFailed = true;
process.stderr.write(
`REA plugin could not verify Windows process-tree cleanup: ${result.reason}\n`,
);
}
})
.catch((cause) => {
cleanupFailed = true;
process.stderr.write(
`REA plugin Windows process-tree cleanup failed: ${cause.message}\n`,
);
});
};
signals.set(signal, forward);
process.on(signal, forward);
}
child.once("error", (cause) => {
process.stderr.write(
`REA plugin Windows process supervisor failed: ${cause.message}\n`,
);
process.exitCode = 1;
});
child.once("close", async (code) => {
for (const [signal, listener] of signals) process.off(signal, listener);
await cleanupPromise;
if (!cleanupFailed) {
try {
await rm(workingDirectory, { recursive: true });
} catch (cause) {
cleanupFailed = true;
process.stderr.write(
`REA plugin retained its working directory at ${workingDirectory}: ${cause.message}\n`,
);
}
}
process.exitCode = code === 0 && cleanupFailed ? 1 : (code ?? 1);
});
}
function isMissing(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
);
}
function isMissingProcess(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ESRCH"
);
}
+10 -5
View File
@@ -3,13 +3,14 @@ import {
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readFile, rm } from "node:fs/promises";
import { readFile, realpath, rm } from "node:fs/promises";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import * as prompts from "./verify-package-prompts.mjs";
@@ -246,7 +247,7 @@ const verifyMcpInlineArtifactEvidence = async (
}
};
/** Connect to the packaged MCP server and verify catalog and Evidence composition. */
/** Check CLI diagnostics, then verify catalog and Evidence through the installed plugin. */
export async function verifyPackageMcp({
cli,
environment,
@@ -267,9 +268,13 @@ export async function verifyPackageMcp({
diagnosed.inventory?.tools?.expected
)
throw new Error("packaged production MCP doctor failed");
const scriptsDirectory = dirname(await realpath(cli));
const transport = new StdioClientTransport({
command: cli,
args: ["mcp"],
command: process.execPath,
args: [
join(scriptsDirectory, "plugin-mcp-launcher.mjs"),
PRODUCT_IDENTITY.packageVersion,
],
env: {
...environment,
},
+70
View File
@@ -25,6 +25,23 @@ export async function verifyPackagePack({ root, workspace }) {
const packedManifest = JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout,
);
const packedLock = JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/npm-shrinkwrap.json"]))
.stdout,
);
if (
packedManifest.overrides !== undefined ||
![2, 3].includes(packedLock.lockfileVersion) ||
packedLock.version !== packedManifest.version ||
packedLock.packages?.[""].version !== packedManifest.version ||
JSON.stringify(packedLock.packages?.[""].dependencies) !==
JSON.stringify(packedManifest.dependencies) ||
JSON.stringify(packedLock.packages?.[""].optionalDependencies) !==
JSON.stringify(packedManifest.optionalDependencies)
)
throw new Error(
"package is not installable under the Claude plugin dependency policy",
);
if (
packedManifest.scripts?.postinstall !== undefined ||
packedManifest.dependencies?.["node-pty"] !== undefined ||
@@ -42,6 +59,10 @@ export async function verifyPackagePack({ root, workspace }) {
throw new Error(
"package included authored skill sources instead of only the generated bundle",
);
if (packedFiles.some((path) => path.startsWith("package/src/")))
throw new Error(
"Packaged runtime must not masquerade as a source checkout",
);
if (packedFiles.some((path) => path.startsWith("package/.cache/")))
throw new Error("package included development cache or test metadata");
const skill = (
@@ -60,6 +81,55 @@ export async function verifyPackagePack({ root, workspace }) {
throw new Error(
"packaged skill metadata did not match the catalog shipped in the package",
);
for (const path of [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
]) {
const manifest = JSON.parse(
(await exec("tar", ["-xOf", tarball, `package/${path}`])).stdout,
);
const serverConfig = path.startsWith(".codex")
? JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/.mcp.json"])).stdout,
)
: manifest;
const expectedArgs = [
`${path.startsWith(".claude") ? "${CLAUDE_PLUGIN_ROOT}/" : "./"}scripts/plugin-mcp-launcher.mjs`,
packedManifest.version,
];
if (
manifest.version !== packedManifest.version ||
manifest.skills !== "./skills" ||
(path.startsWith(".codex")
? manifest.mcpServers !== "./.mcp.json" ||
serverConfig.mcpServers?.rea?.cwd !== "." ||
serverConfig.mcpServers?.rea?.startup_timeout_sec !== 30
: false) ||
serverConfig.mcpServers?.rea?.command !== "node" ||
JSON.stringify(serverConfig.mcpServers?.rea?.args) !==
JSON.stringify(expectedArgs)
)
throw new Error(
`Packaged plugin ${path} does not match the exact runtime and canonical skill release`,
);
}
if (!packedFiles.includes("package/.mcp.json"))
throw new Error("Packaged Codex MCP server declaration is missing");
for (const path of [
"scripts/plugin-mcp-launcher.mjs",
"dist/application/PackageRunnerCommand.mjs",
])
if (!packedFiles.includes(`package/${path}`))
throw new Error(
`Packaged plugin launcher dependency is missing: ${path}`,
);
if (
skill.includes(`${packedManifest.name}@latest`) ||
!skill.includes(`${packedManifest.name}@${packedManifest.version}`)
)
throw new Error(
"Packaged plugin skill recovery is not pinned to the shipped runtime",
);
if (
packedFiles.some(
(path) => path.includes("__pycache__") || path.endsWith(".pyc"),
@@ -2,6 +2,7 @@ import { basename, isAbsolute, resolve } from "node:path";
import { valid } from "semver";
import { PRODUCT_IDENTITY } from "../identity.js";
import { packageRunnerCommand } from "./PackageRunnerCommand.mjs";
/** Package runners whose pinned launcher setup can register. */
export type PackageRunner = "npx" | "bunx";
@@ -53,14 +54,12 @@ export const withoutPackageRunnerIdentity = (
export const packageRunnerRegistrationCommand = (
runner: PackageRunner,
platform: NodeJS.Platform = process.platform,
): readonly string[] => {
const command =
runner === "npx"
? ["npx", "-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]
: ["bunx", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"];
// Both runners may be .cmd shims on Windows, which clients cannot spawn directly.
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
};
): readonly string[] =>
packageRunnerCommand(
runner,
PRODUCT_IDENTITY.registrationPackageSpecifier,
platform,
);
/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */
export const npxRegistrationCommand = (
@@ -0,0 +1,6 @@
/** Exact host argv for a pinned registry package's MCP entry point. */
export function packageRunnerCommand(
runner: "npx" | "bunx",
packageSpecifier: string,
platform: NodeJS.Platform,
): readonly string[];
+16
View File
@@ -0,0 +1,16 @@
/** Host argv shared by setup registrations and the source-only plugin launcher. */
export const packageRunnerCommand = (runner, packageSpecifier, platform) => {
if (runner !== "npx" && runner !== "bunx")
throw new Error("Unsupported package runner");
// Windows cmd.exe consumes these tokens itself. Package registrations use
// exact registry references, never shell expressions or arbitrary operands.
if (!/^[@A-Za-z0-9_./+-]+$/u.test(packageSpecifier))
throw new Error("Package runner requires a shell-safe registry reference");
const command =
runner === "npx"
? ["npx", "-y", packageSpecifier, "mcp"]
: ["bunx", packageSpecifier, "mcp"];
// npm's .cmd shim is not a native executable. Disable cmd AutoRun, matching
// the setup registration policy, and keep all dynamic values out of syntax.
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
};
+1 -1
View File
@@ -1,4 +1,4 @@
/** Generated from package.json and package-lock.json; do not edit. */
/** Generated from package.json and npm-shrinkwrap.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "6.4.0", // x-release-please-version
@@ -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",
@@ -1,5 +1,5 @@
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, expect, it } from "vitest";
import { z } from "zod";
@@ -19,6 +19,32 @@ const verifier = fileURLToPath(
new URL("../../../scripts/verify-release-checkpoint.mjs", import.meta.url),
);
const pluginManifestPaths = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginManifest = (version: string, launch = version) => ({
version,
mcpServers: {
rea: {
command: "node",
args: ["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs", launch],
},
},
});
const codexServerConfig = (version: string, launch = version) => ({
mcpServers: {
rea: {
command: "node",
args: ["./scripts/plugin-mcp-launcher.mjs", launch],
cwd: ".",
startup_timeout_sec: 30,
},
},
});
async function metadata(
directory: string,
version: string,
@@ -27,9 +53,17 @@ async function metadata(
) {
await mkdir(join(directory, "docs"), { recursive: true });
await mkdir(join(directory, "src"), { recursive: true });
for (const path of pluginManifestPaths)
await mkdir(join(directory, dirname(path)), { recursive: true });
const files = {
".claude-plugin/plugin.json": pluginManifest(version),
".codex-plugin/plugin.json": {
version,
mcpServers: "./.mcp.json",
},
".mcp.json": codexServerConfig(version),
"package.json": { name: "rea-agents", version },
"package-lock.json": { version, packages: { "": { version } } },
"npm-shrinkwrap.json": { version, packages: { "": { version } } },
".release-please-manifest.json": { ".": version },
"server.json": {
version,
@@ -534,7 +568,7 @@ it("validates candidate options before inspecting the working directory", async
});
it.each([
"package-lock.json",
"npm-shrinkwrap.json",
"server.json",
"src/generatedPackageMetadata.ts",
])("rejects inconsistent version metadata in %s", async (path) => {
@@ -545,7 +579,7 @@ it.each([
path.endsWith(".ts")
? 'export const METADATA = {\n version: "5.1.0",\n};\n'
: JSON.stringify(
path === "package-lock.json"
path === "npm-shrinkwrap.json"
? { version: "6.0.0", packages: { "": { version: "5.1.0" } } }
: path === "server.json"
? {
@@ -565,6 +599,51 @@ it.each([
});
});
it.each(
pluginManifestPaths.map((path) => ({
path,
stale: "version",
manifest:
path === ".codex-plugin/plugin.json"
? { version: "5.1.0", mcpServers: "./.mcp.json" }
: pluginManifest("5.1.0"),
failure: "declares 5.1.0; release branch requires 6.0.0",
})),
)("rejects a plugin $stale in $path", async ({ path, manifest, failure }) => {
const f = await fixture();
await candidate(f, "6.0.0");
await writeFile(join(f.directory, path), JSON.stringify(manifest));
await git(f.directory, ["add", "."]);
await git(f.directory, ["commit", "-m", "chore: stale plugin manifest"]);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(failure),
});
});
it.each([
{
path: ".claude-plugin/plugin.json",
manifest: pluginManifest("6.0.0", "5.1.0"),
},
{
path: ".mcp.json",
manifest: codexServerConfig("6.0.0", "5.1.0"),
},
])("reads the release pin from $path", async ({ path, manifest }) => {
const f = await fixture();
await candidate(f, "6.0.0");
await writeFile(join(f.directory, path), JSON.stringify(manifest));
await git(f.directory, ["add", "."]);
await git(f.directory, ["commit", "-m", "chore: stale plugin launch"]);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
`launches version 5.1.0; release branch requires 6.0.0`,
),
});
});
it("checks a publish merge using its pre-release first parent baseline", async () => {
const f = await fixture();
const sourceSha = await candidate(f, "6.0.0");
+182 -2
View File
@@ -6,7 +6,7 @@ import {
readFile,
writeFile,
} from "node:fs/promises";
import { delimiter, join } from "node:path";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, expect, it } from "vitest";
import { z } from "zod";
@@ -43,6 +43,59 @@ interface ReleaseNotesSeed {
let seed: ReleaseNotesSeed | undefined;
let seedWorkspaceRoot: string | undefined;
const pluginManifests = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginServerConfig = ".mcp.json";
const pluginMarketplaces = [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
] as const;
const pluginMarketplace = (version: string, packageName = "rea-agents") => ({
plugins: [
{ name: "rea", source: { source: "npm", package: packageName, version } },
],
});
const pluginServer = (version: string, command = "node") => ({
mcpServers: {
rea: {
command,
args: ["./scripts/plugin-mcp-launcher.mjs", version],
startup_timeout_sec: 30,
},
},
});
const pluginManifest = (
version: string,
{ skills = ["./skill"], command = "node" } = {},
) => ({
version,
skills,
mcpServers: {
rea: {
command,
args: ["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs", version],
},
},
});
const pluginManifestFor = (
path: (typeof pluginManifests)[number],
version: string,
options: { skills?: string[]; command?: string } = {},
) =>
path === ".codex-plugin/plugin.json"
? {
version,
skills: options.skills ?? ["./skill"],
mcpServers: "./.mcp.json",
}
: pluginManifest(version, options);
beforeAll(async () => {
const workspace = await createTestWorkspace("rea-release-notes-seed-");
seedWorkspaceRoot = workspace.root;
@@ -58,6 +111,24 @@ beforeAll(async () => {
join(directory, ".release-please-manifest.json"),
JSON.stringify({ ".": "6.3.0" }),
);
for (const path of pluginManifests) {
await mkdir(join(directory, dirname(path)), { recursive: true });
await writeFile(
join(directory, path),
JSON.stringify(pluginManifestFor(path, "6.3.0")),
);
}
await writeFile(
join(directory, pluginServerConfig),
JSON.stringify(pluginServer("6.3.0")),
);
for (const path of pluginMarketplaces) {
await mkdir(join(directory, dirname(path)), { recursive: true });
await writeFile(
join(directory, path),
JSON.stringify(pluginMarketplace("6.3.0")),
);
}
await git(directory, ["add", "."]);
await git(directory, ["commit", "-m", "chore: baseline"]);
const base = await git(directory, ["rev-parse", "HEAD"]);
@@ -368,9 +439,30 @@ it("rejects lost grouped credits, fabricated references, bots, and stale PR bodi
it("binds publication to complete recorded history and the reviewed base", async () => {
const f = await fixture();
await git(f.directory, ["add", "CHANGELOG.md"]);
for (const path of pluginManifests)
await writeFile(
join(f.directory, path),
JSON.stringify(pluginManifestFor(path, "6.4.0")),
);
await writeFile(
join(f.directory, pluginServerConfig),
JSON.stringify(pluginServer("6.4.0")),
);
for (const path of pluginMarketplaces)
await writeFile(
join(f.directory, path),
JSON.stringify(pluginMarketplace("6.4.0")),
);
await git(f.directory, [
"add",
"CHANGELOG.md",
...pluginManifests,
pluginServerConfig,
...pluginMarketplaces,
]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
const source = await git(f.directory, ["rev-parse", "HEAD"]);
// Release PRs bump the plugin manifest version and pinned launch only.
await f.invoke(["check", "--manifest", f.manifest, "--source", source]);
await writeFile(join(f.directory, "product.txt"), "later product change");
await git(f.directory, ["add", "product.txt"]);
@@ -391,6 +483,74 @@ it("binds publication to complete recorded history and the reviewed base", async
});
});
it.each(pluginManifests)(
"treats a release that changes the skill path in %s as a product change",
async (path) => {
const f = await fixture();
await writeFile(
join(f.directory, path),
JSON.stringify(
pluginManifestFor(path, "6.4.0", { skills: ["./another-skill"] }),
),
);
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
it("accepts Release Please lockfile refreshes without allowing product changes", async () => {
const f = await fixture();
await writeFile(
join(f.directory, "package-lock.json"),
'{"lockfileVersion":3}',
);
await writeFile(
join(f.directory, "npm-shrinkwrap.json"),
'{"lockfileVersion":3}',
);
await git(f.directory, [
"add",
"CHANGELOG.md",
"package-lock.json",
"npm-shrinkwrap.json",
]);
await git(f.directory, ["commit", "-m", "chore: release lockfiles"]);
await f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]);
});
it.each([
{
path: ".claude-plugin/plugin.json",
content: pluginManifest("6.4.0", { command: "other-launcher" }),
},
{
path: pluginServerConfig,
content: pluginServer("6.4.0", "other-launcher"),
},
])(
"keeps launcher behavior in $path outside version-only metadata",
async ({ path, content }) => {
const f = await fixture();
await writeFile(join(f.directory, path), JSON.stringify(content));
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
it("checks the live merged PR and exact Git snapshot before either publication route", async () => {
const f = await fixture();
await mkdir(join(f.directory, "docs/releases"), { recursive: true });
@@ -536,3 +696,23 @@ it("fails closed on partial GitHub errors and non-advancing pagination without s
});
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(pluginMarketplaces)(
"treats a marketplace package change in %s as product behavior",
async (path) => {
const f = await fixture();
await writeFile(
join(f.directory, path),
JSON.stringify(pluginMarketplace("6.4.0", "another-package")),
);
await git(f.directory, ["add", "CHANGELOG.md", path]);
await git(f.directory, ["commit", "-m", "chore: release notes"]);
await expect(
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
).rejects.toMatchObject({
stderr: expect.stringContaining(
`Product changed after notes target; regenerate inventory: ${path}`,
),
});
},
);
@@ -78,7 +78,7 @@ const writeTaskFixture = async (
}),
);
await writeFile(
join(root, "package-lock.json"),
join(root, "npm-shrinkwrap.json"),
JSON.stringify({
name: "rea-static-task-cache-fixture",
version: "1.0.0",
@@ -288,3 +288,38 @@ it("invalidates checker task hashes for Node, ignore, and VitePress config chang
`${task} must ignore vitest.config.ts`,
).toBe(initial.get(task));
});
it.each(["compile", "typecheck"])(
"invalidates %s for JavaScript owners, declarations, and the authoritative lock",
async (task) => {
const root = await createTestTempDirectory("rea-mjs-cache-inputs-");
const cache = await createTestTempDirectory("rea-mjs-cache-store-");
await mkdir(join(root, "src/application"), { recursive: true });
await writeTaskFixture(root, [task], {
[task]: 'node -e "process.exit(0)"',
});
const owner = join(root, "src/application/PackageRunnerCommand.mjs");
const declaration = join(
root,
"src/application/PackageRunnerCommand.d.mts",
);
await writeFile(owner, "export const command = 1;\n");
await writeFile(declaration, "export declare const command: number;\n");
await workflowGit(root, ["init", "--quiet"]);
await workflowGit(root, ["add", "."]);
const initial = taskHash(root, task, cache);
expect(taskHash(root, task, cache)).toBe(initial);
await writeFile(owner, "export const command = 2;\n");
expect(taskHash(root, task, cache)).not.toBe(initial);
await writeFile(owner, "export const command = 1;\n");
await writeFile(declaration, "export declare const command: string;\n");
expect(taskHash(root, task, cache)).not.toBe(initial);
await writeFile(declaration, "export declare const command: number;\n");
expect(taskHash(root, task, cache)).toBe(initial);
const lockPath = join(root, "npm-shrinkwrap.json");
const lock = JSON.parse(await readFile(lockPath, "utf8"));
lock.packages["node_modules/fixture"] = { version: "1.0.0" };
await writeFile(lockPath, JSON.stringify(lock));
expect(taskHash(root, task, cache)).not.toBe(initial);
},
);
@@ -28,7 +28,7 @@ test("generates locked inventory and rejects stale notice metadata or preserved
optional: true,
};
await writeFile(
resolve(root, "package-lock.json"),
resolve(root, "npm-shrinkwrap.json"),
JSON.stringify({
packages: {
"": { name: "fixture", version: "1.0.0" },
+3 -1
View File
@@ -94,7 +94,9 @@ const statusCapability = (
describe("server and catalog identity", () => {
it("derives package and SDK versions from canonical package metadata", async () => {
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageLock = JSON.parse(await readFile("package-lock.json", "utf8"));
const packageLock = JSON.parse(
await readFile("npm-shrinkwrap.json", "utf8"),
);
expect(PACKAGE_METADATA).toMatchObject({
name: packageJson.name,
version: packageJson.version,
@@ -0,0 +1,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: "",
});
});
+253 -11
View File
@@ -1,13 +1,28 @@
import { readFile } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import { GenericJson } from "release-please/build/src/updaters/generic-json.js";
import { Version } from "release-please/build/src/version.js";
import { describe, expect, it } from "vitest";
import { SUPPORTED_NODE_VERSION_RANGE } from "../../src/domain/runtimeVersion.js";
import { MCP_STARTUP_POLICY } from "../../src/mcpStartupPolicy.js";
const readJson = async (path: string): Promise<unknown> =>
JSON.parse(await readFile(path, "utf8")) as unknown;
const versionedDocumentation = ["docs/installation.md"] as const;
// Each file lists a fragment of every example Release Please must keep
// current, so a lost marker is caught as well as a stale version.
const versionedDocumentation = [
{
path: "docs/installation.md",
examples: ["doctor --provider ghidra --json", '"command": "npx"'],
},
] as const;
const pluginManifests = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
] as const;
const pluginServerConfig = ".mcp.json";
describe("release configuration", () => {
it("keeps doctor and setup runtime support aligned with package engines", async () => {
@@ -24,7 +39,7 @@ describe("release configuration", () => {
packages: {
".": {
"extra-files": [
...versionedDocumentation.map((path) => ({
...versionedDocumentation.map(({ path }) => ({
type: "generic",
path,
})),
@@ -42,6 +57,35 @@ describe("release configuration", () => {
path: "server.json",
jsonpath: "$.packages[0].version",
},
...pluginManifests.flatMap((path) => [
{
type: "json",
path,
jsonpath: "$.version",
},
...(path.startsWith(".claude")
? [
{
type: "json",
path,
jsonpath: "$.mcpServers.rea.args[1]",
},
]
: []),
]),
{
type: "json",
path: pluginServerConfig,
jsonpath: "$.mcpServers.rea.args[1]",
},
...[
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
].map((path) => ({
type: "json",
path,
jsonpath: "$.plugins[0].source.version",
})),
],
},
},
@@ -49,18 +93,27 @@ describe("release configuration", () => {
});
it.each(versionedDocumentation)(
"marks the versioned package example in %s",
async (path) => {
"marks every versioned package example in $path",
async ({ path, examples }) => {
const content = await readFile(path, "utf8");
const packageJson = (await readJson("package.json")) as {
version: string;
};
const versionBlock =
/<!-- x-release-please-start-version -->[\s\S]*?<!-- x-release-please-end -->/u.exec(
content,
)?.[0];
const versionBlocks = [
...content.matchAll(
/<!-- x-release-please-start-version -->[\s\S]*?<!-- x-release-please-end -->/gu,
),
].map(([block]) => block);
expect(versionBlock).toContain(`rea-agents@${packageJson.version}`);
for (const example of examples)
expect(
versionBlocks.filter((block) => block.includes(example)),
example,
).toHaveLength(1);
for (const block of versionBlocks)
expect(block.match(/rea-agents@[^\s"`]+/gu)).toEqual([
`rea-agents@${packageJson.version}`,
]);
},
);
@@ -71,10 +124,13 @@ describe("release configuration", () => {
expect(await readJson(".release-please-manifest.json")).toEqual({
".": packageJson.version,
});
expect(await readJson("package-lock.json")).toMatchObject({
expect(await readJson("npm-shrinkwrap.json")).toMatchObject({
version: packageJson.version,
packages: { "": { version: packageJson.version } },
});
await expect(access("package-lock.json")).rejects.toMatchObject({
code: "ENOENT",
});
const changelog = await readFile("CHANGELOG.md", "utf8");
expect(/^## \[([^\]]+)\]/mu.exec(changelog)?.[1]).toBe(packageJson.version);
});
@@ -120,3 +176,189 @@ describe("release configuration", () => {
});
});
});
describe("plugin manifests", () => {
it("distributes both plugins with generated canonical skills from the exact npm release", async () => {
const pkg = (await readJson("package.json")) as {
version: string;
files: string[];
};
expect(pkg.files).toEqual(
expect.arrayContaining([
...pluginManifests,
pluginServerConfig,
"skills",
]),
);
for (const path of pluginManifests)
expect(await readJson(path)).toMatchObject({ skills: "./skills" });
for (const path of [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
])
expect(await readJson(path)).toMatchObject({
plugins: [
{
source: {
source: "npm",
package: "rea-agents",
version: pkg.version,
},
},
],
});
});
it.each(pluginManifests)(
"pins the plugin manifest %s and Codex's server config path",
async (path) => {
const packageJson = (await readJson("package.json")) as {
name: string;
version: string;
};
expect(await readJson(path)).toMatchObject({
version: packageJson.version,
...(path.startsWith(".codex")
? { mcpServers: `./${pluginServerConfig}` }
: {}),
});
},
);
});
describe("plugin release pins", () => {
it("advances every plugin pin through Release Please JSON extra-files", async () => {
const packageJson = (await readJson("package.json")) as {
version: string;
};
const currentVersion = Version.parse(packageJson.version);
const nextVersion = `${currentVersion.major}.${currentVersion.minor + 1}.0`;
const version = Version.parse(nextVersion);
const config = (await readJson("release-please-config.json")) as {
packages: {
".": {
"extra-files": Array<{
type: string;
path: string;
jsonpath?: string;
}>;
};
};
};
const updated = new Map<string, string>();
for (const extraFile of config.packages["."]["extra-files"]) {
if (extraFile.type !== "json" || extraFile.jsonpath === undefined)
continue;
const content =
updated.get(extraFile.path) ?? (await readFile(extraFile.path, "utf8"));
updated.set(
extraFile.path,
new GenericJson(extraFile.jsonpath, version).updateContent(content),
);
}
const updatedJson = new Map<string, unknown>(
[...updated].map(
([path, content]) => [path, JSON.parse(content) as unknown] as const,
),
);
expect(updatedJson.get(".claude-plugin/plugin.json")).toMatchObject({
version: nextVersion,
mcpServers: {
rea: {
args: [
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
nextVersion,
],
},
},
});
expect(updatedJson.get(".codex-plugin/plugin.json")).toMatchObject({
version: nextVersion,
});
expect(updatedJson.get(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
args: ["./scripts/plugin-mcp-launcher.mjs", nextVersion],
startup_timeout_sec: 30,
},
},
});
for (const path of [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
])
expect(updatedJson.get(path)).toMatchObject({
plugins: [
{
source: {
source: "npm",
package: "rea-agents",
version: nextVersion,
},
},
],
});
});
it("pins the plugin server launch from the shared MCP declaration", async () => {
const packageJson = (await readJson("package.json")) as {
version: string;
};
expect(await readJson(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
command: "node",
args: ["./scripts/plugin-mcp-launcher.mjs", packageJson.version],
cwd: ".",
},
},
});
});
it.each(pluginManifests)(
"stores %s in the form Release Please rewrites it to",
async (path) => {
// Release Please reserializes JSON extra-files, so any other layout
// would change formatting in every release PR.
const content = await readFile(path, "utf8");
expect(content).toBe(`${JSON.stringify(JSON.parse(content), null, 2)}
`);
},
);
it("declares the interface fields Codex plugin validation requires", async () => {
const manifest = (await readJson(".codex-plugin/plugin.json")) as {
interface: Record<string, unknown>;
};
for (const field of [
"displayName",
"shortDescription",
"longDescription",
"developerName",
"category",
])
expect(manifest.interface[field]).toEqual(expect.stringMatching(/\S/u));
expect(manifest.interface.capabilities).toEqual(
expect.arrayContaining([expect.stringMatching(/\S/u)]),
);
expect(manifest.interface.defaultPrompt).toEqual(
expect.arrayContaining([expect.stringMatching(/\S/u)]),
);
});
it("carries the owned Codex startup timeout in the companion server entry", async () => {
expect(await readJson(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: {
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
},
},
});
});
});
+2 -1
View File
@@ -1,10 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true,
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.mjs"],
"exclude": [
"src/**/*.fixture.ts",
"src/**/*.test.ts",
+19 -19
View File
@@ -9,12 +9,12 @@
"dependsOn": ["metadata:check"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/check-dependency-install.mjs",
"scripts/clean-build-output.mjs",
"scripts/bundle-runtime.mjs",
"scripts/generate-package-metadata.mjs",
"src/**/*.ts",
"src/**/*.{ts,mjs,mts}",
"!src/**/*.test.ts",
"!src/**/*.fixture.ts",
"tsconfig.json",
@@ -25,8 +25,8 @@
"typecheck": {
"inputs": [
"package.json",
"package-lock.json",
"src/**/*.ts",
"npm-shrinkwrap.json",
"src/**/*.{ts,mjs,mts}",
"tests/**/*.ts",
"tsconfig.json",
"tsconfig.build.json",
@@ -39,7 +39,7 @@
"metadata:check": {
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-package-metadata.mjs",
"scripts/lib/generated-file.mjs",
".agents/skills/reverse-engineer-anything/SKILL.md",
@@ -52,7 +52,7 @@
".gitignore",
".oxlintrc.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
@@ -73,7 +73,7 @@
".gitignore",
".oxlintrc.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
@@ -94,9 +94,9 @@
".gitignore",
"knip.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.ts",
"src/**/*.{ts,mjs,mts}",
"tests/**/*.ts",
"tsconfig.json",
"tsconfig.build.json",
@@ -109,7 +109,7 @@
".gitignore",
".jscpd.json",
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/ci/static-check-report.mjs",
"src/**",
"tests/**"
@@ -120,7 +120,7 @@
"inputs": [
"bridge/**/*.{java,py}",
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.ts",
"src/**/*.{ts,mjs,mts}",
"tests/**/*.ts"
]
},
@@ -141,7 +141,7 @@
],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-*.mjs",
"scripts/check-doc-facts.mjs",
"scripts/lib/*.mjs",
@@ -172,7 +172,7 @@
"dependsOn": ["compile"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-mcp-tool-catalog.mjs",
"scripts/lib/generated-file.mjs"
],
@@ -182,7 +182,7 @@
"dependsOn": ["compile"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-skill-metadata.mjs",
"scripts/lib/generated-file.mjs",
".agents/skills/reverse-engineer-anything/**"
@@ -193,7 +193,7 @@
"dependsOn": ["compile"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-product-catalog.mjs",
"scripts/lib/**/*.mjs",
".oxfmtrc.json"
@@ -204,7 +204,7 @@
"dependsOn": ["artifacts:skills"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-completion-ledger.mjs",
"scripts/lib/generated-file.mjs",
"scripts/verify/managed/**",
@@ -219,7 +219,7 @@
"dependsOn": ["compile"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-error-schema.mjs",
"scripts/lib/generated-file.mjs",
"scripts/lib/format-generated-file.mjs",
@@ -231,7 +231,7 @@
"dependsOn": ["artifacts:mcp-catalog"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-mcp-tool-catalog.mjs",
"scripts/lib/generated-file.mjs",
{
@@ -244,7 +244,7 @@
"dependsOn": ["artifacts:managed-evidence"],
"inputs": [
"package.json",
"package-lock.json",
"npm-shrinkwrap.json",
"scripts/generate-completion-ledger.mjs",
"scripts/lib/generated-file.mjs",
"scripts/verify/managed/**",