diff --git a/README.md b/README.md index bf39055ccc..e285cd1d5a 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,13 @@ detected agents, then asks which capabilities to set up: agent integration Nothing is preselected. Choosing agent integration opens a second empty checklist for the specific detected agents that should receive a registration. +If bare `npx` finds a project-local REA package, its dispatcher restarts setup +through `rea-agents@latest` before planning any changes. This prevents an older +local dependency from silently writing stale integration state. An already +published older release cannot contain that dispatcher protection; run +`npx -y rea-agents@latest setup` once to repair registrations created by one. +Intentional rollbacks remain available through an exact package request. + REA keeps the journey inline so its history remains in the terminal. Selecting a capability does not select every detected target or authorize a change. Before anything changes, REA validates existing configuration, prints exact diff --git a/docs/installation.md b/docs/installation.md index 7f559870a4..90aeb5232f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -15,6 +15,24 @@ current package-runner invocation. REA still prints its own setup plan and asks for separate approval before changing agent configuration or installing a product-owned component. +When bare `npx` resolves `rea-agents` from the current project's +`node_modules`, the selected dispatcher restarts setup through +`rea-agents@latest` before REA plans or writes configuration. This protects +current and future releases from silently persisting stale integration state. +Code in an already-published older release cannot acquire that check +retroactively; invoke `npx -y rea-agents@latest setup` once to repair any +registration it created. + +For an intentional rollback, make the package request explicit: + +```bash +npm exec --yes --package=rea-agents@2.4.0 -- rea setup +``` + +Setup continues to pin persistent MCP registrations to the exact version that +performed setup. Running current setup later migrates unversioned or older +managed registrations through the normal reviewed setup transaction. + REA supports Node.js 22.19+ and 24.11+ (including newer releases). It uses the npm already paired with that runtime and never upgrades Node.js, npm, or Homebrew. Running `npm install rea-agents` without `--global` installs the executable only diff --git a/package.json b/package.json index 244c549fac..e9c873aec9 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "bridge/hopper_bridge.py", "bridge/ghidra/ReaGhidraBridge.java", "scripts/rea.mjs", + "scripts/package-runner-bootstrap.mjs", "scripts/hopper-demo-x11.py", "scripts/prepack.mjs", "scripts/prepare.mjs", diff --git a/scripts/package-runner-bootstrap.d.mts b/scripts/package-runner-bootstrap.d.mts new file mode 100644 index 0000000000..b220b87295 --- /dev/null +++ b/scripts/package-runner-bootstrap.d.mts @@ -0,0 +1,19 @@ +export interface PackageRunnerSetupInput { + readonly args: readonly string[]; + readonly environment: Readonly>; + readonly packageRoot: string; + readonly packageName: string; +} + +export interface PackageRunnerSetupPlan { + readonly command: "npm"; + readonly args: readonly string[]; +} + +export function packageRunnerSetupPlan( + input: PackageRunnerSetupInput, +): PackageRunnerSetupPlan | undefined; + +export function runPackageRunnerSetupBootstrap( + input: PackageRunnerSetupInput, +): Promise; diff --git a/scripts/package-runner-bootstrap.mjs b/scripts/package-runner-bootstrap.mjs new file mode 100644 index 0000000000..fd3034792e --- /dev/null +++ b/scripts/package-runner-bootstrap.mjs @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; + +const BOOTSTRAP_MARKER = "REA_PACKAGE_RUNNER_BOOTSTRAPPED"; + +/** + * Plan a current-release setup bootstrap when bare npx selected a project-local + * package. Explicit `npm exec --package=@` rollbacks bypass it. + */ +export function packageRunnerSetupPlan(input) { + if (input.args[0] !== "setup") return undefined; + if (input.environment[BOOTSTRAP_MARKER] === "1") return undefined; + if (input.environment.npm_lifecycle_event !== "npx") return undefined; + + const localPrefix = input.environment.npm_config_local_prefix; + if (localPrefix === undefined || localPrefix.length === 0) return undefined; + if ( + !isPackageBelowLocalPrefix( + input.packageRoot, + localPrefix, + input.packageName, + ) + ) + return undefined; + + const requestedPackage = input.environment.npm_config_package; + if ( + requestedPackage !== undefined && + isExactPackageVersion(requestedPackage, input.packageName) + ) + return undefined; + + return { + command: "npm", + args: [ + "exec", + "--yes", + `--package=${input.packageName}@latest`, + "--", + "rea", + ...input.args, + ], + }; +} + +/** Execute a planned package-runner bootstrap and return its exit status. */ +export async function runPackageRunnerSetupBootstrap(input) { + const plan = packageRunnerSetupPlan(input); + if (plan === undefined) return undefined; + + return await new Promise((resolveExitCode) => { + const child = spawn(plan.command, plan.args, { + stdio: "inherit", + env: { ...input.environment, [BOOTSTRAP_MARKER]: "1" }, + }); + child.once("error", () => resolveExitCode(1)); + child.once("exit", (code) => resolveExitCode(code ?? 1)); + }); +} + +function isPackageBelowLocalPrefix(packageRoot, localPrefix, packageName) { + const expectedRoot = resolve(localPrefix, "node_modules", packageName); + return resolve(packageRoot) === expectedRoot; +} + +function isExactPackageVersion(packageSpecifier, packageName) { + const version = packageSpecifier.slice(`${packageName}@`.length); + return ( + packageSpecifier.startsWith(`${packageName}@`) && + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(version) + ); +} diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 8bc02dfe67..8d75c1c551 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -4,6 +4,8 @@ import { access } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { runPackageRunnerSetupBootstrap } from "./package-runner-bootstrap.mjs"; + // Route production MCP before importing Incur. Incur owns registration helpers // such as `mcp add`, while only dist/main.js may serve the stdio tool catalog. const args = process.argv.slice(2); @@ -15,13 +17,21 @@ const isMcpMode = args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp"); const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const bootstrapExitCode = await runPackageRunnerSetupBootstrap({ + args, + environment: process.env, + packageRoot, + packageName: packageJson.name, +}); const runtimeFiles = isMcpMode ? ["dist/main.js"] : isMcpDoctorMode ? ["dist/main.js", "dist/mcpDoctor.js"] : ["dist/cli.js", "dist/cliOutput.js"]; -if (!(await compiledRuntimeExists(runtimeFiles))) { +if (bootstrapExitCode !== undefined) { + process.exitCode = bootstrapExitCode; +} else if (!(await compiledRuntimeExists(runtimeFiles))) { process.stderr.write( `REA's compiled runtime is missing. Run \`npm ci\` in ${packageRoot} to install dependencies and build REA, then restart it. If this is an installed package, reinstall rea-agents.\n`, ); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index 4feeff67c5..5afe2869ae 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -25,6 +25,10 @@ describe("executable dispatcher", () => { await Promise.all([mkdir(scripts), mkdir(dist)]); await Promise.all([ copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), + copyFile( + "scripts/package-runner-bootstrap.mjs", + join(scripts, "package-runner-bootstrap.mjs"), + ), writeFile( join(fixtureRoot, "package.json"), JSON.stringify({ type: "module", version: "1.0.0" }), @@ -77,6 +81,10 @@ describe("executable dispatcher", () => { await mkdir(scripts); await Promise.all([ copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), + copyFile( + "scripts/package-runner-bootstrap.mjs", + join(scripts, "package-runner-bootstrap.mjs"), + ), writeFile( join(fixtureRoot, "package.json"), JSON.stringify({ type: "module", version: "1.0.0" }), diff --git a/tests/packageRunnerBootstrap.test.ts b/tests/packageRunnerBootstrap.test.ts new file mode 100644 index 0000000000..6f03fe0541 --- /dev/null +++ b/tests/packageRunnerBootstrap.test.ts @@ -0,0 +1,153 @@ +import { chmod, readFile, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + packageRunnerSetupPlan, + runPackageRunnerSetupBootstrap, +} from "../scripts/package-runner-bootstrap.mjs"; +import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; + +describe("package-runner setup bootstrap", () => { + it("reruns bare setup through the current release when npm selected a local package", () => { + expect( + packageRunnerSetupPlan({ + args: ["setup", "--client", "codex"], + environment: { + npm_lifecycle_event: "npx", + npm_config_local_prefix: "/work/project", + }, + packageRoot: "/work/project/node_modules/rea-agents", + packageName: "rea-agents", + }), + ).toEqual({ + command: "npm", + args: [ + "exec", + "--yes", + "--package=rea-agents@latest", + "--", + "rea", + "setup", + "--client", + "codex", + ], + }); + }); + + it("leaves explicit rollback and non-local invocations untouched", () => { + const base = { + args: ["setup"], + packageRoot: "/work/project/node_modules/rea-agents", + packageName: "rea-agents", + } as const; + + expect( + packageRunnerSetupPlan({ + ...base, + environment: { + npm_lifecycle_event: "npx", + npm_config_local_prefix: "/work/project", + npm_config_package: "rea-agents@2.4.0", + }, + }), + ).toBeUndefined(); + expect( + packageRunnerSetupPlan({ + ...base, + environment: { + npm_lifecycle_event: "npx", + npm_config_local_prefix: "/work/project", + npm_config_package: "rea-agents@^2.4.0", + }, + }), + ).toEqual( + expect.objectContaining({ + args: expect.arrayContaining(["--package=rea-agents@latest"]), + }), + ); + expect( + packageRunnerSetupPlan({ + ...base, + packageRoot: "/root/.npm/_npx/cache/node_modules/rea-agents", + environment: { + npm_lifecycle_event: "npx", + npm_config_local_prefix: "/work/project", + }, + }), + ).toBeUndefined(); + }); + + it("does not redirect recursively or affect commands other than setup", () => { + const environment = { + npm_lifecycle_event: "npx", + npm_config_local_prefix: "/work/project", + REA_PACKAGE_RUNNER_BOOTSTRAPPED: "1", + }; + + expect( + packageRunnerSetupPlan({ + args: ["setup"], + environment, + packageRoot: "/work/project/node_modules/rea-agents", + packageName: "rea-agents", + }), + ).toBeUndefined(); + expect( + packageRunnerSetupPlan({ + args: ["doctor"], + environment: { + npm_lifecycle_event: "npx", + npm_config_local_prefix: "/work/project", + }, + packageRoot: "/work/project/node_modules/rea-agents", + packageName: "rea-agents", + }), + ).toBeUndefined(); + }); + + it("forwards setup arguments to npm with a recursion marker", async () => { + const directory = await createTestTempDirectory("rea-bootstrap-"); + const npm = join(directory, "npm"); + const log = join(directory, "invocation.json"); + await writeFile( + npm, + `#!/usr/bin/env node +import { writeFileSync } from "node:fs"; +writeFileSync(process.env.REA_BOOTSTRAP_TEST_LOG, JSON.stringify({ + args: process.argv.slice(2), + marker: process.env.REA_PACKAGE_RUNNER_BOOTSTRAPPED, +})); +`, + ); + await chmod(npm, 0o755); + + await expect( + runPackageRunnerSetupBootstrap({ + args: ["setup", "--dry-run"], + environment: { + ...process.env, + PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`, + REA_BOOTSTRAP_TEST_LOG: log, + npm_lifecycle_event: "npx", + npm_config_local_prefix: "/work/project", + }, + packageRoot: "/work/project/node_modules/rea-agents", + packageName: "rea-agents", + }), + ).resolves.toBe(0); + await expect(readFile(log, "utf8").then(JSON.parse)).resolves.toEqual({ + args: [ + "exec", + "--yes", + "--package=rea-agents@latest", + "--", + "rea", + "setup", + "--dry-run", + ], + marker: "1", + }); + }); +}); diff --git a/tests/readmeConsistency.test.ts b/tests/readmeConsistency.test.ts index b01bc59df6..83b881b84e 100644 --- a/tests/readmeConsistency.test.ts +++ b/tests/readmeConsistency.test.ts @@ -92,5 +92,19 @@ describe("localized README product facts", () => { expect(content).toContain("--install-hopper"); expect(content).toContain("docs/installation.md"); expect(content).toContain("You do not need both"); + expect(content).toContain( + "npx -y rea-agents@latest setup` once to repair registrations", + ); + }); + + it("documents stale bootstrap recovery and explicit rollback", async () => { + const content = await readFile(resolve("docs/installation.md"), "utf8"); + expect(content).toContain("before REA plans or writes configuration"); + expect(content).toContain( + "npm exec --yes --package=rea-agents@2.4.0 -- rea setup", + ); + expect(content).toContain( + "pin persistent MCP registrations to the exact version", + ); }); });