feat(plugins): make Claude Code and Codex installs self-contained (#7514)

Ship Claude Code and Codex from one release-pinned npm artifact with bundled runtime dependencies, extraction-only MCP qualification, plugin-aware recovery, and synchronized release metadata.

Integrates #2815 and the original proposal #1977. The original commits remain available on the PR branch; retain their author and assistant credit in this squash.

Validation: required CI passed, real Codex/Claude loading verified, and reviewed packaging/recovery findings addressed.

Co-authored-by: GamerDubz <themastergamerplays24.7@gmail.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: OpenAI Codex <openai-codex@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gensokyo-dev
2026-10-28 01:13:17 +08:00
committed by GitHub
parent 269630f4e1
commit 0cd7ef456b
29 changed files with 1591 additions and 132 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "rea",
"interface": {
"displayName": "REA"
},
"plugins": [
{
"name": "rea",
"source": {
"source": "npm",
"package": "rea-agents",
"version": "6.8.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
@@ -37,37 +40,18 @@ repeat or known not to have started. If it may already have changed the
target, or it fails again, tell the user and ask them to restart the MCP
client. If REA could not start its connection, retry once, then tell the user
and ask them to restart the MCP client. Do not retry a startup failure that
returns the same unchanged result. Run `rea doctor` when REA could not start
returns the same unchanged result. For plugin installs, follow
[plugin recovery](references/connection-and-recovery.md#rea-installed-as-a-client-plugin).
For setup-owned registrations, run `rea doctor` when REA could not start
or tools stay missing after that restart. For `cleanup_incomplete`, a retryable
capacity limit, or `configuration_invalid`, follow the recovery guide's ladder
before asking for a restart. Read that guide before a second repair attempt.
1. Diagnose without changing REA or client configuration. `npx` may download
the package and write to npm's cache. For Codex, run
`npx -y rea-agents@latest doctor --client codex --json`. Substitute the current
supported client: `claude_code`, `claude_desktop`, `codex`, `cursor`,
`gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`,
`commandcode`, `vscode`, `grok_build`, `omp`, or `grok_bot`. `grok_bot` has no local
file registration: doctor reports the Grok Bot chat step, and setup does not
write `mcp.json`. If the client is unknown, use `doctor --json` and
inspect its registration results before choosing a setup scope.
2. Distinguish the reason. Missing, malformed, or stale registration needs a
scoped configuration repair. An aligned registration with no tools in the
active session needs a restart/reconnection; doctor cannot prove that the
current agent has connected. A missing provider affects only tasks requiring
that provider: static JavaScript inspection needs neither Hopper nor Ghidra,
and Android inspection has separate bring-your-own JADX/Java prerequisites.
3. For a configuration repair, prepare the read-only plan:
`npx -y rea-agents@latest setup --client codex --dry-run --json`.
Use the current client's ID, show its exact proposed paths, backups, and
changes, and obtain approval before setup writes configuration or installs
Hopper. Setup normally installs the matching bundled skill too; include that
replacement in the reviewed plan. After approval, apply the same scope with
`npx -y rea-agents@latest setup --client codex --yes`. Add `--install-hopper`
only if that separate installation was needed and explicitly approved.
4. Restart/reconnect the affected agent. Verify that REA tools actually appear
in the session, then resume the original investigation. If they remain
absent, inspect the client's MCP launch error rather than repeating setup.
Choose the installation owner before repairing registration. Plugin installs use
[plugin recovery](references/connection-and-recovery.md#rea-installed-as-a-client-plugin);
only setup-owned registrations use the guide's
[registration repair plan](references/connection-and-recovery.md#repair-registration-when-needed).
A plugin-only doctor result of `missing` does not call for `rea setup`.
REA setup never installs or upgrades Node.js, npm, Homebrew, Java, Ghidra, IDA,
JADX, Kuna, Binwalk, or Unblob. Use existing prerequisites; do not install unrelated
@@ -86,7 +70,7 @@ returns the Evidence record directly. This fallback does not establish that
MCP is configured. Native CLI tasks still require their selected engine.
Prefer an existing `rea` CLI. When none is installed and REA package acquisition
is within the authorized scope, use `npx -y rea-agents@latest` in its place.
Configuration repair starts with a read-only setup plan; show paths, backups and
For setup-owned registration, configuration repair starts with a read-only setup plan; show paths, backups and
skill replacement before applying authorized changes. Preserve existing approval
and target-execution boundaries.
@@ -17,7 +17,7 @@ does not establish which server an approval wildcard covers. See
| 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 connection dropped or REA could not start | If the connection drops, retry that call once only when it is safe to repeat or known not to have started. If it may already have changed the target, or the same failure returns, tell the user REA's connection failed and ask them to restart the MCP client. If REA could not start its connection, retry once, then tell the user and ask them to restart the MCP client. Do not retry a startup failure that returns the same unchanged result. Run `rea doctor` when REA could not start or tools stay missing after restart. Doctor cannot restore a live connection by itself. |
@@ -116,6 +116,32 @@ is not a recommendation to change it. Do not invent a setting. Host memory and
- `invalid_request`: correct the reported fields from the current schema. Do
not repeat the unchanged call.
## 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 plugin starts its bundled runtime; a missing build or version mismatch
requires a reinstall, or a fresh build when developing from source.
- 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.8.0"
},
"category": "development"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "rea",
"version": "6.8.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.8.0"
]
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"mcpServers": {
"rea": {
"command": "node",
"args": [
"./scripts/plugin-mcp-launcher.mjs",
"6.8.0"
],
"cwd": ".",
"startup_timeout_sec": 30
}
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "rea",
"version": "6.8.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": "./.codex-plugin/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."
]
}
}
+4 -1
View File
@@ -10,7 +10,10 @@
"node_modules/**",
"CHANGELOG.md",
"package-lock.json",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
"_reference/**",
"third_party/**"
"third_party/**",
".codex-plugin/mcp.json"
]
}
+1 -1
View File
@@ -88,7 +88,7 @@ configuration. Restart your agent afterward.
Setup supports Claude Code, Codex, Cursor, Gemini CLI, Grok Build and
[other agents](docs/installation.md#supported-agents). See
[installation and setup](docs/installation.md) for provider configuration and
manual MCP registration.
manual MCP registration and Claude Code/Codex plugin installation.
MCP uses compact input schemas by default to reduce context overhead. Set
`REA_MCP_INPUT_SCHEMA_PROFILE=full` in your MCP registration when you need the
+89 -7
View File
@@ -3,13 +3,14 @@
Run REA, connect your selected agents, and check the tools needed for your task.
Setup previews configuration changes and asks for approval before applying them.
| Task | Section |
| --------------------------------- | --------------------------------------------------------------------------------------------------- |
| Connect an agent | [Start setup](#start-setup), then [review changes](#review-setup-changes) |
| Diagnose one task | [Scoped readiness checks](#check-readiness-for-your-task) |
| Configure an analysis engine | [Hopper](#hopper), [Ghidra](#ghidra), the [IDA guide](ida-provider.md), or [Kuna](kuna-provider.md) |
| Update or remove REA | [Maintenance](#diagnose-update-and-remove) |
| Resolve a client-specific problem | [Client configuration](client-configuration.md) |
| Task | Section |
| ---------------------------------- | --------------------------------------------------------------------------------------------------- |
| Connect an agent | [Start setup](#start-setup), then [review changes](#review-setup-changes) |
| Install a Claude Code/Codex plugin | [Agent plugins and marketplaces](#agent-plugins-and-marketplaces) |
| Diagnose one task | [Scoped readiness checks](#check-readiness-for-your-task) |
| Configure an analysis engine | [Hopper](#hopper), [Ghidra](#ghidra), the [IDA guide](ida-provider.md), or [Kuna](kuna-provider.md) |
| Update or remove REA | [Maintenance](#diagnose-update-and-remove) |
| Resolve a client-specific problem | [Client configuration](client-configuration.md) |
## Start setup
@@ -1552,6 +1553,87 @@ 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 bootstrap verifies the package version and runs its bundled runtime in
the same process, preserving stdio and REA's provider/worker cleanup. It does
not start another package runner or download a different runtime. 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.
The qualified release archive contains a runtime-only manifest and
`npm-shrinkwrap.json` plus bundled runtime dependencies, including PTY packages
for each supported OS and CPU; development dependencies and development-only overrides
stay in the source manifest and lockfile. Both clients receive this same archive.
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.
A source plugin uses the locally built runtime and generated skill; rebuild after
source changes. Missing builds and mismatched version pins fail with an actionable
error. The authored `.agents/skills/` tree is not the plugin's shipped skill layout.
Windows source workflows that need native controls still require the
[Windows source-checkout build](https://github.com/morluto/rea/blob/main/native/windows/README.md#build).
In Claude Code:
```text
/plugin marketplace add morluto/rea
/plugin install rea@rea
```
In Codex CLI, add the marketplace and install REA:
```bash
codex plugin marketplace add morluto/rea
codex plugin add rea@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.8.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
+19 -1
View File
@@ -349,7 +349,10 @@ 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
`server.json`, both plugin manifests (`.claude-plugin/plugin.json` and
`.codex-plugin/plugin.json`), the Codex server declaration (`.codex-plugin/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:
@@ -383,3 +386,18 @@ tag. Do not dispatch a fresh publish phase to repair an already-created
release: Release Please will not create the same release again. Do not move
the tag, delete the release, or unpublish npm as a retry. A defective public
package requires a reviewed correction and a new version.
### Plugin artifact metadata
Package verification selects files with npm, then packs an owned staging directory.
The release manifest omits development dependencies and development-only overrides;
its generated `npm-shrinkwrap.json` preserves the runtime dependency resolutions
and integrity values from `package-lock.json`. Runtime or unresolved overrides
fail packaging rather than being dropped. Source metadata is never rewritten.
The archive bundles production dependencies because Codex fetches npm plugins
without installing dependencies. The producer includes all locked PTY platform
archives after checking their integrity, without re-resolving the runtime tree.
Optional Frida stays outside the bundle so ordinary npm installation can retain
its host-specific installer. This also lets Claude install locked dependencies under its plugin policy while
retaining development security overrides. Both plugin marketplaces select the
same qualified npm artifact, which the release workflow publishes unchanged.
+6 -1
View File
@@ -99,7 +99,12 @@
"bridge/pwndbg/core_context.py",
"third_party/pwndbg/README.md",
"third_party/pwndbg/LICENSE.md",
"licenses"
"licenses",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".codex-plugin/mcp.json",
"scripts/plugin-mcp-launcher.mjs",
"npm-shrinkwrap.json"
],
"scripts": {
"release:notes": "node scripts/release-notes.mjs",
+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": ".codex-plugin/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"
}
]
}
+35
View File
@@ -0,0 +1,35 @@
interface PackageManifest {
name: string;
version: string;
devDependencies?: Record<string, string>;
dependencies?: Record<string, string>;
overrides?: Record<string, unknown>;
// npm manifests are extensible; preserve metadata outside this projection.
[key: string]: unknown;
}
interface LockedPackage {
version?: string;
dev?: boolean;
// Keep unmodified integrity, resolution, platform, and other lock-entry facts.
[key: string]: unknown;
}
interface PackageLock {
name: string;
version: string;
lockfileVersion: number;
packages: Record<string, LockedPackage>;
// Preserve other npm lock metadata rather than dropping unfamiliar fields.
[key: string]: unknown;
}
export function pluginPackageMetadata<Manifest extends PackageManifest>(
manifest: Manifest,
lock: PackageLock,
): {
packageJson: Omit<Manifest, "devDependencies" | "overrides"> & {
bundleDependencies: string[];
};
shrinkwrap: PackageLock;
};
+41
View File
@@ -0,0 +1,41 @@
/** Project the development manifest and lock into the shared runtime artifact. */
export function pluginPackageMetadata(manifest, lock) {
if (
lock.lockfileVersion !== 3 ||
lock.name !== manifest.name ||
lock.version !== manifest.version ||
lock.packages?.[""]?.version !== manifest.version
)
throw new Error("Plugin packaging requires the matching v3 package lock");
// Claude skips dependency installation when a published manifest has
// overrides. Preserve development security overrides in the source owner,
// and reject any future runtime override rather than silently discarding it.
for (const name of Object.keys(manifest.overrides ?? {})) {
const matches = Object.entries(lock.packages).filter(([path]) =>
path.endsWith(`node_modules/${name}`),
);
if (matches.length === 0 || matches.some(([, entry]) => entry.dev !== true))
throw new Error(`Cannot omit a runtime or unresolved override: ${name}`);
}
const packageJson = { ...manifest };
delete packageJson.devDependencies;
delete packageJson.overrides;
// Codex's npm source fetches the archive without installing dependencies.
// Keep optional Frida outside the bundle so ordinary npm installs can still
// run its host-specific installer. PTY binaries are transitive optionals of
// a runtime dependency and are included for every supported platform by the
// archive producer.
packageJson.bundleDependencies = Object.keys(manifest.dependencies ?? {});
const root = { ...lock.packages[""] };
delete root.devDependencies;
const shrinkwrap = {
...lock,
packages: Object.fromEntries(
Object.entries(lock.packages).filter(([, entry]) => entry.dev !== true),
),
};
shrinkwrap.packages[""] = root;
return { packageJson, shrinkwrap };
}
+59
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("./.codex-plugin/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(
@@ -189,6 +220,26 @@ 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,
".codex-plugin/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 +261,14 @@ async function validateCandidate(git, sha, version, commits) {
);
}
}
// The launcher checks the scalar pin against the runtime in its own package.
for (const [location, launched] of launches) {
if (launched !== version) {
throw new Error(
`${location}@${sha} launches version ${launched ?? "no version"}; release branch requires ${version}.`,
);
}
}
const notes = releaseNotes(
await git(["show", `${sha}:CHANGELOG.md`]),
version,
+64 -1
View File
@@ -369,6 +369,60 @@ export function verifyReleaseNotes(raw, text, body) {
};
}
const VERSION_ONLY_PLUGIN_METADATA = new Set([
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".codex-plugin/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);
@@ -437,7 +491,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(", ")}`,
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
import { access, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
// Release Please owns the scalar pin in each client declaration. The runtime
// and skill come from the same installed artifact, without another npx child.
const [version, ...extra] = process.argv.slice(2);
try {
if (
extra.length !== 0 ||
!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/u.test(version ?? "")
)
throw new Error("requires one exact rea-agents@VERSION pin");
const bundledPackage = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8"),
);
if (
bundledPackage.name !== "rea-agents" ||
bundledPackage.version !== version
)
throw new Error(
"version pin does not match its installed rea-agents package; reinstall the plugin",
);
await access(new URL("../dist/mcp/main.js", import.meta.url)).catch(
(cause) => {
if (cause?.code !== "ENOENT") throw cause;
throw new Error(
"runtime is missing; reinstall the plugin, or run npm ci && npm run build:cached in a source checkout",
{ cause },
);
},
);
} catch (cause) {
process.stderr.write(`REA plugin ${cause.message}.\n`);
process.exitCode = 1;
}
if (process.exitCode !== 1) {
process.argv = [
process.execPath,
fileURLToPath(new URL("./rea.mjs", import.meta.url)),
"mcp",
];
await import("./rea.mjs");
}
+11 -6
View File
@@ -5,11 +5,12 @@ import {
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import assert from "node:assert/strict";
import { join } from "node:path";
import { readFile, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
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 { resolveMcpStartupBudgets } from "../dist/config/mcpStartupBudgets.js";
import { parseEvidence } from "../dist/domain/evidence.js";
import * as prompts from "./verify-package-prompts.mjs";
@@ -239,7 +240,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({
commands,
cli,
@@ -249,7 +250,7 @@ export async function verifyPackageMcp({
}) {
const diagnosed = JSON.parse(
(
await commands.exec(cli, ["mcp", "doctor", "--json"], {
await commands.exec(process.execPath, [cli, "mcp", "doctor", "--json"], {
env: environment,
timeout: resolveMcpStartupBudgets(environment).doctor.milliseconds,
})
@@ -261,9 +262,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,
},
+200 -10
View File
@@ -1,11 +1,12 @@
import { importVerifierSource } from "./lib/verifier-source-loader.mjs";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { FULL_CATALOG_IDENTITY } from "../dist/catalogIdentity.js";
import { parseTarListing } from "./lib/verify-package-listing.mjs";
import { verifyPackedBridge } from "./verify-packed-bridge.mjs";
import { pluginPackageMetadata } from "./lib/plugin-package.mjs";
const { parseSkillManifestMetadata } = await importVerifierSource(
new URL("../src/domain/skillManifest.ts", import.meta.url).href,
@@ -16,13 +17,108 @@ const { parseSkillManifestMetadata } = await importVerifierSource(
export async function verifyPackagePack({ root, workspace, commands }) {
const packDirectory = join(workspace, "pack");
await mkdir(packDirectory);
const stagingDirectory = join(workspace, "plugin-package");
await mkdir(stagingDirectory);
const [{ files }] = JSON.parse(
(await commands.execNpm(["pack", "--dry-run", "--json"], { cwd: root }))
.stdout,
);
// Reuse npm's file selection, then project metadata in an owned directory.
// Source files and the development lock are never rewritten by packaging.
for (const { path } of files) {
const source = resolve(root, path);
const contained = relative(root, source);
if (
contained === ".." ||
contained.startsWith(`..${sep}`) ||
isAbsolute(contained)
)
throw new Error(`npm selected a path outside the package: ${path}`);
const destination = join(stagingDirectory, contained);
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination);
}
const { packageJson, shrinkwrap } = pluginPackageMetadata(
JSON.parse(await readFile(join(root, "package.json"), "utf8")),
JSON.parse(await readFile(join(root, "package-lock.json"), "utf8")),
);
await writeFile(
join(stagingDirectory, "package.json"),
`${JSON.stringify(packageJson, null, 2)}\n`,
);
await writeFile(
join(stagingDirectory, "npm-shrinkwrap.json"),
`${JSON.stringify(shrinkwrap, null, 2)}\n`,
);
// Materialize exactly the locked runtime tree without executing installers.
await commands.execNpm(
["ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: stagingDirectory },
);
if (packageJson.dependencies?.["@lydell/node-pty"] !== undefined) {
// npm ci filters foreign-platform optional packages. Include the exact locked
// PTY archives as well, without re-resolving the installed dependency tree.
const ptyManifest = JSON.parse(
await readFile(
join(stagingDirectory, "node_modules/@lydell/node-pty/package.json"),
"utf8",
),
);
const platformDirectory = join(workspace, "plugin-platform-packages");
await mkdir(platformDirectory);
for (const dependency of Object.keys(ptyManifest.optionalDependencies)) {
const destination = join(stagingDirectory, "node_modules", dependency);
if (
await access(destination).then(
() => true,
(cause) => {
if (cause.code !== "ENOENT") throw cause;
return false;
},
)
)
continue;
const locked = shrinkwrap.packages[`node_modules/${dependency}`];
if (locked?.version === undefined || locked.integrity === undefined)
throw new Error(
`Plugin platform dependency is not locked: ${dependency}`,
);
const [archive] = JSON.parse(
(
await commands.execNpm(
[
"pack",
"--ignore-scripts",
"--json",
"--pack-destination",
platformDirectory,
`${dependency}@${locked.version}`,
],
{ cwd: platformDirectory },
)
).stdout,
);
if (archive.integrity !== locked.integrity)
throw new Error(
`Plugin platform archive differs from the lock: ${dependency}`,
);
await mkdir(destination, { recursive: true });
await commands.exec("tar", [
"-xzf",
join(platformDirectory, archive.filename),
"-C",
destination,
"--strip-components=1",
]);
}
}
const tarball = join(
packDirectory,
(
await commands.execNpm(
["pack", "--silent", "--pack-destination", packDirectory],
{
cwd: root,
cwd: stagingDirectory,
},
)
).stdout.trim(),
@@ -34,6 +130,54 @@ export async function verifyPackagePack({ root, workspace, commands }) {
(await commands.exec("tar", ["-xOf", tarball, "package/package.json"]))
.stdout,
);
if (packedManifest.mcpName !== "io.github.morluto/rea")
throw new Error("package did not retain its MCP Registry ownership marker");
const packedLock = JSON.parse(
(
await commands.exec("tar", [
"-xOf",
tarball,
"package/npm-shrinkwrap.json",
])
).stdout,
);
if (
packedManifest.overrides !== undefined ||
packedManifest.devDependencies !== 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",
);
for (const dependency of Object.keys(packedManifest.dependencies))
if (
!packedFiles.includes(`package/node_modules/${dependency}/package.json`)
)
throw new Error(
`Plugin runtime dependency is not bundled: ${dependency}`,
);
const pty = JSON.parse(
(
await commands.exec("tar", [
"-xOf",
tarball,
"package/node_modules/@lydell/node-pty/package.json",
])
).stdout,
);
for (const dependency of Object.keys(pty.optionalDependencies))
if (
!packedFiles.includes(`package/node_modules/${dependency}/package.json`)
)
throw new Error(
`Plugin PTY platform dependency is missing: ${dependency}`,
);
if (
packedManifest.scripts?.postinstall !== undefined ||
packedManifest.dependencies?.["node-pty"] !== undefined ||
@@ -45,14 +189,16 @@ export async function verifyPackagePack({ root, workspace, commands }) {
packedManifest.bin?.["rea-agents"] !== "scripts/rea.mjs"
)
throw new Error("package did not expose both rea command entry points");
if (packedManifest.mcpName !== "io.github.morluto/rea")
throw new Error("package did not retain its MCP Registry ownership marker");
if (!packedFiles.includes("package/scripts/lib/cli-process-environment.mjs"))
throw new Error("package omitted the CLI process environment owner");
if (packedFiles.some((path) => path.startsWith("package/.agents/")))
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 = (
@@ -71,13 +217,57 @@ export async function verifyPackagePack({ root, workspace, commands }) {
throw new Error(
"packaged skill metadata did not match the catalog shipped in the package",
);
if (
packedFiles.some(
(path) => path.includes("__pycache__") || path.endsWith(".pyc"),
for (const path of [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
]) {
const manifest = JSON.parse(
(await commands.exec("tar", ["-xOf", tarball, `package/${path}`])).stdout,
);
const serverConfig = path.startsWith(".codex")
? JSON.parse(
(
await commands.exec("tar", [
"-xOf",
tarball,
"package/.codex-plugin/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 !== "./.codex-plugin/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("package contained generated Python bytecode");
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("Plugin metadata must not register a project MCP server");
if (!packedFiles.includes("package/.codex-plugin/mcp.json"))
throw new Error("Packaged Codex MCP server declaration is missing");
if (!packedFiles.includes("package/scripts/plugin-mcp-launcher.mjs"))
throw new Error("Packaged plugin launcher is missing");
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",
);
await verifyPackedBridge({ tarball, packedFiles, commands });
return { tarball };
}
+8 -2
View File
@@ -220,10 +220,15 @@ await withVerifierCleanup(async () => {
root,
}),
);
await runStage("MCP tools and evidence", () =>
const extractedDirectory = join(workspace, "extracted-plugin");
await mkdir(extractedDirectory);
await commands.exec("tar", ["-xzf", tarball, "-C", extractedDirectory]);
// Codex extracts npm plugins without installing dependencies. Run the real
// catalog/Evidence workflow from that isolated tree so npm cannot repair it.
await runStage("extracted plugin MCP tools and evidence", () =>
verifyPackageMcp({
commands,
cli,
cli: join(extractedDirectory, "package", "scripts", "rea.mjs"),
environment: environmentData.environment,
evidenceRoot,
artifactArchive,
@@ -258,6 +263,7 @@ await withVerifierCleanup(async () => {
promptCompletion: true,
promptCompletionLifecycle: true,
evidenceMcp: true,
extractedPluginMcp: true,
targetFree: true,
targetLifecycle: true,
boundedRegexBridge: true,
+7
View File
@@ -5,6 +5,13 @@ const packedCutterBridge = "package/bridge/cutter/rea_bridge.py";
/** Check shipped bridge files; real-provider lanes verify runtime behavior. */
export async function verifyPackedBridge({ tarball, packedFiles, commands }) {
if (
packedFiles.some(
(path) => path.includes("__pycache__") || path.endsWith(".pyc"),
)
) {
throw new Error("package contained generated Python bytecode");
}
for (const path of [
packedHopperBridge,
packedCutterBridge,
@@ -0,0 +1,83 @@
import { expect, it } from "vitest";
import { pluginPackageMetadata } from "../../../scripts/lib/plugin-package.mjs";
const fixture = () => ({
manifest: {
name: "rea-agents",
version: "6.8.0",
dependencies: { runtime: "1.0.0" },
devDependencies: { builder: "1.0.0" },
overrides: { builder: { nested: "2.0.0" } },
},
lock: {
name: "rea-agents",
version: "6.8.0",
lockfileVersion: 3,
packages: {
"": {
name: "rea-agents",
version: "6.8.0",
dependencies: { runtime: "1.0.0" },
devDependencies: { builder: "1.0.0" },
},
"node_modules/runtime": { version: "1.0.0", integrity: "source digest" },
"node_modules/builder": { version: "1.0.0", dev: true },
"node_modules/builder/node_modules/nested": {
version: "2.0.0",
dev: true,
},
"node_modules/optional": {
version: "1.0.0",
devOptional: true,
optional: true,
},
},
},
});
it("preserves locked runtime facts and optional dependencies without mutating source metadata", () => {
const source = fixture();
const before = structuredClone(source);
const packed = pluginPackageMetadata(source.manifest, source.lock);
expect(source).toEqual(before);
expect(packed.packageJson).toEqual({
name: "rea-agents",
version: "6.8.0",
dependencies: { runtime: "1.0.0" },
bundleDependencies: ["runtime"],
});
expect(packed.shrinkwrap.packages).toEqual({
"": {
name: "rea-agents",
version: "6.8.0",
dependencies: { runtime: "1.0.0" },
},
"node_modules/runtime": source.lock.packages["node_modules/runtime"],
"node_modules/optional": source.lock.packages["node_modules/optional"],
});
});
it("refuses to discard a security override when the package is also used at runtime", () => {
const { manifest, lock } = fixture();
lock.packages["node_modules/builder"].dev = false;
expect(() => pluginPackageMetadata(manifest, lock)).toThrow(
"runtime or unresolved override: builder",
);
});
it("fails when an override cannot be attributed to the locked development tree", () => {
const { manifest, lock } = fixture();
delete (lock.packages as Record<string, unknown>)["node_modules/builder"];
expect(() => pluginPackageMetadata(manifest, lock)).toThrow(
"runtime or unresolved override: builder",
);
});
it("refuses a stale lock before producing a release artifact", () => {
const { manifest, lock } = fixture();
lock.packages[""].version = "6.7.0";
expect(() => pluginPackageMetadata(manifest, lock)).toThrow(
"matching v3 package lock",
);
});
@@ -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,7 +53,15 @@ 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: "./.codex-plugin/mcp.json",
},
".codex-plugin/mcp.json": codexServerConfig(version),
"package.json": { name: "rea-agents", version },
"package-lock.json": { version, packages: { "": { version } } },
".release-please-manifest.json": { ".": version },
@@ -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: "./.codex-plugin/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: ".codex-plugin/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 = ".codex-plugin/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: "./.codex-plugin/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"]);
@@ -385,9 +456,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"]);
@@ -408,6 +500,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, "package-lock.json"),
'{"lockfileVersion":3}',
);
await git(f.directory, [
"add",
"CHANGELOG.md",
"package-lock.json",
"package-lock.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 });
@@ -553,3 +713,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}`,
),
});
},
);
@@ -29,7 +29,18 @@ it("keeps npm pack output in the owned workspace when package validation fails",
version: "6.0.0",
mcpName: "invalid-owner",
bin: { rea: "scripts/rea.mjs", "rea-agents": "scripts/rea.mjs" },
dependencies: { "@lydell/node-pty": "1.1.0" },
dependencies: {},
}),
),
writeFile(
join(packageRoot, "package-lock.json"),
JSON.stringify({
name: "rea-agents",
version: "6.0.0",
lockfileVersion: 3,
packages: {
"": { name: "rea-agents", version: "6.0.0", dependencies: {} },
},
}),
),
writeFile(archivePath, sentinel),
@@ -1,97 +1,77 @@
import { execFile } from "node:child_process";
import { join, resolve } from "node:path";
import { resolve } from "node:path";
import { promisify } from "node:util";
import { pathToFileURL } from "node:url";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { GHIDRA_BRIDGE_SOURCES } from "../../../src/ghidra/GhidraBridgeSources.js";
const execFileAsync = promisify(execFile);
const packerUrl = pathToFileURL(
resolve("scripts/verify-package-pack.mjs"),
const listingUrl = pathToFileURL(
resolve("scripts/lib/verify-package-listing.mjs"),
).href;
const bridgeUrl = pathToFileURL(
resolve("scripts/verify-packed-bridge.mjs"),
).href;
const catalogUrl = pathToFileURL(resolve("dist/catalogIdentity.js")).href;
const bridge = "package/bridge/hopper_bridge.py";
const members = [
bridge,
"package/bridge/cutter/rea_bridge.py",
"package/bridge/ghidra/ReaGhidraBridge.java",
"package/bridge/ghidra/ReaGhidraBundleActivator.java",
"package/bridge/ghidra/ReaGhidraExtensions.java",
"package/bridge/ghidra/ReaGhidraPrepareCom.java",
"package/bridge/ghidra/ReaGhidraPrepareRaw.java",
"package/bridge/ghidra/ReaGhidraPrepareDol.java",
"package/bridge/ghidra/ReaGhidraApplySeeds.java",
"package/bridge/ghidra/ReaGhidraAnalysisOptions.java",
"package/bridge/ghidra/ReaGhidraNoReturnFix.java",
"package/bridge/ghidra/ReaGhidraMipsSoftFloat.java",
"package/scripts/lib/cli-process-environment.mjs",
...Object.values(GHIDRA_BRIDGE_SOURCES).map(
(name) => `package/bridge/ghidra/${name}`,
),
];
async function verifyListing(listing: string) {
const workspace = await createTestTempDirectory("rea-tar-listing-");
const { stdout } = await execFileAsync(process.execPath, [
"--input-type=module",
"--eval",
`import { FULL_CATALOG_IDENTITY } from ${JSON.stringify(catalogUrl)};
`import { parseTarListing } from ${JSON.stringify(listingUrl)};
import { verifyPackedBridge } from ${JSON.stringify(bridgeUrl)};
let bridgeParsed = false;
const exec = async (command, args) => {
let stdout;
if ((command === "npm" && args[0] === "pack") || (command === process.execPath && args[1] === "pack")) stdout = "fixture.tgz\\n";
else if (command === "tar" && args[0] === "-tf") stdout = ${JSON.stringify(listing)};
else if (command === "tar" && args[0] === "-xOf" && args[2] === "package/package.json")
stdout = JSON.stringify({ bin: { rea: "scripts/rea.mjs", "rea-agents": "scripts/rea.mjs" }, mcpName: "io.github.morluto/rea", dependencies: { "@lydell/node-pty": "1.1.0" } });
else if (command === "tar" && args[0] === "-xOf" && args[2] === "package/skills/reverse-engineer-anything/SKILL.md")
stdout = '---\\nname: reverse-engineer-anything\\nmetadata:\\n version: "fixture"\\n tool_count: ' + FULL_CATALOG_IDENTITY.counts.mcp_tools + "\\n---\\n";
else if (command === "tar" && args[0] === "-xf") stdout = "";
else if (command === "python3") { bridgeParsed = true; stdout = ""; }
else throw new Error("Unexpected command: " + command + " " + JSON.stringify(args));
return { stdout, stderr: "" };
};
const commands = { exec, execNpm: (args) => exec("npm", args) };
const { verifyPackagePack } = await import(${JSON.stringify(packerUrl)});
const commands = { exec: async (command) => {
if (command !== "python3") throw new Error("Unexpected command: " + command);
bridgeParsed = true;
return { stdout: "", stderr: "" };
} };
try {
const result = await verifyPackagePack({ root: process.cwd(), workspace: ${JSON.stringify(workspace)}, commands });
process.stdout.write(JSON.stringify({ ...result, bridgeParsed }));
await verifyPackedBridge({ tarball: "fixture.tgz", packedFiles: parseTarListing(${JSON.stringify(listing)}), commands });
process.stdout.write(JSON.stringify({ bridgeParsed }));
} catch (cause) {
process.stdout.write(JSON.stringify({ error: cause.message, bridgeParsed }));
}`,
]);
return { result: JSON.parse(stdout), workspace };
return JSON.parse(stdout);
}
it("preserves a literal trailing CR in an LF-listed member", async () => {
const listing =
members.map((path) => (path === bridge ? `${path}\r` : path)).join("\n") +
"\n";
const { result } = await verifyListing(listing);
expect(result).toEqual({
expect(await verifyListing(listing)).toEqual({
error: `package omitted ${bridge}`,
bridgeParsed: false,
});
});
it("accepts exact bridge entries from a CRLF tar listing and still rejects generated bytecode", async () => {
const ending = "\r\n";
const { result, workspace } = await verifyListing(
members.join(ending) + ending,
);
expect(result).toEqual({
tarball: join(workspace, "pack/fixture.tgz"),
bridgeParsed: true,
});
it.each(["\r\n", "\n"])(
"accepts exact bridge identities with %j line endings",
async (ending) => {
expect(await verifyListing(members.join(ending) + ending)).toEqual({
bridgeParsed: true,
});
},
);
const rejected = await verifyListing(
[...members, "package/bridge/stale.pyc"].join(ending) + ending,
);
expect(rejected.result).toEqual({
error: "package contained generated Python bytecode",
bridgeParsed: false,
});
});
it("accepts exact bridge entries from an LF tar listing", async () => {
const { result } = await verifyListing(members.join("\n") + "\n");
expect(result.bridgeParsed).toBe(true);
});
it.each(["package/bridge/stale.pyc", "package/bridge/__pycache__/stale"])(
"rejects generated Python assets before parsing a bridge: %s",
async (generated) => {
expect(
await verifyListing([...members, generated].join("\r\n") + "\r\n"),
).toEqual({
error: "package contained generated Python bytecode",
bridgeParsed: false,
});
},
);
@@ -0,0 +1,164 @@
import { spawn } from "node:child_process";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { 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 pluginFixture = async (runtime: string) => {
const workspace = await createTestTempDirectory("rea-plugin-");
const root = join(workspace, "plugin with spaces");
await mkdir(root);
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.8.0", type: "module" }),
);
await writeFile(join(root, "dist/mcp/main.js"), "");
await writeFile(join(root, "scripts/rea.mjs"), runtime);
return { root, entry };
};
const start = (entry: string, cwd: string, args = ["6.8.0"]) => {
const child = spawn(process.execPath, [entry, ...args], {
cwd,
stdio: "pipe",
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (bytes: Buffer) => {
stdout += bytes.toString();
});
child.stderr.on("data", (bytes: Buffer) => {
stderr += bytes.toString();
});
const closed = new Promise<{
code: number | null;
stdout: string;
stderr: string;
}>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve({ code, stdout, stderr }));
});
onTestFinished(async () => {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
await closed;
});
return { child, closed };
};
it("runs the matching runtime in-process and preserves cwd, stdio, and exit status", async () => {
const { root, entry } = await pluginFixture(`
process.stderr.write(JSON.stringify({ pid: process.pid, args: process.argv.slice(2), cwd: process.cwd() }));
process.stdin.pipe(process.stdout);
process.stdin.on("end", () => { process.exitCode = 7; });
`);
const caller = await createTestTempDirectory("rea-plugin-caller-");
const cwd = join(caller, "caller with spaces");
await mkdir(cwd);
// Built source plugins and npm artifacts use the same runtime owner.
await writeFile(join(root, ".git"), "gitdir: /fixture/worktree");
const { child, closed } = start(entry, cwd);
child.stdin.end('{"jsonrpc":"2.0","id":1}\n');
const result = await closed;
expect(result).toMatchObject({
code: 7,
stdout: '{"jsonrpc":"2.0","id":1}\n',
});
expect(JSON.parse(result.stderr)).toEqual({
pid: child.pid,
args: ["mcp"],
cwd,
});
});
it.each([["latest"], ["6.8.0&echo wrong"], ["6.8.0", "extra"], []])(
"rejects a non-exact or extra version argument: %j",
async (...args) => {
const { root, entry } = await pluginFixture(
'process.stdout.write("unexpected");',
);
const { child, closed } = start(entry, root, args);
child.stdin.end();
expect(await closed).toEqual({
code: 1,
stdout: "",
stderr: "REA plugin requires one exact rea-agents@VERSION pin.\n",
});
},
);
it("rejects a mismatched runtime instead of downloading another package", async () => {
const { root, entry } = await pluginFixture(
'process.stdout.write("unexpected");',
);
const { child, closed } = start(entry, root, ["6.7.0"]);
child.stdin.end();
expect(await closed).toEqual({
code: 1,
stdout: "",
stderr:
"REA plugin version pin does not match its installed rea-agents package; reinstall the plugin.\n",
});
});
it("reports how to repair an incomplete artifact or unbuilt source checkout", async () => {
const { root, entry } = await pluginFixture(
'process.stdout.write("unexpected");',
);
await rm(join(root, "dist"), { recursive: true });
const { child, closed } = start(entry, root);
child.stdin.end();
expect(await closed).toEqual({
code: 1,
stdout: "",
stderr:
"REA plugin runtime is missing; reinstall the plugin, or run npm ci && npm run build:cached in a source checkout.\n",
});
});
it("preserves runtime startup failures", async () => {
const { root, entry } = await pluginFixture(
'throw new Error("runtime failed");',
);
const { child, closed } = start(entry, root);
child.stdin.end();
const result = await closed;
expect(result.code).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("runtime failed");
});
it.skipIf(process.platform === "win32")(
"leaves signal cleanup with the running REA runtime",
async () => {
const { root, entry } = await pluginFixture(`
process.on("SIGTERM", () => { process.stderr.write("cleaned"); process.exit(0); });
process.stdin.resume();
process.stdout.write("ready");
`);
const { child, closed } = start(entry, root);
await new Promise<void>((resolve, reject) => {
child.stdout.once("data", () => resolve());
child.once("error", reject);
child.once("exit", () =>
reject(new Error("runtime exited before readiness")),
);
});
child.kill("SIGTERM");
expect(await closed).toEqual({
code: 0,
stdout: "ready",
stderr: "cleaned",
});
},
);
+248 -9
View File
@@ -1,13 +1,28 @@
import { 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 = ".codex-plugin/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(new Set(block.match(/rea-agents@[^\s"`]+/gu))).toEqual(
new Set([`rea-agents@${packageJson.version}`]),
);
},
);
@@ -120,3 +173,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, pluginServerConfig])(
"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,
},
},
});
});
});