diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 9de416eb0a..f0792352f2 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -56,7 +56,7 @@ 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 software to repair MCP registration. For an unsupported client, use manual stdio -registration with a version-pinned `rea-agents` package or continue through the CLI. +registration with an existing installation’s absolute runtime and entry script, or continue through the CLI. A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is: @@ -69,7 +69,10 @@ graph, limitations, and unknowns with the same care as an MCP result; the CLI 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. +is within the authorized scope, use `npx -y rea-agents@latest` in its place from a +trusted directory outside the target project, with absolute target paths. Managed +setup binds the current installation directly; retain its runtime and package +cache entry, or restore that installation before repairing its registration. 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. diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index 7abd414917..d6491eab80 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -156,7 +156,10 @@ Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`, `copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`, `hermes`, `qoder`, `droid`, `zcode`, `dsh`, `freebuff`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a scope. `grok_bot` uses a chat registration step rather than local `mcp.json`. -For unsupported clients, use version-pinned manual stdio registration or CLI. +For unsupported clients, use manual stdio registration with the selected +installation’s absolute runtime and entry script, or continue through the CLI. +Acquire packages from a trusted directory outside the target project. Keep a +package-runner installation and its runtime available while registrations use it. Show the plan's exact paths, backups, changes and bundled-skill replacement. Obtain approval for writes when that concrete scope is not already authorized, diff --git a/README.md b/README.md index 07af6c4d1e..aee8f3fb6c 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,10 @@ npx rea-agents setup Choose your agents, review the proposed changes, and approve them. Setup adds REA's MCP server and matching workflow instructions, with backups of existing -configuration. Restart your agent afterward. +configuration. Restart your agent afterward. Setup records the selected runtime and REA +entry script directly. If acquired through a package runner, keep its cache entry +available. Run package acquisition from a trusted directory outside the target +project. Installation and recovery details are in the setup guide below. Setup supports Claude Code, Codex, Cursor, Gemini CLI, Grok Build and [other agents](docs/installation.md#supported-agents). See diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 790e5a463c..5e7ab056cf 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -14,12 +14,17 @@ interpreter: the npm launcher's `#!/usr/bin/env node` still searches `PATH` for Node. Running that command successfully in a terminal does not establish that the client can launch it. -For direct JavaScript installations, setup registers the selected runtime +For JavaScript installations, setup registers the selected runtime executable and REA script by absolute path. On POSIX, it prefers executable `node` or `bun` and REA aliases on setup's `PATH` only when they resolve to the same selected files. This preserves manager-owned aliases across version updates; -otherwise setup uses the selected paths directly. Package-runner registrations keep -their selected runner. Setup does not copy your ambient environment into client +otherwise setup uses the selected paths directly. This also applies when setup +runs through a package runner: retain the selected cache entry and runtime while +the registration uses them. Clearing either makes startup fail without resolving +another package from the client's project. Restore the same installation before +repairing it; an unidentified registration remains untouched. See +[installation and recovery](installation.md#package-runners-and-global-installation). +Setup does not copy your ambient environment into client settings. To refresh an older registration, preview `rea setup --client --skill=false --dry-run`, then run the same command without `--dry-run` and approve the displayed changes. Setup preserves unrelated diff --git a/docs/installation.md b/docs/installation.md index 0868aabeba..49004b735b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -273,7 +273,7 @@ After reviewing an update plan, rerun that same prefixed setup command without `--dry-run` and approve its exact changes. Profile removal likewise needs the prefix on `claude mcp remove --scope user rea` and any `rea uninstall` command; keep the broader client scope of uninstall in mind. Restart the selected -profile after changing its pinned registration. +profile after changing its registration. `claude mcp remove` removes only the registration. To remove REA from this profile without running the broader uninstall flow, first back up the profile's @@ -323,30 +323,26 @@ commands. Record an existing selection first if you intend to restore it after finishing. The configuration path below is the default profile's `~/.claude.json`; a different active profile uses its own config root instead. -Register with the variable unset: - - +Register with the variable unset, substituting the absolute runtime and entry +script from your existing installation or setup dry-run: ```bash -claude mcp add --scope local rea -- npx -y rea-agents@6.8.0 mcp +claude mcp add --scope local rea -- "/absolute/path/to/node" "/absolute/path/to/rea/scripts/rea.mjs" mcp ``` -On native Windows, use the same project directory and wrap the package-runner -shim so the stdio client can launch it: +On native Windows, use the same project directory and absolute paths: ```powershell -claude mcp add --scope local rea -- cmd.exe /d /c npx -y rea-agents@6.8.0 mcp +claude mcp add --scope local rea -- "C:\Program Files\nodejs\node.exe" "C:\tools\rea\scripts\rea.mjs" mcp ``` - - Local scope stores the entry under that directory's path in `~/.claude.json`, so other projects don't load it. This route doesn't install the bundled skill. Avoid `--scope project`, which writes `.mcp.json` into the repository where it can be committed or copied. `rea doctor` and `rea uninstall` read only the user-level entry, so they don't report or remove a local-scope registration; remove it with `claude mcp remove --scope local rea` from the same directory. -`rea update` doesn't refresh it either, so it stays on the version it names. +`rea update` doesn't refresh it either, so it continues using the selected installation. To move it to a newer release, first back up the project entry in `~/.claude.json` and record its environment and other metadata. In particular, preserve caller-selected overrides such as `REA_MCP_INPUT_SCHEMA_PROFILE` and @@ -553,8 +549,8 @@ non-TTY, and JSON modes without applying configuration changes. ## Released package and main Repository main documents its current code and generated catalog. `@latest` -selects the npm release, and persistent MCP registrations are pinned to the -version that performed setup. Installing newer instructions does not update a +selects the npm release, and managed MCP registrations bind the installation +that performed setup. Installing newer instructions does not update a running server or its registration. The npm release checked on **2026-10-11** was **6.4.0**, published from @@ -1101,14 +1097,15 @@ rea providers --json ``` `rea setup` can configure supported Windows agents and install the bundled -REA skill after approval. Direct registrations use Node to launch REA's entry -script; package-runner registrations use the pinned `npx` or `bunx` command. Hopper +REA skill after approval. Managed registrations use the absolute selected Node or +Bun runtime and REA entry script, including when setup ran through a package runner. Hopper installation remains unavailable on Windows. Setup never installs Ghidra, Java, or Python. It preserves valid detected Ghidra/JDK settings in agent registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagnostics. -The separate `rea mcp add` builtin currently uses Incur's package-runner child -launcher, which can fail on Windows with `MCP_ADD_FAILED: spawn npx ENOENT` +The separate `rea mcp add` builtin delegates most clients to Incur's package-runner +child launcher. Run that acquisition from a trusted directory; prefer managed +`rea setup` when supported. That child launcher can fail on Windows with `MCP_ADD_FAILED: spawn npx ENOENT` before registering a client. REA's setup path is unaffected. For a client supported by setup, review `rea setup --client opencode --skill=false --dry-run --json` (substitute its client ID), then use the same scoped command without `--dry-run` @@ -1653,40 +1650,45 @@ For a client that supports Registry discovery, search for `io.github.morluto/rea and select the npm package. The published metadata launches the existing stdio server through `npx` with the `mcp` command. -For a client that requires manual configuration, use: +For supported clients, prefer `rea setup`: it records the absolute JavaScript +runtime and entry script of the REA installation performing setup. Client starts +then use that installation directly, keeping the client's working directory +without running npm or Bun package resolution there. Registry clients control +their own package acquisition; Registry metadata cannot name local absolute paths. - +For manual stdio configuration, substitute the absolute paths of an existing +installation (use the command shown in a setup dry-run): ```json { "mcpServers": { "rea": { - "command": "npx", - "args": ["-y", "rea-agents@6.8.0", "mcp"] + "command": "/absolute/path/to/node", + "args": ["/absolute/path/to/rea/scripts/rea.mjs", "mcp"] } } } ``` - +Run current setup to migrate recognized older `npx`/`bunx` registrations, with +backups, then restart the client. Unrelated or unidentified registrations remain +untouched. Setup does not prewarm a separate package-runner command. -Persistent registrations should use one exact package version. `rea setup` -writes the same package-runner shape, or `bunx rea-agents@ mcp` when -setup runs through `bunx`, and pins it to the exact version that performed -setup. Run current setup to refresh an older registration, then restart the -client. - -Package runners key their package cache by the requested spec, so the entry -installed for the `@latest` invocation that ran setup cannot serve the pinned -registration. Setup therefore materializes the pinned entry once, best-effort -with a bounded timeout, before finishing. When that step cannot run, for -example offline, the first client start performs the one-time install inside -the client's MCP startup deadline and may need one more restart. +If setup itself runs from a package-runner cache, the registration depends on +that cache entry and the selected runtime remaining installed. Removing either +makes startup fail; it does not download or resolve another package. Restore the +same installation and rerun setup. Changing package requests, cache roots, or +runtimes can create a different installation identity. If the old entry cannot be +identified, restore it first or manually remove the confirmed REA registration +before running setup again. A global installation provides a more durable path. ## Package runners and global installation -A package-runner invocation can use a REA version installed in the current project. To -request the latest release explicitly, use: +Package runners can select project-local packages and read project configuration, +even for explicit version requests. Acquire REA from a trusted working directory, +outside the target project, and pass absolute target paths to analysis commands. +REA cannot validate package acquisition that happens before its entrypoint starts. +To request the latest release explicitly, use: ```bash npx rea-agents@latest setup @@ -1703,16 +1705,14 @@ For an intentional rollback, make the package request explicit: 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. +Setup binds persistent MCP registrations to the installation that performed setup. +Running current setup later migrates recognized legacy package-runner registrations +through the normal reviewed setup transaction. With Bun, `bunx rea-agents setup` (or `bunx rea-agents@latest setup`) is the -equivalent entry point. Setup run through `npx` registers -`npx -y rea-agents@ mcp`; setup run through `bunx` or `bun x` registers -`bunx rea-agents@ mcp`. On Windows, setup wraps either command in -`cmd.exe /d /c` so clients can start the runner's command shim. Neither -registration points into the runner's temporary package cache. +equivalent acquisition entry point. Managed registrations use the selected Bun +executable and REA entry script directly. Windows registrations likewise use +absolute runtime and script paths, without a package-runner command shim. REA supports Node.js 22.x (>=22.23.4), 24.x (>=24.22.1), and 26.12.1+. Node.js 23, 25, earlier 26.x releases, and prereleases are unsupported. It uses the npm already paired with that runtime and never upgrades Node.js, npm, or Homebrew. @@ -1746,8 +1746,9 @@ Review the plan, approve the selected client changes, then restart those clients This registers the installed entrypoint directly; installing the global package alone does not replace existing `npx` registrations. Each client still has its own REA process and analysis state. Direct registrations follow the installed CLI; -use `rea update` to update it. Running setup through `npx` again selects the pinned -package-runner command. Do not register paths inside npm's temporary package cache. +use `rea update` to update it. Running setup through `npx` again selects the +installation that executes setup, potentially in npm's temporary package cache; +retain that cache entry for as long as the registration uses it. ### npm workspace errors before REA starts diff --git a/scripts/lib/verify-package-runner-fixture.mjs b/scripts/lib/verify-package-runner-fixture.mjs new file mode 100644 index 0000000000..a1833de844 --- /dev/null +++ b/scripts/lib/verify-package-runner-fixture.mjs @@ -0,0 +1,8 @@ +import { writeFileSync } from "node:fs"; + +// Keep argument boundaries intact so verifier consumers never parse shell display text. +writeFileSync( + process.env.REA_NPX_LOG, + JSON.stringify(process.argv.slice(2)) + "\n", +); +process.stdout.write("│ ✓ Cursor: isolated │\n"); diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs index be2e81b865..91834d3fa1 100644 --- a/scripts/verify-package-environment.mjs +++ b/scripts/verify-package-environment.mjs @@ -1,6 +1,6 @@ import { chmod, mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { supportedClients } from "../dist/application/SupportedClients.js"; /** Create an isolated prefix, fake PATH, client configs, and environment. */ @@ -41,7 +41,7 @@ export async function verifyPackageEnvironment({ await writeFile(swVers, "#!/bin/sh\necho 14.5\n"); await writeFile( npx, - '#!/bin/sh\nprintf "%s\\n" "$*" > "$REA_NPX_LOG"\necho "│ ✓ Cursor: isolated │"\n', + '#!/bin/sh\nexec "$REA_VERIFY_NODE" "$REA_VERIFY_NPX_SCRIPT" "$@"\n', ); await writeFile(hopper, "#!/bin/sh\nexit 0\n"); await Promise.all([ @@ -98,6 +98,10 @@ export async function verifyPackageEnvironment({ REA_ANALYSIS_PROVIDER: "auto", ...packageHopperEnvironment(root), REA_NPX_LOG: npxLog, + REA_VERIFY_NODE: process.execPath, + REA_VERIFY_NPX_SCRIPT: fileURLToPath( + new URL("./lib/verify-package-runner-fixture.mjs", import.meta.url), + ), REA_REFERENCE_SECRET_PATTERNS_JSON: JSON.stringify([".env"]), }; return { diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs index 5dcd6490d7..f11af1e388 100644 --- a/scripts/verify-package-setup.mjs +++ b/scripts/verify-package-setup.mjs @@ -1,3 +1,4 @@ +import { deepStrictEqual } from "node:assert"; import { lstat, mkdir, @@ -11,6 +12,7 @@ import { import { dirname, isAbsolute, join } from "node:path"; import { parse as parseToml } from "smol-toml"; +import { clientRegistrationCommandText } from "../dist/cli/clientRegistrationCommandText.js"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; import { PRODUCT_IDENTITY } from "../dist/identity.js"; import { pathExists } from "./lib/verify-package-core.mjs"; @@ -50,12 +52,29 @@ const isDirectRegistration = async (command, cli) => (await realpath(command[0])) === (await realpath(process.execPath)) && (await realpath(command[1])) === (await realpath(cli)); -const verifyMcpAdd = async ({ commands, cli, environment, npxLog }) => { +const verifyMcpAdd = async ({ commands, cli, environment, npxLog, home }) => { await commands.run(cli, ["mcp", "add"], environment); - const mcpRegistration = await readFile(npxLog, "utf8"); - const expected = `add-mcp npx -y ${PRODUCT_IDENTITY.registrationPackageSpecifier} mcp --name rea`; - if (!mcpRegistration.includes(expected)) - throw new Error("Incur mcp add did not register the pinned npx command"); + const settings = JSON.parse( + await readFile(join(home, ".config", "amp", "settings.json"), "utf8"), + ); + const registration = settings?.["amp.mcpServers"]?.rea; + if (!Array.isArray(registration?.args)) + throw new Error("Incur mcp add did not write the native Amp registration"); + const command = [registration.command, ...registration.args]; + if (!(await isDirectRegistration(command, cli))) + throw new Error( + "Incur mcp add did not register the selected absolute runtime and REA script", + ); + // Amp consumes the same command through Incur's parser. Check the delegated + // helper receives that command as one argument, without flattening its argv. + deepStrictEqual(JSON.parse(await readFile(npxLog, "utf8")), [ + "add-mcp", + clientRegistrationCommandText(command), + "--name", + "rea", + "-y", + "-g", + ]); }; const verifySetupPlan = async ({ @@ -588,7 +607,7 @@ export async function verifyPackageSetup({ hopperSetupSupported, root, }) { - await verifyMcpAdd({ commands, cli, environment, npxLog }); + await verifyMcpAdd({ commands, cli, environment, npxLog, home }); const skillPath = join( home, ".agents/skills/reverse-engineer-anything/SKILL.md", diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index e439e1188f..e8f2463288 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -1,6 +1,9 @@ import { accessSync, constants, realpathSync } from "node:fs"; import { isAbsolute } from "node:path"; -import { normalizeEntrypointPath } from "../process/CurrentEntrypointPath.js"; +import { + CURRENT_ENTRYPOINT_PATH, + normalizeEntrypointPath, +} from "../process/CurrentEntrypointPath.js"; import { valid } from "semver"; import { @@ -10,8 +13,8 @@ import { import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { PRODUCT_IDENTITY } from "../identity.js"; -/** Package runners whose pinned launcher setup can register. */ -export type PackageRunner = "npx" | "bunx"; +/** Package runners recognized for invocation guidance and legacy registrations. */ +type PackageRunner = "npx" | "bunx"; /** Read one variable; Windows variable names are case-insensitive. */ const environmentValue = ( @@ -57,24 +60,32 @@ export const withoutPackageRunnerIdentity = ( ), ); -/** Resolve a pinned package-runner launcher that stdio clients can spawn on the host. */ -export const packageRunnerRegistrationCommand = ( - runner: PackageRunner, - platform: NodeJS.Platform = process.platform, +/** Bind the selected REA entrypoint to the runtime already executing setup. */ +export const currentClientRegistrationCommand = ( + selection: { + readonly platform?: NodeJS.Platform; + readonly entrypointPath?: string | undefined; + readonly environment?: Readonly; + } = {}, ): 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; + const platform = selection.platform ?? process.platform; + const environment = selection.environment ?? process.env; + const entrypointPath = Object.hasOwn(selection, "entrypointPath") + ? selection.entrypointPath + : CURRENT_ENTRYPOINT_PATH; + if (entrypointPath === undefined || entrypointPath.length === 0) return []; + return [ + registrationRuntimePath( + process.execPath, + javaScriptRuntime(process.versions).id, + environment, + platform, + ), + registrationEntrypointPath(entrypointPath, environment, platform), + "mcp", + ]; }; -/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */ -export const npxRegistrationCommand = ( - platform: NodeJS.Platform = process.platform, -): readonly string[] => packageRunnerRegistrationCommand("npx", platform); - const unwrapCmdCommand = (command: readonly string[]): readonly string[] => { const executable = command[0]?.toLowerCase(); if (executable !== "cmd" && executable !== "cmd.exe") return command; @@ -170,7 +181,7 @@ const sameFileExecutableAlias = ( }; /** Prefer an executable PATH alias that proves the same selected REA file. */ -export const registrationEntrypointPath = ( +const registrationEntrypointPath = ( entrypoint: string, environment: Readonly, platform: NodeJS.Platform, diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index c30b5755e1..24266348a9 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -24,7 +24,6 @@ import { inspectClientRegistrationCommand, type ClientRegistrationCommandFacts, type ClientRegistrationCommandIdentity, - packageRunnerRegistrationCommand, } from "./ClientRegistrationIdentity.js"; import { clientEvidencePaths, @@ -470,17 +469,7 @@ const registrationAligned = ({ (registration.serverName !== "rea" || registration.transport !== "stdio") ) return "stale"; - if ( - (["npx", "bunx"] as const).some((runner) => { - const expected = packageRunnerRegistrationCommand(runner, platform); - return ( - command.length === expected.length && - command.every((argument, index) => argument === expected[index]) - ); - }) - ) - return "aligned"; - // Other owned package references remain stale until pinned to this version. + // Legacy package runners remain owned, but setup migrates them to the selected installation. if (commandFacts.package !== undefined) return "stale"; // Owning the script does not establish how its shebang interpreter resolves // in the client. Setup migrates these launchers without losing ownership. diff --git a/src/application/GeminiClientSettings.ts b/src/application/GeminiClientSettings.ts index 55a0bb9b4d..a9398d1624 100644 --- a/src/application/GeminiClientSettings.ts +++ b/src/application/GeminiClientSettings.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { err, ok, type Result } from "../domain/result.js"; import { PRODUCT_IDENTITY } from "../identity.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; -import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { clientConfigurationValuesEqual, clientRegistrationEntry, @@ -211,7 +211,7 @@ export const readClientPolicyBlock = async ( const desired = desiredRegistration ?? userDocument?.servers[PRODUCT_IDENTITY.mcpServerKey] ?? - clientRegistrationEntry("json", npxRegistrationCommand(), {}); + clientRegistrationEntry("json", currentClientRegistrationCommand(), {}); if ( override !== undefined && !clientConfigurationValuesEqual( diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index 6a671253d2..c502299ffe 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { FakeSetupHost, options } from "./Setup.fixture.js"; import { runSetup } from "./Setup.js"; -import { setupRegistrationCommand } from "./SetupHost.js"; +import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; import { discoverSetupState } from "./SetupPlan.js"; import type { MsixPackageView } from "./MsixPackageStorage.js"; @@ -414,13 +414,23 @@ describe("setup workflow action selection", () => { expect(result.status).toBe("ready"); expect(host.configuredCommands).toEqual([ - setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH), + currentClientRegistrationCommand({ + platform: "win32", + entrypointPath: CURRENT_ENTRYPOINT_PATH, + }), ]); expect( - setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH), + currentClientRegistrationCommand({ + platform: "win32", + entrypointPath: CURRENT_ENTRYPOINT_PATH, + }), ).toEqual([process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"]); - expect(setupRegistrationCommand("win32", undefined, undefined)).toEqual([]); - expect(setupRegistrationCommand("win32", "npx", undefined)).not.toEqual([]); + expect( + currentClientRegistrationCommand({ + platform: "win32", + entrypointPath: undefined, + }), + ).toEqual([]); }); }); diff --git a/src/application/Setup.fixture.ts b/src/application/Setup.fixture.ts index 31c6a4e702..ca334f9495 100644 --- a/src/application/Setup.fixture.ts +++ b/src/application/Setup.fixture.ts @@ -19,7 +19,7 @@ import type { LinuxDistribution } from "./LinuxHopper.js"; import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js"; import { DEFAULT_CATALOG_IDENTITY } from "../catalogIdentity.js"; import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js"; -import { setupRegistrationCommand } from "./SetupHost.js"; +import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; /** Recording setup host for service-level planning and recovery tests. */ @@ -64,11 +64,7 @@ export class FakeSetupHost implements SetupHost { constructor(platform: NodeJS.Platform = "darwin") { this.platform = platform; - this.registrationCommand = setupRegistrationCommand( - platform, - undefined, - CURRENT_ENTRYPOINT_PATH, - ); + this.registrationCommand = currentClientRegistrationCommand({ platform }); } macosVersion = (): Promise => diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts index e06f6bb314..e711dc7f80 100644 --- a/src/application/Setup.recovery.test.ts +++ b/src/application/Setup.recovery.test.ts @@ -778,48 +778,3 @@ it.each(["return", "throw"] as const)( expect(failure.hasCleanupOwner).toBe(false); }, ); - -it("stops setup after interrupted prewarm and retains cleanup through host close retry", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }]; - let doctorCalls = 0; - const doctor = host.doctor.bind(host); - host.doctor = async (...args) => { - doctorCalls++; - return doctor(...args); - }; - let callsAtInterrupt = -1; - let released = false; - const cleanup = new SetupCleanupOwner([ - { - close: async () => { - if (!released) throw new Error("controlled runner cleanup refusal"); - }, - }, - ]); - const selected = Object.assign(host, { - prewarmRegistrationRunner: async () => { - callsAtInterrupt = doctorCalls; - return { - status: "failed" as const, - reason: "interrupted prewarm", - interruptedSignal: "SIGINT", - }; - }, - close: () => cleanup.close(), - }); - const result = await runSetup( - { ...options(true), clientIds: ["codex"], installSkill: false }, - selected, - ); - expect(callsAtInterrupt).toBeGreaterThanOrEqual(0); - expect(doctorCalls).toBe(callsAtInterrupt); - expect(result.status).toBe("needs_human"); - expect(result.appliedActions).toContain("configured_codex"); - expect(result.prewarm).toMatchObject({ interruptedSignal: "SIGINT" }); - expect(result.cleanup?.reason).toContain("controlled runner cleanup refusal"); - released = true; - await cleanup.close(); - expect(result.cleanup).toBeUndefined(); -}); diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 8f173c32fd..4bdc1cf3f6 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -35,7 +35,6 @@ import { setupCleanupError, setupCleanupReason } from "./SetupCleanupOwner.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import type { SetupSkillInstallError } from "./SetupSkill.js"; import type { SetupHopperInstallFailure } from "./SetupInstallFailure.js"; -import type { SetupRegistrationPrewarm } from "./SetupRegistrationPrewarm.js"; /** Discover, approve, and apply setup actions idempotently. */ export const runSetup = async ( @@ -387,44 +386,6 @@ const runSetupWorkflow = async ( return withHopperFailure(await failSkill(skillFailure)); } } - // The prewarm only follows client registrations the user approved; an empty - // plan or a skill-only run must not install anything. - const prewarmEligible = appliedActions.some((id) => - id.startsWith("configured_"), - ); - let prewarm: SetupRegistrationPrewarm | undefined; - if (prewarmEligible && host.prewarmRegistrationRunner !== undefined) { - emitProgress(options, { - actionId: "prewarm_registration_runner", - label: "REA package-runner cache", - state: "started", - detail: "Preparing the pinned package entry", - }); - prewarm = await host.prewarmRegistrationRunner(); - if (prewarm.status === "failed" && prewarm.interruptedSignal !== undefined) - return { - status: "cancelled", - plannedActions, - appliedActions, - clients, - clientStates, - doctor: summarizeDoctor(discovery.initialDoctor), - prewarm, - }; - if (prewarm.status === "failed") - emitProgress(options, { - actionId: "prewarm_registration_runner", - label: "REA package-runner cache", - state: "warning", - detail: prewarm.reason, - }); - else if (prewarm.status === "warmed") - emitProgress(options, { - actionId: "prewarm_registration_runner", - label: "REA package-runner cache", - state: "completed", - }); - } const doctor = summarizeDoctor(await host.doctor(readinessScope)); const remediation = combineRemediation( hopperFailure?.remediation ?? @@ -447,7 +408,6 @@ const runSetupWorkflow = async ( clientStates, ...(remediation === undefined ? {} : { remediation }), ...(hopperFailure === undefined ? {} : { code: hopperFailure.code }), - ...(prewarm === undefined ? {} : { prewarm }), }; }; diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 5102d5b955..42236315c5 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -48,7 +48,7 @@ import { import { PRODUCT_IDENTITY } from "../identity.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; -import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { packageReadTargetChanged } from "./MsixPackageStorage.js"; import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; @@ -93,9 +93,16 @@ export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanu export const configureClientConfiguration = async ( client: SetupClient, environment: SetupProviderEnvironment = {}, - command: readonly string[] = npxRegistrationCommand(), + command: readonly string[] = currentClientRegistrationCommand(), resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Promise => { + if (command.length === 0) + return { + status: "failed", + reason: "registration_ownership", + detail: + "The running REA entrypoint could not be identified; run setup from an installed REA command.", + }; if (client.configPathError !== undefined) return { status: "failed", reason: "path" }; const transactionPath = await resolveClientConfigTransactionPath( diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 60402a7cf4..0b465f78b0 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -9,20 +9,9 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { PRODUCT_IDENTITY } from "../identity.js"; -import { - invokingPackageRunner, - registrationEntrypointPath, - registrationRuntimePath, - packageRunnerRegistrationCommand, - type PackageRunner, -} from "./ClientRegistrationIdentity.js"; -import { - materializeRegistrationRunnerCache, - type SetupRegistrationPrewarm, -} from "./SetupRegistrationPrewarm.js"; +import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { SUPPORTED_NODE_VERSION_PROSE, - javaScriptRuntime, supportsNodeVersion, } from "../domain/runtimeVersion.js"; import { @@ -83,32 +72,6 @@ import { bindRegularFileCleanup } from "./RegularFileRead.js"; import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; import { analysisProviderSelectorSchema } from "../domain/providerSelection.js"; -/** Resolve the executable and arguments used in a managed MCP registration. */ -export const setupRegistrationCommand = ( - platform: NodeJS.Platform, - packageRunner: PackageRunner | undefined, - entrypointPath: string | undefined, - environment: Readonly = {}, -): readonly string[] => { - if (packageRunner !== undefined) - return packageRunnerRegistrationCommand(packageRunner, platform); - if (entrypointPath === undefined || entrypointPath.length === 0) return []; - const script = registrationEntrypointPath( - entrypointPath, - environment, - platform, - ); - const runtime = registrationRuntimePath( - process.execPath, - javaScriptRuntime(process.versions).id, - environment, - platform, - ); - // The script's env-node shebang depends on the client's PATH, which may - // differ from setup's shell. Bind the runtime that is executing setup. - return [runtime, script, "mcp"]; -}; - /** * The launcher setup registers, and why it must not be registered when it * exists only inside one MSIX package's private storage. @@ -118,12 +81,11 @@ const setupRegistration = ( homeDirectory: string, environment: Readonly, ): Pick => { - const registrationCommand = setupRegistrationCommand( + const registrationCommand = currentClientRegistrationCommand({ platform, - invokingPackageRunner(environment, platform), - CURRENT_ENTRYPOINT_PATH, + entrypointPath: CURRENT_ENTRYPOINT_PATH, environment, - ); + }); const registrationCommandBlocker = registrationCommand.length === 0 ? "REA could not determine the current executable entrypoint. Run setup through the installed REA CLI or a supported package runner before configuring clients." @@ -192,12 +154,6 @@ export const systemSetupHost = ( }, }; const registration = setupRegistration(platform, homeDirectory, environment); - const prewarmRegistrationRunner = (): Promise => - materializeRegistrationRunnerCache( - registration.registrationCommand, - environment, - resources, - ); const cleanup = new SetupCleanupOwner([ macCleanup, { @@ -220,7 +176,6 @@ export const systemSetupHost = ( skillDestinations: (clientIds) => skillDestinations(homeDirectory, clientIds, environment, platform), ...registration, - prewarmRegistrationRunner, nodeVersion: process.versions.node, macosVersion: () => doctorHost.macosVersion(), linuxDistribution, diff --git a/src/application/SetupRegistrationPrewarm.test.ts b/src/application/SetupRegistrationPrewarm.test.ts deleted file mode 100644 index 3a272b402a..0000000000 --- a/src/application/SetupRegistrationPrewarm.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { describe, expect, it, onTestFinished } from "vitest"; -import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; -import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; -import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; - -import { PRODUCT_IDENTITY } from "../identity.js"; -import { - launchRegistrationRunnerCommand, - materializeRegistrationRunnerCache, - registrationPrewarmCommand, - type RegistrationRunnerLaunch, - type RegistrationRunnerLauncher, -} from "./SetupRegistrationPrewarm.js"; - -const scope = () => { - const resources = new ArtifactResourceScope(); - onTestFinished(() => resources.close()); - return resources; -}; - -const specifier = PRODUCT_IDENTITY.registrationPackageSpecifier; - -const launch = ( - overrides: Partial = {}, -): RegistrationRunnerLaunch => ({ - exitCode: 0, - signal: null, - stdout: "", - stderr: "", - timedOut: false, - ...overrides, -}); - -describe("registrationPrewarmCommand", () => { - it("replaces the server subcommand of an npx registration", () => { - expect(registrationPrewarmCommand(["npx", "-y", specifier, "mcp"])).toEqual( - ["npx", "-y", specifier, "--version"], - ); - }); - - it("keeps the Windows command wrapper", () => { - expect( - registrationPrewarmCommand([ - "cmd.exe", - "/d", - "/c", - "npx", - "-y", - specifier, - "mcp", - ]), - ).toEqual(["cmd.exe", "/d", "/c", "npx", "-y", specifier, "--version"]); - }); - - it("replaces the server subcommand of a bunx registration", () => { - expect(registrationPrewarmCommand(["bunx", specifier, "mcp"])).toEqual([ - "bunx", - specifier, - "--version", - ]); - }); - - it("keeps direct launchers and non-server commands undefined", () => { - expect(registrationPrewarmCommand(["/opt/rea/rea", "mcp"])).toBeUndefined(); - expect( - registrationPrewarmCommand(["npx", "-y", specifier, "doctor"]), - ).toBeUndefined(); - expect(registrationPrewarmCommand([])).toBeUndefined(); - }); -}); - -describe("materializeRegistrationRunnerCache", () => { - it("skips direct launchers without launching", async () => { - let launched = 0; - const launcher: RegistrationRunnerLauncher = async () => { - launched += 1; - return launch(); - }; - const result = await materializeRegistrationRunnerCache( - ["/opt/rea/rea", "mcp"], - {}, - scope(), - launcher, - ); - expect(result).toEqual({ status: "skipped", reason: "direct_launcher" }); - expect(launched).toBe(0); - }); - - it("materializes the pinned spec once and reports warmed", async () => { - const commands: (readonly string[])[] = []; - const launcher: RegistrationRunnerLauncher = async ( - command, - arguments_, - options, - ) => { - commands.push([command, ...arguments_]); - expect(options.timeoutMs).toBeGreaterThan(0); - expect(options.env.REA_PREWARM_PROBE).toBe("1"); - expect(options.cwd.length).toBeGreaterThan(0); - return launch({ stdout: "6.7.0\n" }); - }; - const result = await materializeRegistrationRunnerCache( - ["npx", "-y", specifier, "mcp"], - { REA_PREWARM_PROBE: "1" }, - scope(), - launcher, - ); - expect(result).toEqual({ status: "warmed" }); - expect(commands).toEqual([["npx", "-y", specifier, "--version"]]); - }); - - it("reports a bounded failure reason for a non-zero exit", async () => { - const result = await materializeRegistrationRunnerCache( - ["npx", "-y", specifier, "mcp"], - {}, - scope(), - async () => launch({ exitCode: 1, stderr: "spawn EPERM\n" }), - ); - expect(result.status).toBe("failed"); - if (result.status !== "failed") return; - expect(result.reason).toContain("exit 1"); - expect(result.reason).toContain("spawn EPERM"); - expect(result.reason).not.toMatch(/[\n\r]/u); - expect(result.reason).toContain("first client start"); - }); - - it("reports a timeout failure without command output facts", async () => { - const result = await materializeRegistrationRunnerCache( - ["npx", "-y", specifier, "mcp"], - {}, - scope(), - async () => launch({ exitCode: null, timedOut: true }), - ); - expect(result.status).toBe("failed"); - if (result.status !== "failed") return; - expect(result.reason).toContain("timed out"); - }); - - it("reports a spawn failure", async () => { - const result = await materializeRegistrationRunnerCache( - ["npx", "-y", specifier, "mcp"], - {}, - scope(), - async () => launch({ exitCode: null, spawnError: "spawn npx ENOENT" }), - ); - expect(result.status).toBe("failed"); - if (result.status !== "failed") return; - expect(result.reason).toContain("spawn npx ENOENT"); - }); - - it("reports a forwarded interrupt as a failure", async () => { - const result = await materializeRegistrationRunnerCache( - ["npx", "-y", specifier, "mcp"], - {}, - scope(), - async () => launch({ exitCode: null, interruptedSignal: "SIGINT" }), - ); - expect(result.status).toBe("failed"); - if (result.status !== "failed") return; - expect(result.reason).toContain("stopped by SIGINT"); - }); -}); - -describe("launchRegistrationRunnerCommand", () => { - it("retains bounded head and tail bytes and preserves UTF-8 across writes", async () => { - const result = await launchRegistrationRunnerCommand( - process.execPath, - [ - "-e", - ` - process.stdout.write(Buffer.from([0xe4])); - setTimeout(() => { - process.stdout.write(Buffer.from([0xb8, 0xad])); - process.stderr.write('x'.repeat(60000)); - setTimeout(() => { - process.stderr.write('y'.repeat(60000)); - process.stderr.write('FINAL: package permission denied'); - process.exitCode = 1; - }, 30); - }, 30); - `, - ], - { - env: process.env, - cwd: process.cwd(), - timeoutMs: 5000, - platform: process.platform, - resources: scope(), - }, - ); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe("中"); - expect(Buffer.byteLength(result.stderr)).toBeLessThan(64 * 1024 + 50); - expect(result.stderr).toContain("[output omitted]"); - expect(result.stderr).toContain("FINAL: package permission denied"); - const prewarm = await materializeRegistrationRunnerCache( - ["npx", "-y", specifier, "mcp"], - {}, - scope(), - async () => result, - ); - expect(prewarm.status).toBe("failed"); - if (prewarm.status === "failed") { - expect(prewarm.reason).toContain("exit 1"); - expect(prewarm.reason).toContain("FINAL: package permission denied"); - } - }); - - it("captures a successful command exit", async () => { - const result = await launchRegistrationRunnerCommand( - process.execPath, - ["--version"], - { - env: process.env, - cwd: process.cwd(), - timeoutMs: 30_000, - platform: process.platform, - resources: scope(), - }, - ); - expect(result.exitCode).toBe(0); - expect(result.timedOut).toBe(false); - expect(result.stdout.trim().length).toBeGreaterThan(0); - }); - - it("stops the runner tree at the deadline and reports the timeout", async () => { - const startedAt = Date.now(); - const result = await launchRegistrationRunnerCommand( - process.execPath, - ["-e", "setInterval(() => {}, 1000)"], - { - env: process.env, - cwd: process.cwd(), - timeoutMs: 300, - platform: process.platform, - resources: scope(), - }, - ); - expect(result.timedOut).toBe(true); - expect(Date.now() - startedAt).toBeLessThan(10_000); - }); -}); - -it.runIf(process.platform !== "win32")( - "settles the prewarm deadline after a detached descendant inherits the exited runner's pipes", - async () => { - const result = await launchRegistrationRunnerCommand( - process.execPath, - [ - "-e", - ` - const { spawn } = require('node:child_process'); - const child = spawn(process.execPath, ['-e', 'process.stdout.write("descendant:" + process.pid); setTimeout(() => process.stdout.write("natural-descendant-exit"), 60_000)'], { - detached: true, stdio: ['ignore', 'inherit', 'inherit'] - }); - child.unref(); - `, - ], - { - env: process.env, - cwd: process.cwd(), - // Ownership admission and two Node starts must finish before the - // deadline tests held output pipes rather than startup cancellation. - timeoutMs: 5000, - platform: process.platform, - resources: scope(), - }, - ); - expect(result.exitCode).toBe(0); - expect(result.timedOut).toBe(true); - expect(result.stdout).toContain("descendant:"); - expect(result.cleanupFailure).toBeUndefined(); - expect(result.stdout).not.toContain("natural-descendant-exit"); - }, - 15_000, -); - -it.each([false, true])( - "retains the exact runner cleanup for scope close and retry (interrupt=%s)", - async (interrupt) => { - const resources = new ArtifactResourceScope(); - let allowCleanup = false; - let attempts = 0; - onTestFinished(async () => { - allowCleanup = true; - await resources.close(); - }); - const result = await launchRegistrationRunnerCommand( - process.execPath, - ["--version"], - { - env: process.env, - cwd: process.cwd(), - timeoutMs: 5000, - platform: process.platform, - resources, - launcher: async (options) => { - const launched = await spawnOwnedProviderProcess(options); - return { - ...launched, - cleanup: async () => { - attempts++; - if (interrupt && attempts === 1) process.emit("SIGINT"); - return allowCleanup - ? cleanupOwnedProcessGroup(launched.ownership) - : { - cleaned: false as const, - reason: "controlled runner cleanup refusal", - }; - }, - }; - }, - }, - ); - expect(result.exitCode).toBe(0); - expect(result.cleanupFailure).toContain( - "controlled runner cleanup refusal", - ); - expect(result.interruptedSignal).toBe(interrupt ? "SIGINT" : undefined); - expect(resources.hasPending).toBe(true); - const prewarm = await materializeRegistrationRunnerCache( - ["npx", "-y", specifier, "mcp"], - {}, - resources, - async () => result, - ); - expect(prewarm).toMatchObject({ status: "failed" }); - await expect(resources.close()).rejects.toThrow(); - expect(attempts).toBe(2); - allowCleanup = true; - await resources.close(); - expect(attempts).toBe(3); - expect(resources.hasPending).toBe(false); - }, -); - -it("captures the selected environment at admission and accepts reconfiguration on the next prewarm", async () => { - const env = { ...process.env, REA_PREWARM_SELECTED_VALUE: "first" }; - const resources = scope(); - const invoke = () => - launchRegistrationRunnerCommand( - process.execPath, - ["-e", "process.stdout.write(process.env.REA_PREWARM_SELECTED_VALUE)"], - { - env, - cwd: process.cwd(), - timeoutMs: 5000, - platform: process.platform, - resources, - }, - ); - const first = invoke(); - env.REA_PREWARM_SELECTED_VALUE = "second"; - expect((await first).stdout).toBe("first"); - expect((await invoke()).stdout).toBe("second"); - expect(resources.hasPending).toBe(false); -}); diff --git a/src/application/SetupRegistrationPrewarm.ts b/src/application/SetupRegistrationPrewarm.ts deleted file mode 100644 index 0a9499fefa..0000000000 --- a/src/application/SetupRegistrationPrewarm.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; -import { randomUUID } from "node:crypto"; -import { - ArtifactResourceScope, - type ArtifactResourceOwner, -} from "../artifacts/ArtifactResourceScope.js"; -import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; -import { - ProviderProcessSupervisor, - spawnOwnedProviderProcess, -} from "../process/ProviderProcess.js"; -import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; -import { ownedCommandResources } from "../process/OwnedCommand.js"; -import { tmpdir } from "node:os"; -import { BoundedOutputExcerpt } from "../process/BoundedOutputExcerpt.js"; - -import { inspectClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; - -/** - * Best-effort outcome of materializing the pinned package-runner cache entry. - * A failure never blocks setup; the first client start then performs the - * one-time install inside its own startup deadline. - */ -export type SetupRegistrationPrewarm = - | { readonly status: "skipped"; readonly reason: "direct_launcher" } - | { readonly status: "warmed" } - | { - readonly status: "failed"; - readonly reason: string; - readonly interruptedSignal?: string; - }; - -/** One cold package install at setup time; client startup deadlines stay untouched. */ -const PREWARM_TIMEOUT_MS = 180_000; -const PREWARM_CAPTURE_LIMIT = 64 * 1024; -const PREWARM_REASON_LIMIT = 300; - -/** Facts of one supervised package-runner launch. */ -export interface RegistrationRunnerLaunch { - readonly exitCode: string | number | null; - readonly signal: string | null; - readonly stdout: string; - readonly stderr: string; - readonly timedOut: boolean; - readonly spawnError?: string; - readonly cleanupFailure?: string; - /** Terminal interrupt that was forwarded to the runner tree, if any. */ - readonly interruptedSignal?: string; -} - -/** Launch shape shared with the supervised spawn so tests can observe it. */ -export type RegistrationRunnerLauncher = ( - command: string, - arguments_: readonly string[], - options: { - readonly env: Readonly; - /** Neutral directory; inside a REA source checkout the package runner - * would resolve the local project instead of the published entry. */ - readonly cwd: string; - readonly timeoutMs: number; - readonly platform: NodeJS.Platform; - readonly resources: ArtifactResourceScope; - readonly launcher?: typeof spawnOwnedProviderProcess; - }, -) => Promise; - -/** - * Resolve the cache-materializing variant of a registration command. The - * package-runner cache entry is keyed by the requested spec, so a terminating - * subcommand with the same spec warms the entry the client will launch. - * Direct launchers keep no package-runner cache; they return undefined. - */ -export const registrationPrewarmCommand = ( - command: readonly string[], -): readonly string[] | undefined => { - if ( - command.length === 0 || - command[command.length - 1] !== "mcp" || - inspectClientRegistrationCommand(command, undefined).package === undefined - ) - return undefined; - return [...command.slice(0, -1), "--version"]; -}; - -/** Materialize the pinned package-runner cache entry once, best-effort. */ -export const materializeRegistrationRunnerCache = async ( - command: readonly string[], - environment: Readonly, - resources: ArtifactResourceScope, - launchCommand: RegistrationRunnerLauncher = launchRegistrationRunnerCommand, -): Promise => { - const prewarm = registrationPrewarmCommand(command); - if (prewarm === undefined) - return { status: "skipped", reason: "direct_launcher" }; - const launch = await launchCommand(prewarm[0] ?? "", prewarm.slice(1), { - env: environment, - cwd: tmpdir(), - timeoutMs: PREWARM_TIMEOUT_MS, - platform: process.platform, - resources, - }); - return launch.timedOut || - launch.spawnError !== undefined || - launch.cleanupFailure !== undefined || - launch.interruptedSignal !== undefined || - launch.exitCode !== 0 - ? { - status: "failed", - reason: prewarmFailureReason(launch), - ...(launch.interruptedSignal === undefined - ? {} - : { interruptedSignal: launch.interruptedSignal }), - } - : { status: "warmed" }; -}; - -/** Own the launcher and token-marked descendants until cleanup is verified. */ -export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = ( - command, - arguments_, - options, -) => { - const environment = snapshotEnvironment(options.env, options.platform); - return options.resources.run(async () => { - const stdout = new BoundedOutputExcerpt(PREWARM_CAPTURE_LIMIT); - const stderr = new BoundedOutputExcerpt(PREWARM_CAPTURE_LIMIT); - const controller = new AbortController(); - let timedOut = false; - let spawnError: string | undefined; - let cleanupFailure: string | undefined; - let interruptedSignal: NodeJS.Signals | undefined; - let supervisor: ProviderProcessSupervisor | undefined; - let resource: ArtifactResourceOwner | undefined; - const timer = setTimeout(() => { - timedOut = true; - controller.abort(); - }, options.timeoutMs); - const signalHandlers = new Map void>( - FORWARD_SIGNALS.map((signal) => [ - signal, - () => { - if (interruptedSignal !== undefined) return; - interruptedSignal = signal; - controller.abort(); - }, - ]), - ); - for (const [signal, handler] of signalHandlers) process.on(signal, handler); - try { - const launched = await (options.launcher ?? spawnOwnedProviderProcess)({ - command, - arguments: arguments_, - cwd: options.cwd, - hostEnvironment: environment, - platform: options.platform, - runId: `rea-registration-prewarm-${randomUUID()}`, - expectedCommand: null, - sweepTokenOwnedProcesses: true, - signal: controller.signal, - }); - supervisor = new ProviderProcessSupervisor( - { - ...launched, - ownsProcessLifetime: true, - cleanup: - launched.cleanup ?? - (() => cleanupOwnedProcessGroup(launched.ownership)), - }, - { - captureStdout: false, - maxDiagnosticBytes: 0, - onDiagnostic: (event) => { - if (event.type === "error") spawnError = event.message; - }, - }, - ); - const owner = supervisor; - const lease = new AnalysisCleanupLease(async () => { - const stopped = await owner.stop(); - if (stopped.status === "incomplete" || stopped.status === "not-owned") - throw new Error( - stopped.status === "incomplete" - ? stopped.reason - : "Registration runner ownership was not confirmed.", - ); - }, ownedCommandResources(launched.ownership)); - resource = { kind: "lease", resource: command, lease }; - options.resources.retain(resource); - launched.process.stdout?.on("data", (chunk: Buffer) => - stdout.append(chunk), - ); - launched.process.stderr?.on("data", (chunk: Buffer) => - stderr.append(chunk), - ); - await supervisor.waitForOutputClose(undefined, controller.signal); - } catch (cause: unknown) { - if (!controller.signal.aborted) - spawnError = cause instanceof Error ? cause.message : String(cause); - } finally { - clearTimeout(timer); - try { - if (resource !== undefined) { - const released = await options.resources.release(resource); - if (released.kind === "failed") throw released.cause; - } - } catch (cause: unknown) { - cleanupFailure = cause instanceof Error ? cause.message : String(cause); - } - for (const [signal, handler] of signalHandlers) - process.removeListener(signal, handler); - // Restore default termination only after stopping the owned runner tree. - if (interruptedSignal !== undefined && cleanupFailure === undefined) - process.kill(process.pid, interruptedSignal); - } - const exit = supervisor?.observedExit(); - return { - exitCode: exit?.code ?? exit?.signal ?? null, - signal: exit?.signal ?? null, - stdout: stdout.text, - stderr: stderr.text, - timedOut, - ...(spawnError === undefined ? {} : { spawnError }), - ...(cleanupFailure === undefined ? {} : { cleanupFailure }), - ...(interruptedSignal === undefined ? {} : { interruptedSignal }), - }; - }); -}; - -/** Terminal interrupts that must also stop a detached package-runner tree. */ -const FORWARD_SIGNALS: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; - -const prewarmFailureReason = (launch: RegistrationRunnerLaunch): string => { - const outcome = - launch.cleanupFailure !== undefined - ? "runner cleanup remains incomplete" - : launch.spawnError !== undefined - ? "spawn failed" - : launch.interruptedSignal !== undefined - ? `the package runner was stopped by ${launch.interruptedSignal}` - : launch.timedOut - ? `the package runner timed out after the bounded prewarm window` - : `exit ${launch.exitCode === null ? "unknown" : String(launch.exitCode)}`; - const normalized = ( - launch.cleanupFailure ?? - launch.spawnError ?? - launch.stderr - ) - .replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "") - .replace(/[\u0000-\u001f\u007f]+/gu, " ") - .replace(/\s+/gu, " ") - .trim(); - const detailLimit = PREWARM_REASON_LIMIT - outcome.length - 2; - const detail = - normalized.length > detailLimit - ? `… ${normalized.slice(-(detailLimit - 2))}` - : normalized; - const observed = `${outcome}${detail === "" ? "" : `: ${detail}`}`; - return `The pinned package-runner entry could not be materialized before the first client start (${observed}); the first client start will install it inside its startup deadline.`; -}; diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index f27e2f7eaa..695c66b83e 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -13,7 +13,6 @@ import type { AnalysisError, } from "../domain/analysisErrorBase.js"; import type { SetupClient } from "./SupportedClients.js"; -import type { SetupRegistrationPrewarm } from "./SetupRegistrationPrewarm.js"; import type { SetupFailureCode, SetupHopperInstallResult, @@ -143,10 +142,6 @@ export interface SetupHost { ): Promise>; installSkill(clientIds: readonly string[]): Promise; doctor(scope?: DoctorScope): Promise>>; - /** Best-effort materialization of the pinned package-runner cache entry - * before the first client start, so a one-time dependency install cannot - * consume a client's MCP startup deadline. Absent hosts skip the step. */ - prewarmRegistrationRunner?(): Promise; } /** Structured setup outcome carrying remediation instead of prompting. */ export interface SetupResult { @@ -166,9 +161,6 @@ export interface SetupResult { readonly code?: SetupFailureCode; /** Identified resources still retained after finalization; absent after confirmed release. */ readonly cleanup?: AnalysisCleanupObservation | undefined; - /** Best-effort pinned package-runner cache materialization; absent when the - * host offers no prewarm capability or setup finished before finalization. */ - readonly prewarm?: SetupRegistrationPrewarm; } /** Whether the requested setup actions still need approval or human repair. */ diff --git a/src/cli.ts b/src/cli.ts index 425a1f3ed4..1a4249cb7f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,8 @@ import { import { createLogger } from "./logger.js"; import { parseLogLevel } from "./config/processStart.js"; import { PRODUCT_IDENTITY } from "./identity.js"; -import { npxRegistrationCommand } from "./application/ClientRegistrationIdentity.js"; +import { currentClientRegistrationCommand } from "./application/ClientRegistrationIdentity.js"; +import { clientRegistrationCommandText } from "./cli/clientRegistrationCommandText.js"; import { registerSetupCommands } from "./cli/setupCommands.js"; import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js"; import { registerUtilityCommands } from "./cli/utilityCommands.js"; @@ -106,7 +107,9 @@ export const createCli = ( .describe("Write measured command timing to stderr."), }), mcp: { - command: npxRegistrationCommand().join(" "), + command: clientRegistrationCommandText( + currentClientRegistrationCommand({ environment }), + ), instructions: "Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.", }, diff --git a/src/cli/clientRegistrationCommandText.ts b/src/cli/clientRegistrationCommandText.ts new file mode 100644 index 0000000000..2aa864d244 --- /dev/null +++ b/src/cli/clientRegistrationCommandText.ts @@ -0,0 +1,5 @@ +/** Incur takes a command string and splits quoted segments without backslash escapes. */ +export const clientRegistrationCommandText = ( + command: readonly string[], +): string => + command.map((argument) => `'${argument.replaceAll("'", `'"'"'`)}'`).join(" "); diff --git a/src/cli/interactiveSetup.ts b/src/cli/interactiveSetup.ts index 2d2690abaf..9e73025e69 100644 --- a/src/cli/interactiveSetup.ts +++ b/src/cli/interactiveSetup.ts @@ -100,8 +100,6 @@ export const renderInteractiveSetupResult = ( .map(([client]) => clientDisplayNames.get(client) ?? client); const readyClients = readyAgentClients(result, changedClients); renderReadyCapabilities(result, readyClients, environment); - if (result.prewarm?.status === "failed") - writeLine(`! ${result.prewarm.reason}`); outro( changedClients.length === 0 ? "REA is ready for local app analysis." diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 00a2347709..5a5ed8d9ad 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "./application/ClientRegistrationIdentity.js"; import { UpdateCommandRunner } from "./application/UpdateCommandRunner.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -16,7 +17,6 @@ import { createSystemDoctorHost } from "./doctorRuntime.js"; import { PRODUCT_IDENTITY } from "./identity.js"; import { invokingPackageRunner, - packageRunnerRegistrationCommand, withoutPackageRunnerIdentity, } from "./application/ClientRegistrationIdentity.js"; import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; @@ -86,8 +86,8 @@ it("plans setup and uninstall against selected home and client overrides", async }), ]), ); - expect(host.registrationCommand).toContain( - PRODUCT_IDENTITY.registrationPackageSpecifier, + expect(host.registrationCommand).toEqual( + currentClientRegistrationCommand({ platform: "win32", environment }), ); const clients = await systemUninstallHost( environment.HOME, @@ -301,7 +301,7 @@ it.each([ createDoctorHostFixture({ platform: "win32" }), environment, ).registrationCommand, - ).toEqual(packageRunnerRegistrationCommand(runner, "win32")); + ).toEqual(currentClientRegistrationCommand({ platform: "win32" })); expect(withoutPackageRunnerIdentity(environment, "win32")).toEqual({ NPM_CONFIG_USER_AGENT: userAgent, }); @@ -378,7 +378,7 @@ it("registers the selected Windows package runner after canonicalizing environme ); try { expect(host.registrationCommand).toEqual( - packageRunnerRegistrationCommand("npx", "win32"), + currentClientRegistrationCommand({ platform: "win32" }), ); } finally { await host.close?.(); diff --git a/tests/acceptance/setup/codexToolPolicySetup.test.ts b/tests/acceptance/setup/codexToolPolicySetup.test.ts index 2fe7512f90..f63f05ab1c 100644 --- a/tests/acceptance/setup/codexToolPolicySetup.test.ts +++ b/tests/acceptance/setup/codexToolPolicySetup.test.ts @@ -1,5 +1,6 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { parse as parseToml } from "smol-toml"; import { expect } from "vitest"; @@ -15,7 +16,12 @@ cliTest( async ({ cli }) => { const home = await createTestTempDirectory("rea-codex-tool-policy-"); const config = join(home, ".codex/config.toml"); - const original = codexToolPolicySource("tables"); + const original = codexToolPolicySource( + "tables", + currentClientRegistrationCommand({ + entrypointPath: resolve("scripts/rea.mjs"), + }), + ); await mkdir(dirname(config), { recursive: true }); await writeFile(config, original); const run = (arguments_: readonly string[]) => diff --git a/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts b/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts index 796d1aa5f7..8f19c05f2b 100644 --- a/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts +++ b/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts @@ -1,10 +1,23 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { expect } from "vitest"; +import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +const assertSelectedLauncher = async (value: unknown) => { + const [runtime, entrypoint] = z + .tuple([z.string(), z.string(), z.literal("mcp")]) + .parse(value); + expect(isAbsolute(runtime)).toBe(true); + expect(isAbsolute(entrypoint)).toBe(true); + expect(await realpath(runtime)).toBe(await realpath(process.execPath)); + expect(await realpath(entrypoint)).toBe( + await realpath(resolve("scripts/rea.mjs")), + ); +}; + // JSON.parse, JSONC parse, and native Claude Code read the last duplicate. const shadowParent = '"mcpServers": {"shadow": {"command": "/usr/bin/false"}},'; const shadowLeaf = '"rea": {"command": "/usr/bin/false"},'; @@ -61,9 +74,17 @@ for (const [layout, original] of [ expect(configured).toContain( layout === "a repeated server table" ? shadowParent : shadowLeaf, ); - expect(JSON.parse(configured)).toMatchObject({ - mcpServers: { rea: { command: expect.not.stringMatching(/^\/usr/) } }, - }); + const registration = z + .object({ + mcpServers: z.object({ + rea: z.object({ command: z.string(), args: z.array(z.string()) }), + }), + }) + .parse(JSON.parse(configured)).mcpServers.rea; + await assertSelectedLauncher([ + registration.command, + ...registration.args, + ]); const repeated = await run(setupArguments("claude_code")); expect(repeated.exitCode, repeated.stdout).toBe(0); @@ -267,13 +288,13 @@ cliTest( mcp: Record & { servers: Record }; }; expect(Object.hasOwn(mcp, "rea")).toBe(false); - const launcher = resolve("scripts/rea.mjs"); expect(mcp.servers).toMatchObject({ peer: { command: ["/usr/bin/true"] }, - rea: { - type: "local", - command: [process.execPath, launcher, "mcp"], - }, + rea: { type: "local" }, }); + const registration = z + .object({ command: z.unknown() }) + .parse(mcp.servers.rea); + await assertSelectedLauncher(registration.command); }, ); diff --git a/tests/boundary/cli/clientProfiles.test.ts b/tests/boundary/cli/clientProfiles.test.ts index 700001753f..78e8e9c9fa 100644 --- a/tests/boundary/cli/clientProfiles.test.ts +++ b/tests/boundary/cli/clientProfiles.test.ts @@ -1,5 +1,6 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { access, mkdir, readFile, writeFile, rm } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { expect } from "vitest"; import { parse } from "jsonc-parser"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; @@ -482,12 +483,9 @@ cliTest( mcp: { rea: { type: "local", - command: [ - "npx", - "-y", - PRODUCT_IDENTITY.registrationPackageSpecifier, - "mcp", - ], + command: currentClientRegistrationCommand({ + entrypointPath: resolve("scripts/rea.mjs"), + }), enabled: true, }, }, @@ -541,12 +539,9 @@ cliTest( await mkdir(dirname(custom), { recursive: true }); const owned = { type: "local", - command: [ - "npx", - "-y", - PRODUCT_IDENTITY.registrationPackageSpecifier, - "mcp", - ], + command: currentClientRegistrationCommand({ + entrypointPath: resolve("scripts/rea.mjs"), + }), enabled: true, }; const original = JSON.stringify({ diff --git a/tests/boundary/cli/packageRunnerBoundInstallation.test.ts b/tests/boundary/cli/packageRunnerBoundInstallation.test.ts new file mode 100644 index 0000000000..05a5aaa6bf --- /dev/null +++ b/tests/boundary/cli/packageRunnerBoundInstallation.test.ts @@ -0,0 +1,220 @@ +import { + access, + mkdir, + readFile, + rename, + symlink, + writeFile, +} from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/client"; +import { + StdioClientTransport, + getDefaultEnvironment, +} from "@modelcontextprotocol/client/stdio"; +import { expect } from "vitest"; +import { z } from "zod"; +import { npmInvocation } from "../../../scripts/lib/npm-invocation.mjs"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const registrationSchema = z.object({ + mcpServers: z.object({ + rea: z.object({ command: z.string(), args: z.array(z.string()) }), + }), +}); + +cliTest.skipIf(process.platform === "win32")( + "real offline npm setup binds an installation across project starts and cache restoration", + async ({ processes }) => { + const root = await createTestTempDirectory("rea-real-npm-binding-"); + const home = join(root, "home"), + trusted = join(root, "trusted"), + project = join(root, "project"); + for (const path of [ + home, + trusted, + join(project, "node_modules", ".bin"), + join(project, "node_modules", PRODUCT_IDENTITY.packageName), + ]) + await mkdir(path, { recursive: true }); + const environment = { + ...process.env, + HOME: home, + USERPROFILE: home, + npm_config_cache: join(root, "cache"), + npm_config_prefix: join(root, "prefix"), + npm_config_userconfig: join(root, "user.npmrc"), + npm_config_globalconfig: join(root, "global.npmrc"), + npm_config_update_notifier: "false", + }; + const npm = npmInvocation( + [ + "exec", + "--offline", + "--ignore-scripts", + "--yes", + `--package=file:${resolve(".")}`, + "--", + "rea", + "setup", + "--client", + "omp", + "--no-skill", + "--yes", + "--json", + ], + environment, + ); + const setup = await processes.run(npm.command, npm.arguments, { + cwd: trusted, + env: environment, + timeoutMs: 30_000, + }); + expect(setup.exitCode, setup.stderr).toBe(0); + const configPath = join(home, ".omp", "agent", "mcp.json"); + const original = await readFile(configPath, "utf8"); + const registration = registrationSchema.parse(JSON.parse(original)) + .mcpServers.rea; + const alias = registration.args[0]; + expect(alias).toBeDefined(); + if (alias === undefined) throw new Error("Missing registered entrypoint"); + // Only remove an alias in this test's owned cache, never the source installation. + expect(alias.startsWith(join(root, "cache") + "/")).toBe(true); + expect(registration.args.slice(1)).toEqual(["mcp"]); + const marker = join(root, "project-candidate-called"); + const candidate = join( + project, + "node_modules", + PRODUCT_IDENTITY.packageName, + ); + await writeFile( + join(project, "package.json"), + JSON.stringify({ + name: "owned-registration-project", + version: "1.0.0", + dependencies: { + [PRODUCT_IDENTITY.packageName]: PRODUCT_IDENTITY.packageVersion, + }, + }), + ); + await writeFile( + join(candidate, "package.json"), + JSON.stringify({ + name: PRODUCT_IDENTITY.packageName, + version: PRODUCT_IDENTITY.packageVersion, + bin: { rea: "candidate.cjs" }, + }), + ); + await writeFile( + join(candidate, "candidate.cjs"), + `#!${process.execPath}\nrequire('node:fs').writeFileSync(${JSON.stringify(marker)}, 'unexpected');\n`, + { mode: 0o700 }, + ); + await symlink( + `../${PRODUCT_IDENTITY.packageName}/candidate.cjs`, + join(project, "node_modules", ".bin", "rea"), + ); + await writeFile(join(project, ".npmrc"), "registry=http://127.0.0.1:9/\n"); + const clientEnvironment = { + ...getDefaultEnvironment(), + HOME: home, + PATH: [ + join(project, "node_modules", ".bin"), + process.env.PATH ?? "", + ].join(delimiter), + }; + const connect = async () => { + const transport = new StdioClientTransport({ + ...registration, + cwd: project, + env: clientEnvironment, + stderr: "pipe", + }); + const client = new Client({ + name: "real-npm-registration", + version: "1", + }); + try { + await client.connect(transport); + expect(client.getServerVersion()?.version).toBe( + PRODUCT_IDENTITY.packageVersion, + ); + const status = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(status.isError).not.toBe(true); + expect(status.structuredContent).toMatchObject({ + result: { open: false }, + }); + } finally { + await client.close(); + await transport.close(); + } + }; + await connect(); + await rename(alias, `${alias}.removed`); + try { + const missing = await processes.run( + registration.command, + registration.args, + { cwd: project, env: clientEnvironment, timeoutMs: 10_000 }, + ); + expect(missing.exitCode).not.toBe(0); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rename(`${alias}.removed`, alias); + } + await connect(); + expect(await readFile(configPath, "utf8")).toBe(original); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); + }, + 60_000, +); + +cliTest.skipIf(process.platform === "win32")( + "mcp add preserves a selected entrypoint with spaces and both quote characters", + async ({ processes }) => { + const home = await createTestTempDirectory("rea-amp-command-"); + const bin = join(home, `bin 'quoted' "double"`); + await mkdir(bin); + const alias = join(bin, "rea"); + await symlink(resolve("scripts/rea.mjs"), alias); + const result = await processes.run( + process.execPath, + [alias, "mcp", "add", "--agent", "amp"], + { + cwd: home, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + PATH: [bin, process.env.PATH ?? ""].join(delimiter), + }, + timeoutMs: 20_000, + }, + ); + expect(result.exitCode, result.stderr).toBe(0); + const settings = z + .object({ + "amp.mcpServers": z.object({ + rea: z.object({ command: z.string(), args: z.array(z.string()) }), + }), + }) + .parse( + JSON.parse( + await readFile(join(home, ".config", "amp", "settings.json"), "utf8"), + ), + ); + expect(settings["amp.mcpServers"].rea.args).toEqual([alias, "mcp"]); + const start = await processes.run( + settings["amp.mcpServers"].rea.command, + [alias, "--version"], + { cwd: home, timeoutMs: 10_000 }, + ); + expect(start.exitCode, start.stderr).toBe(0); + expect(start.stdout).toContain(PRODUCT_IDENTITY.packageVersion); + }, +); diff --git a/tests/boundary/cli/packageRunnerRegistration.test.ts b/tests/boundary/cli/packageRunnerRegistration.test.ts index 0d3d0c97e5..cd842bad29 100644 --- a/tests/boundary/cli/packageRunnerRegistration.test.ts +++ b/tests/boundary/cli/packageRunnerRegistration.test.ts @@ -1,9 +1,9 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { delimiter, join } from "node:path"; +import { delimiter, join, resolve } from "node:path"; import { expect } from "vitest"; import { z } from "zod"; -import { packageRunnerRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; -import { PRODUCT_IDENTITY } from "../../../src/identity.js"; + import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -53,13 +53,13 @@ console.log("controlled runner fixture"); }; // Package runners set these variables for the bin they launch from a temporary cache. -// Only the prewarm child is controlled; each CLI still starts with its runner identity. +// Decoy runners record any accidental acquisition; setup retains its launch identity. for (const { runner, userAgent } of [ { runner: "npx", userAgent: "npm/10.9.0 node/v24.11.0 linux x64" }, { runner: "bunx", userAgent: "bun/1.4.2 npm/? node/v26.3.0 linux x64" }, ] as const) { cliTest( - `${runner} setup registers the pinned ${runner} launcher instead of its cache`, + `${runner} setup binds the selected installation without invoking another runner`, async ({ cli }) => { const home = await createTestTempDirectory("rea-package-runner-"); const controlledRunner = await controlledRunnerFixture(home); @@ -85,10 +85,8 @@ for (const { runner, userAgent } of [ "--json", ]); expect(setup.exitCode).toBe(0); - expect(setup.json).toMatchObject({ - status: "ready", - prewarm: { status: "warmed" }, - }); + expect(setup.json).toMatchObject({ status: "ready" }); + expect(setup.json).not.toHaveProperty("prewarm"); const configuration = ompConfigurationSchema.parse( JSON.parse( await readFile(join(home, ".omp", "agent", "mcp.json"), "utf8"), @@ -96,7 +94,10 @@ for (const { runner, userAgent } of [ ); const { command, args } = configuration.mcpServers.rea; expect([command, ...args]).toEqual( - packageRunnerRegistrationCommand(runner, process.platform), + currentClientRegistrationCommand({ + entrypointPath: resolve("scripts/rea.mjs"), + environment: controlledRunner.environment, + }), ); const doctor = await run(["doctor", "--client", "omp", "--json"]); expect(doctor.json).toMatchObject({ @@ -111,23 +112,7 @@ for (const { runner, userAgent } of [ "--json", ]); expect(repeat.json).toMatchObject({ plannedActions: [] }); - const calls = (await readFile(controlledRunner.log, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(calls).toEqual([ - { - runner, - arguments: [ - ...(runner === "npx" ? ["-y"] : []), - PRODUCT_IDENTITY.registrationPackageSpecifier, - "--version", - ], - home, - npm_command: "exec", - user_agent: userAgent, - }, - ]); + expect(await readFile(controlledRunner.log, "utf8")).toBe(""); }, ); } diff --git a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts index 1bfaafffd8..898cc3ff7f 100644 --- a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts +++ b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { access, chmod, @@ -14,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../../src/application/ClaudeDesktopConfigLocation.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration, inspectClientConfiguration, @@ -33,7 +34,7 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; /** The Windows registration setup writes, independent of the test host. */ -const command = npxRegistrationCommand("win32"); +const command = currentClientRegistrationCommand({ platform: "win32" }); const CONFIG = "claude_desktop_config.json"; beforeEach(clearClientLocationEnvironment); @@ -192,7 +193,7 @@ describe("Windows Claude Desktop configuration location", () => { (await detectClients(home, "win32", env)).map(({ name }) => name), ).not.toContain("claude_desktop"); const status = ( - await readClientRegistrationStatuses(home, "unused", { + await readClientRegistrationStatuses(home, command[1], { platform: "win32", environment: env, }) @@ -306,7 +307,7 @@ it("preserves explicit AppData root whitespace throughout setup, doctor and unin backupPath: `${privateConfig}.rea.backup`, }); const status = ( - await readClientRegistrationStatuses(home, "unused", { + await readClientRegistrationStatuses(home, command[1], { platform: "win32", environment: selected, }) @@ -341,7 +342,7 @@ describe("MSIX Claude Desktop registration lifecycle", () => { const appDataOriginal = await readFile(appDataConfig, "utf8"); const status = async () => ( - await readClientRegistrationStatuses(home, "unused", { + await readClientRegistrationStatuses(home, command[1], { platform: "win32", environment: env, }) diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts index 42f5249174..67742dd41e 100644 --- a/tests/boundary/filesystem/clientConfigurationDialects.test.ts +++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; @@ -20,12 +21,9 @@ import { normalizeEntrypointPath, } from "../../../src/process/CurrentEntrypointPath.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { - clearClientLocationEnvironment, - NPX_REGISTRATION_COMMAND, -} from "../../fixtures/clientEnvironment.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; -const command = NPX_REGISTRATION_COMMAND; +const command = currentClientRegistrationCommand(); beforeEach(clearClientLocationEnvironment); @@ -141,7 +139,7 @@ describe("additional client configuration dialects", () => { if (name === "vscode" || name === "omp" || name === "pi") expect(registration).toMatchObject({ type: "stdio", - command: "npx", + command: command[0], args: command.slice(1), }); if (name === "copilot_cli") @@ -150,7 +148,7 @@ describe("additional client configuration dialects", () => { expect(registration).toMatchObject({ transport: "stdio", enabled: true, - command: "npx", + command: command[0], args: command.slice(1), }); diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index 5b61d8786d..5e0afcf89b 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; @@ -22,7 +23,7 @@ import { clientRegistrationEnvironmentDetailsWithBudget, } from "../../../src/application/ClientRegistrationEnvironment.js"; import { snapshotEnvironment } from "../../../src/process/snapshotEnvironment.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { ok } from "../../../src/domain/result.js"; import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js"; @@ -129,16 +130,20 @@ it.each(["win32", "linux"] as const)( mcpServers: { rea: clientRegistrationEntry( "json", - npxRegistrationCommand(platform), + currentClientRegistrationCommand({ platform }), { rea_analysis_provider: "invalid provider" }, ), }, }), ); - const statuses = await readClientRegistrationStatuses(home, "rea", { - platform, - environment: {}, - }); + const statuses = await readClientRegistrationStatuses( + home, + currentClientRegistrationCommand({ platform })[1], + { + platform, + environment: {}, + }, + ); expect(statuses.find(({ client }) => client === "cursor")).toMatchObject({ state: platform === "win32" ? "invalid" : "aligned", }); @@ -221,12 +226,9 @@ it.skipIf(process.platform !== "win32")( }), ); const client = { name: "kimi", format: "kimi", configPath } as const; + const command = currentClientRegistrationCommand(); expect( - await configureClientConfiguration( - client, - {}, - npxRegistrationCommand(process.platform), - ), + await configureClientConfiguration(client, {}, command), ).toMatchObject({ status: "configured" }); const parsed = parseClientConfiguration( await readFile(configPath, "utf8"), @@ -235,7 +237,7 @@ it.skipIf(process.platform !== "win32")( expect(effectiveClientServer(parsed, "rea")).toHaveProperty("env", { rea_mcp_input_schema_profile: "full", }); - const statuses = await readClientRegistrationStatuses(home, "rea", { + const statuses = await readClientRegistrationStatuses(home, command[1], { environment: {}, }); expect(statuses.find(({ client }) => client === "kimi")).toMatchObject({ @@ -898,3 +900,20 @@ it.each([ }); }, ); + +it("refuses an unidentified launch command before creating a client configuration", async () => { + const home = await createTestTempDirectory("rea-registration-unidentified-"); + const configPath = join(home, "missing", "mcp.json"); + const result = await configureClientConfiguration( + { name: "claude_code", configPath, format: "json" }, + {}, + [], + ); + expect(result).toMatchObject({ + status: "failed", + reason: "registration_ownership", + }); + await expect(access(join(home, "missing"))).rejects.toMatchObject({ + code: "ENOENT", + }); +}); diff --git a/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts index 196a925332..5fe1a046ff 100644 --- a/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -12,7 +13,7 @@ import { clientConfigurationAligned, } from "../../../src/application/SetupClientConfiguration.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { effectiveClientConfiguration, @@ -20,7 +21,9 @@ import { } from "../../../src/application/ClientConfigurationFiles.js"; import { clientRegistrationEnvironmentDetails } from "../../../src/application/ClientRegistrationEnvironment.js"; -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); const language = "x86:LE:32:default"; const compiler = "borlandcpp"; @@ -129,7 +132,7 @@ const layered = async (lower: unknown, upper: unknown) => { } as const; const status = async () => ( - await readClientRegistrationStatuses(home, "rea", { + await readClientRegistrationStatuses(home, command[1], { environment: { XDG_CONFIG_HOME: join(home, ".config") }, }) ).find(({ client }) => client === "opencode"); diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts index 117e8ec33f..4e76e62b53 100644 --- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts +++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts @@ -11,7 +11,10 @@ import { readClientRegistrationStatuses, } from "../../../src/application/ClientRegistrationStatus.js"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; -import { classifyClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { + currentClientRegistrationCommand, + classifyClientRegistrationCommand, +} from "../../../src/application/ClientRegistrationIdentity.js"; import { existingMaintenanceScope } from "../../../src/application/UpdateMaintenance.js"; import { ConfigurationError } from "../../../src/domain/configurationErrors.js"; import { err } from "../../../src/domain/result.js"; @@ -24,7 +27,7 @@ afterEach(() => { }); describe("client registration status", () => { - it("discovers Claude Code from its config file without a marker directory", async () => { + it("discovers a stale legacy Claude Code registration without a marker directory", async () => { const home = await createTestTempDirectory("rea-claude-config-only-"); await writeFile( join(home, ".claude.json"), @@ -44,8 +47,8 @@ describe("client registration status", () => { expect.objectContaining({ client: "claude_code", config_path: join(home, ".claude.json"), - state: "aligned", - remediation: null, + state: "stale", + remediation: expect.stringMatching(/\S/u), }), ]); }); @@ -56,6 +59,10 @@ describe("client registration status", () => { const foreignEntry = join(home, "foreign-rea.mjs"); await writeFile(currentEntry, "// current dispatcher\n"); await writeFile(foreignEntry, "// different dispatcher\n"); + const currentCommand = currentClientRegistrationCommand({ + entrypointPath: currentEntry, + environment: {}, + }); await Promise.all([ mkdir(join(home, ".codex")), mkdir(join(home, ".cursor")), @@ -64,7 +71,7 @@ describe("client registration status", () => { ]); await writeFile( join(home, ".codex/config.toml"), - `[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "${PRODUCT_IDENTITY.registrationPackageSpecifier}", "mcp"]\nstartup_timeout_sec = 30\ntool_timeout_sec = 450\n`, + `[mcp_servers.rea]\ncommand = ${JSON.stringify(currentCommand[0])}\nargs = ${JSON.stringify(currentCommand.slice(1))}\nstartup_timeout_sec = 30\ntool_timeout_sec = 450\n`, ); await writeFile( join(home, ".cursor/mcp.json"), diff --git a/tests/boundary/filesystem/codexClientPolicy.test.ts b/tests/boundary/filesystem/codexClientPolicy.test.ts index 982db18549..926cedae33 100644 --- a/tests/boundary/filesystem/codexClientPolicy.test.ts +++ b/tests/boundary/filesystem/codexClientPolicy.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { expect, it } from "vitest"; @@ -8,7 +9,7 @@ import { configureClientConfiguration, inspectClientConfiguration, } from "../../../src/application/SetupClientConfiguration.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { @@ -33,14 +34,18 @@ it.each(["tables", "inline table"] as const)( ).find(({ client }) => client === "codex"); expect(await status()).toMatchObject({ state: "aligned" }); expect( - await inspectClientConfiguration(client, {}, npxRegistrationCommand()), + await inspectClientConfiguration( + client, + {}, + currentClientRegistrationCommand(), + ), ).toEqual({ status: "already_current" }); const overrides = { REA_MCP_INPUT_SCHEMA_PROFILE: "full" }; expect( await configureClientConfiguration( client, overrides, - npxRegistrationCommand(), + currentClientRegistrationCommand(), ), ).toMatchObject({ status: "configured" }); const configured = await readFile(configPath, "utf8"); @@ -61,7 +66,7 @@ it.each(["tables", "inline table"] as const)( await configureClientConfiguration( client, overrides, - npxRegistrationCommand(), + currentClientRegistrationCommand(), ), ).toEqual({ status: "unchanged" }); expect(await readFile(configPath, "utf8")).toBe(configured); @@ -102,14 +107,18 @@ it.each([ const inspection = await inspectClientConfiguration( client, {}, - npxRegistrationCommand(), + currentClientRegistrationCommand(), ); expect(inspection).toMatchObject({ status: "invalid", remediation: expect.stringContaining(field), }); expect( - await configureClientConfiguration(client, {}, npxRegistrationCommand()), + await configureClientConfiguration( + client, + {}, + currentClientRegistrationCommand(), + ), ).toEqual({ status: "failed", reason: "readback" }); const statuses = await readClientRegistrationStatuses(home, undefined, { environment: {}, @@ -129,11 +138,12 @@ it.each([{ tools: ["binary_session"] }, { tools: ["*"] }, { tools: [] }])( const home = await createTestTempDirectory("rea-copilot-tools-policy-"); const configPath = join(home, ".copilot/mcp-config.json"); await mkdir(dirname(configPath)); + const command = currentClientRegistrationCommand(); const original = JSON.stringify({ mcpServers: { rea: { - command: "npx", - args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + command: command[0], + args: command.slice(1), type: "stdio", tools, future_policy: { requireReview: true }, @@ -155,11 +165,7 @@ it.each([{ tools: ["binary_session"] }, { tools: ["*"] }, { tools: [] }])( expect(await status()).toMatchObject({ state: "aligned" }); const overrides = { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" }; expect( - await configureClientConfiguration( - client, - overrides, - npxRegistrationCommand(), - ), + await configureClientConfiguration(client, overrides, command), ).toMatchObject({ status: "configured" }); expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ mcpServers: { @@ -171,11 +177,7 @@ it.each([{ tools: ["binary_session"] }, { tools: ["*"] }, { tools: [] }])( }, }); expect( - await configureClientConfiguration( - client, - overrides, - npxRegistrationCommand(), - ), + await configureClientConfiguration(client, overrides, command), ).toEqual({ status: "unchanged" }); expect(await status()).toMatchObject({ state: "aligned" }); expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); diff --git a/tests/boundary/filesystem/droidClientConfiguration.test.ts b/tests/boundary/filesystem/droidClientConfiguration.test.ts index 992113d8d0..9477300e3e 100644 --- a/tests/boundary/filesystem/droidClientConfiguration.test.ts +++ b/tests/boundary/filesystem/droidClientConfiguration.test.ts @@ -1,10 +1,11 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; @@ -16,7 +17,9 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment * The registration command setup writes on the host running the tests. Windows * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. */ -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); diff --git a/tests/boundary/filesystem/dshClientConfiguration.test.ts b/tests/boundary/filesystem/dshClientConfiguration.test.ts index 72aaef7eb1..d775ca2378 100644 --- a/tests/boundary/filesystem/dshClientConfiguration.test.ts +++ b/tests/boundary/filesystem/dshClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -11,7 +12,7 @@ import { } from "yaml"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration, inspectClientConfiguration, @@ -28,7 +29,9 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment * The registration command setup writes on the host running the tests. Windows * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. */ -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); diff --git a/tests/boundary/filesystem/freebuffClientConfiguration.test.ts b/tests/boundary/filesystem/freebuffClientConfiguration.test.ts index 37e750beac..4261ce58d7 100644 --- a/tests/boundary/filesystem/freebuffClientConfiguration.test.ts +++ b/tests/boundary/filesystem/freebuffClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { constants as fsConstants } from "node:fs"; import { copyFile, @@ -14,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { ClientConfigurationPublication } from "../../../src/application/ClientConfigurationPublication.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { detectClients } from "../../../src/application/SetupHost.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js"; @@ -35,7 +36,9 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment * The registration command setup writes on the host running the tests. Windows * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. */ -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); diff --git a/tests/boundary/filesystem/hermesClientConfiguration.test.ts b/tests/boundary/filesystem/hermesClientConfiguration.test.ts index 7061eade2c..b4814b3f26 100644 --- a/tests/boundary/filesystem/hermesClientConfiguration.test.ts +++ b/tests/boundary/filesystem/hermesClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { userInfo } from "node:os"; @@ -6,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parse as parseYaml } from "yaml"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration, inspectClientConfiguration, @@ -26,7 +27,9 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment * The registration command setup writes on the host running the tests. Windows * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. */ -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); diff --git a/tests/boundary/filesystem/kimiClientConfiguration.test.ts b/tests/boundary/filesystem/kimiClientConfiguration.test.ts index 22493bcbfc..344ca4a209 100644 --- a/tests/boundary/filesystem/kimiClientConfiguration.test.ts +++ b/tests/boundary/filesystem/kimiClientConfiguration.test.ts @@ -1,10 +1,11 @@ +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { setupRegistrationCommand } from "../../../src/application/SetupHost.js"; +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { clientSkillDirectories, @@ -14,9 +15,8 @@ import { systemUninstallHost } from "../../../src/application/Uninstall.js"; import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; -const command = npxRegistrationCommand(); +const command = currentClientRegistrationCommand(); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); @@ -366,10 +366,28 @@ describe("Kimi Code package launchers", () => { ["npx", "win32"], ["bunx", "win32"], ] as const)( - "keeps the %s launcher aligned on %s", + "migrates the legacy %s launcher on %s", async (runner, platform) => { const { home, client } = await kimiClient(); - const launcher = setupRegistrationCommand(platform, runner, undefined); + const pinned = PRODUCT_IDENTITY.registrationPackageSpecifier; + const previous = + runner === "npx" + ? ["npx", "-y", pinned, "mcp"] + : ["bunx", pinned, "mcp"]; + const legacy = + platform === "win32" ? ["cmd.exe", "/d", "/c", ...previous] : previous; + expect( + (await configureClientConfiguration(client, {}, legacy)).status, + ).toBe("configured"); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform, + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "stale" }); + const launcher = currentClientRegistrationCommand({ platform }); expect( (await configureClientConfiguration(client, {}, launcher)).status, ).toBe("configured"); diff --git a/tests/boundary/filesystem/ompClientConfiguration.test.ts b/tests/boundary/filesystem/ompClientConfiguration.test.ts index bb5928a8d7..c1b6ff624e 100644 --- a/tests/boundary/filesystem/ompClientConfiguration.test.ts +++ b/tests/boundary/filesystem/ompClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -9,12 +10,9 @@ import { configureClientConfiguration } from "../../../src/application/SetupClie import { supportedClients } from "../../../src/application/SupportedClients.js"; import { systemUninstallHost } from "../../../src/application/Uninstall.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { - clearClientLocationEnvironment, - NPX_REGISTRATION_COMMAND, -} from "../../fixtures/clientEnvironment.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; -const command = NPX_REGISTRATION_COMMAND; +const command = currentClientRegistrationCommand(); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); @@ -93,7 +91,7 @@ describe("OMP disabledServers denylist", () => { expect(parseJsonc(text)).toEqual({ mcpServers: { other: { command: "other-server" }, - rea: { type: "stdio", command: "npx", args: command.slice(1) }, + rea: { type: "stdio", command: command[0], args: command.slice(1) }, }, disabledServers: ["other"], }); @@ -111,7 +109,7 @@ describe("OMP disabledServers denylist", () => { it("reports a registration hidden by disabledServers as stale", async () => { const { home, client } = await ompClient(); - const entry = resolve("scripts/rea.mjs"); + const entry = command[1]; const registration = { command: process.execPath, args: [entry, "mcp"], diff --git a/tests/boundary/filesystem/piClientConfiguration.test.ts b/tests/boundary/filesystem/piClientConfiguration.test.ts index bec8a7d086..57da28cfbc 100644 --- a/tests/boundary/filesystem/piClientConfiguration.test.ts +++ b/tests/boundary/filesystem/piClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -13,10 +14,7 @@ import { detectClients } from "../../../src/application/SetupHost.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; import { systemUninstallHost } from "../../../src/application/Uninstall.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { - clearClientLocationEnvironment, - NPX_REGISTRATION_COMMAND, -} from "../../fixtures/clientEnvironment.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); @@ -29,6 +27,8 @@ const piPath = ( // Catch fallback to OMP paths, loss of relative overrides, and normalization // that differs from Pi's public getAgentDir()/normalizePath boundary. +const command = currentClientRegistrationCommand(); + describe("Pi user configuration paths", () => { it.each([ [undefined, "/home/a/.pi/agent"], @@ -139,7 +139,7 @@ describe("Pi registration semantics", () => { }\n`; await writeFile(client.configPath, original); expect( - await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND), + await configureClientConfiguration(client, {}, command), ).toMatchObject({ status: "configured" }); expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( original, @@ -151,16 +151,16 @@ describe("Pi registration semantics", () => { other: { command: "other-server" }, rea: { type: "stdio", - command: "npx", - args: NPX_REGISTRATION_COMMAND.slice(1), + command: command[0], + args: command.slice(1), }, }, enabledServers: ["other"], disabledServers: ["rea", "other"], }); - expect( - await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND), - ).toEqual({ status: "unchanged" }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); expect(await readFile(client.configPath, "utf8")).toBe(configured); expect( ( @@ -197,8 +197,8 @@ describe("Pi registration semantics", () => { JSON.stringify({ mcpServers: { rea: { - command: "npx", - args: NPX_REGISTRATION_COMMAND.slice(1), + command: command[0], + args: command.slice(1), type, enabled, }, @@ -240,15 +240,12 @@ describe("Pi strict JSON parsing", () => { await writeFile(client.configPath, original); expect(() => parseClientConfiguration(original, "pi")).toThrow(); expect( - await inspectClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND), + await inspectClientConfiguration(client, {}, command), ).toMatchObject({ status: "invalid" }); - expect( - await configureClientConfiguration( - client, - {}, - NPX_REGISTRATION_COMMAND, - ), - ).toEqual({ status: "failed", reason: "readback" }); + expect(await configureClientConfiguration(client, {})).toEqual({ + status: "failed", + reason: "readback", + }); expect(await readFile(client.configPath, "utf8")).toBe(original); await expect( readFile(`${client.configPath}.rea.backup`), @@ -279,11 +276,11 @@ describe("Pi strict JSON parsing", () => { await expect(readFile(client.configPath)).rejects.toMatchObject({ code: "ENOENT", }); + expect(await inspectClientConfiguration(client, {}, command)).toEqual({ + status: "create", + }); expect( - await inspectClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND), - ).toEqual({ status: "create" }); - expect( - await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND), + await configureClientConfiguration(client, {}, command), ).toMatchObject({ status: "configured" }); const created = await readFile(client.configPath, "utf8"); expect(() => JSON.parse(created)).not.toThrow(); @@ -291,17 +288,17 @@ describe("Pi strict JSON parsing", () => { mcpServers: { rea: { type: "stdio", - command: "npx", - args: NPX_REGISTRATION_COMMAND.slice(1), + command: command[0], + args: command.slice(1), }, }, }); await expect( readFile(`${client.configPath}.rea.backup`), ).rejects.toMatchObject({ code: "ENOENT" }); - expect( - await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND), - ).toEqual({ status: "unchanged" }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); expect(await readFile(client.configPath, "utf8")).toBe(created); expect( ( diff --git a/tests/boundary/filesystem/qoderClientConfiguration.test.ts b/tests/boundary/filesystem/qoderClientConfiguration.test.ts index 0621dfdc08..eec5e594f6 100644 --- a/tests/boundary/filesystem/qoderClientConfiguration.test.ts +++ b/tests/boundary/filesystem/qoderClientConfiguration.test.ts @@ -1,10 +1,11 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; @@ -16,7 +17,9 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment * The registration command setup writes on the host running the tests. Windows * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. */ -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); diff --git a/tests/boundary/filesystem/qwenClientConfiguration.test.ts b/tests/boundary/filesystem/qwenClientConfiguration.test.ts index b915288fd3..05fc3f1ae1 100644 --- a/tests/boundary/filesystem/qwenClientConfiguration.test.ts +++ b/tests/boundary/filesystem/qwenClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -12,12 +13,9 @@ import { supportedClients } from "../../../src/application/SupportedClients.js"; import { systemUninstallHost } from "../../../src/application/Uninstall.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { - clearClientLocationEnvironment, - NPX_REGISTRATION_COMMAND, -} from "../../fixtures/clientEnvironment.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; -const command = NPX_REGISTRATION_COMMAND; +const command = currentClientRegistrationCommand(); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); @@ -98,7 +96,7 @@ describe("Qwen Code client lifecycle", () => { model: { name: "qwen3-coder-plus" }, mcpServers: { other: { command: "other-server" }, - rea: { command: "npx", args: command.slice(1) }, + rea: { command: command[0], args: command.slice(1) }, }, }); expect(await configureClientConfiguration(client, {}, command)).toEqual({ @@ -116,12 +114,12 @@ describe("Qwen Code client lifecycle", () => { expect(removedText).toContain("// Keep this note."); }); - it("reports a package-runner registration as aligned", async () => { + it("reports the selected installation as aligned", async () => { const { home, client } = await qwenClient(); expect( (await configureClientConfiguration(client, {}, command)).status, ).toBe("configured"); - const entry = resolve("scripts/rea.mjs"); + const entry = command[1]; const status = ( await readClientRegistrationStatuses(home, entry, { environment: {} }) ).find(({ client: name }) => name === "qwen_code"); @@ -167,11 +165,9 @@ describe("Qwen Code profile setup", () => { }); expect( ( - await readClientRegistrationStatuses( - home, - resolve("scripts/rea.mjs"), - { environment }, - ) + await readClientRegistrationStatuses(home, command[1], { + environment, + }) ).find(({ client: name }) => name === "qwen_code"), ).toMatchObject({ state: "aligned" }); const uninstall = systemUninstallHost(home, undefined, environment); diff --git a/tests/boundary/filesystem/setupConfig.test.ts b/tests/boundary/filesystem/setupConfig.test.ts index 373e0ada19..9612e19e2d 100644 --- a/tests/boundary/filesystem/setupConfig.test.ts +++ b/tests/boundary/filesystem/setupConfig.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { chmod, lstat, @@ -24,10 +25,9 @@ import { resolveWindowsClaudeDesktopLocation } from "../../../src/application/Cl import { supportedClients } from "../../../src/application/SupportedClients.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { failedConfigurationMessage } from "../../../src/application/SetupClients.js"; -import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js"; -const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier; +const registrationCommand = currentClientRegistrationCommand(); it.skipIf(process.platform === "win32")( "preserves an aligned caller launcher alias without writes or a backup", @@ -144,7 +144,7 @@ it.skipIf(process.platform === "win32").each([false, true])( }), ).toMatchObject({ status: "configured" }); expect(JSON.parse(await readFile(selected, "utf8"))).toMatchObject({ - mcpServers: { rea: { command: "npx" } }, + mcpServers: { rea: { command: registrationCommand[0] } }, }); expect(await readFile(unrelated, "utf8")).toBe(original); await expect(lstat(`${unrelated}.rea.backup`)).rejects.toMatchObject({ @@ -263,8 +263,8 @@ describe("JSON client configuration transaction: writing configuration through s mcpServers: { other: { command: "other" }, rea: { - command: "npx", - args: ["-y", pinnedPackage, "mcp"], + command: registrationCommand[0], + args: registrationCommand.slice(1), }, }, }); @@ -291,8 +291,8 @@ describe("JSON client configuration transaction: writing configuration through s theme: "dark", mcpServers: { rea: { - command: "npx", - args: ["-y", pinnedPackage, "mcp"], + command: registrationCommand[0], + args: registrationCommand.slice(1), }, }, }); @@ -335,7 +335,7 @@ describe("JSON client configuration transaction: keeping matching configuration const configPath = join(directory, "mcp.json"); await writeFile( configPath, - `${JSON.stringify({ mcpServers: { rea: { command: "npx", args: ["-y", pinnedPackage, "mcp"] } } })}\n`, + `${JSON.stringify({ mcpServers: { rea: { command: registrationCommand[0], args: registrationCommand.slice(1) } } })}\n`, ); expect( await configureClientConfiguration({ @@ -363,8 +363,8 @@ describe("JSON client configuration transaction: keeping matching configuration JAVA_HOME: "/usr/lib/jvm/jdk-21", GHIDRA_INSTALL_DIR: "/opt/ghidra", }, - args: ["-y", pinnedPackage, "mcp"], - command: "npx", + args: registrationCommand.slice(1), + command: registrationCommand[0], }, }, })}\n`, @@ -405,8 +405,8 @@ describe("JSON client configuration migration and provider paths", () => { theme: "dark", mcpServers: { rea: { - command: "npx", - args: ["-y", pinnedPackage, "mcp"], + command: registrationCommand[0], + args: registrationCommand.slice(1), }, other: { command: "other" }, }, @@ -431,8 +431,8 @@ describe("JSON client configuration migration and provider paths", () => { expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ mcpServers: { rea: { - command: "npx", - args: ["-y", pinnedPackage, "mcp"], + command: registrationCommand[0], + args: registrationCommand.slice(1), env: { HOPPER_LAUNCHER_PATH: hopperPath }, }, }, diff --git a/tests/boundary/filesystem/setupLauncherAlias.test.ts b/tests/boundary/filesystem/setupLauncherAlias.test.ts index 6adf468f6d..21ca31ba5e 100644 --- a/tests/boundary/filesystem/setupLauncherAlias.test.ts +++ b/tests/boundary/filesystem/setupLauncherAlias.test.ts @@ -10,7 +10,7 @@ import { import { dirname, join } from "node:path"; import { expect, it } from "vitest"; -import { setupRegistrationCommand } from "../../../src/application/SetupHost.js"; +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { classifyClientRegistrationCommand, registrationRuntimePath, @@ -42,8 +42,10 @@ it.skipIf(process.platform === "win32")( await symlink(old, latest, "dir"); const unstable = join(latest, scriptWithin("old")); const alias = join(latest, "bin", "rea"); - const command = setupRegistrationCommand("linux", undefined, unstable, { - PATH: join(latest, "bin"), + const command = currentClientRegistrationCommand({ + platform: "linux", + entrypointPath: unstable, + environment: { PATH: join(latest, "bin") }, }); expect(command).toEqual([process.execPath, alias, "mcp"]); expect(classifyClientRegistrationCommand(command, unstable)).toBe("owned"); @@ -138,7 +140,11 @@ it.skipIf(process.platform === "win32")( ), ).toBe(join(valid, runtime)); expect( - setupRegistrationCommand("linux", undefined, script, { PATH: valid }), + currentClientRegistrationCommand({ + platform: "linux", + entrypointPath: script, + environment: { PATH: valid }, + }), ).toEqual([join(valid, runtime), script, "mcp"]); expect( registrationRuntimePath( @@ -242,18 +248,24 @@ it.skipIf(process.platform === "win32")( await writeFile(selected, "selected", { mode: 0o600 }); await symlink(selected, join(unexecutable, "rea")); expect( - setupRegistrationCommand("linux", undefined, entrypoint, { - PATH: `${foreign}:${missing}:relative`, + currentClientRegistrationCommand({ + platform: "linux", + entrypointPath: entrypoint, + environment: { PATH: `${foreign}:${missing}:relative` }, }), ).toEqual([process.execPath, entrypoint, "mcp"]); expect( - setupRegistrationCommand("linux", undefined, selected, { - PATH: unexecutable, + currentClientRegistrationCommand({ + platform: "linux", + entrypointPath: selected, + environment: { PATH: unexecutable }, }), ).toEqual([process.execPath, selected, "mcp"]); expect( - setupRegistrationCommand("win32", undefined, entrypoint, { - PATH: foreign, + currentClientRegistrationCommand({ + platform: "win32", + entrypointPath: entrypoint, + environment: { PATH: foreign }, }), ).toEqual([process.execPath, entrypoint, "mcp"]); }, diff --git a/tests/boundary/filesystem/setupPackageRunnerBinding.test.ts b/tests/boundary/filesystem/setupPackageRunnerBinding.test.ts new file mode 100644 index 0000000000..0fd7adc029 --- /dev/null +++ b/tests/boundary/filesystem/setupPackageRunnerBinding.test.ts @@ -0,0 +1,32 @@ +import { isAbsolute } from "node:path"; +import { expect, it } from "vitest"; + +import { systemSetupHost } from "../../../src/application/SetupHost.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each(["npm/11.16.0 node/v24.22.1", "bun/1.4.2"])( + "binds setup invoked by %s to the already selected installation", + async (userAgent) => { + const home = await createTestTempDirectory("rea-runner-binding-"); + const environment = { + HOME: home, + USERPROFILE: home, + PATH: process.env.PATH, + }; + const direct = systemSetupHost(undefined, environment); + const runner = systemSetupHost(undefined, { + ...environment, + npm_command: "exec", + npm_config_user_agent: userAgent, + }); + try { + expect(runner.registrationCommand).toEqual(direct.registrationCommand); + expect(isAbsolute(runner.registrationCommand[0]!)).toBe(true); + expect(isAbsolute(runner.registrationCommand[1]!)).toBe(true); + expect(runner.registrationCommand.at(-1)).toBe("mcp"); + } finally { + await runner.close?.(); + await direct.close?.(); + } + }, +); diff --git a/tests/boundary/filesystem/setupRegistrationPrewarm.test.ts b/tests/boundary/filesystem/setupRegistrationPrewarm.test.ts deleted file mode 100644 index 37b1300725..0000000000 --- a/tests/boundary/filesystem/setupRegistrationPrewarm.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { mkdir } from "node:fs/promises"; -import { dirname } from "node:path"; -import { expect, it } from "vitest"; - -import { createDoctorHostFixture } from "../../../src/application/Doctor.fixture.js"; -import { systemSetupHost } from "../../../src/application/SetupHost.js"; -import { runSetup } from "../../../src/application/Setup.js"; -import { options } from "../../../src/application/Setup.fixture.js"; -import type { SetupRegistrationPrewarm } from "../../../src/application/SetupRegistrationPrewarm.js"; -import { supportedClients } from "../../../src/application/SupportedClients.js"; -import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; - -/** - * A package-runner launch environment that also satisfies the fixture's - * Hopper launcher probe, so setup reports readiness instead of proposing a - * Hopper installation. - */ -const packageRunnerEnvironment = { - npm_command: "exec", - npm_config_user_agent: "npm/11.16.0 node/v24.14.0", - HOPPER_LAUNCHER_PATH: "/fixture/Hopper", -} as const; - -it("setup prewarms the pinned runner cache once after configuring clients", async () => { - const root = await createTestTempDirectory("rea-setup-prewarm-"); - const client = supportedClients(root, "darwin", {}).find( - (candidate) => candidate.name === "cursor", - ); - if (client === undefined) throw new Error("Cursor registration is missing"); - await mkdir(dirname(client.configPath), { recursive: true }); - const doctor = createDoctorHostFixture({ - clientRegistrations: () => - readClientRegistrationStatuses(root, undefined, { - platform: "darwin", - environment: {}, - }), - providerInspections: () => - Promise.resolve([ - { - id: "hopper", - configured: true, - available: false, - providerVersion: "6", - registrationEnvironment: {}, - checks: [ - { - name: "hopper-license", - ok: false, - code: "unavailable", - detail: "Runtime license is unavailable", - classification: "missing_analysis_engine", - remediation: null, - }, - ], - }, - ]), - }); - let prewarmCalls = 0; - const prewarmResult: SetupRegistrationPrewarm = { status: "warmed" }; - const host = { - ...systemSetupHost(doctor, packageRunnerEnvironment), - // The host reports the developer machine's Node; pin a supported one - // so readiness does not depend on the local runtime. - nodeVersion: "24.22.1", - detectedClients: () => Promise.resolve([client]), - supportedClients: () => Promise.resolve([client]), - prewarmRegistrationRunner: () => { - prewarmCalls += 1; - return Promise.resolve(prewarmResult); - }, - }; - const result = await runSetup( - { - ...options(true), - clientIds: ["cursor"], - installSkill: false, - proposeHopper: false, - readinessScope: { clients: [], providers: [], skill: false }, - }, - host, - ); - expect(result.status).toBe("ready"); - expect(result.clients.cursor).toMatchObject({ status: "configured" }); - expect(prewarmCalls).toBe(1); - expect(result.prewarm).toEqual(prewarmResult); -}); - -it("keeps setup ready when registration prewarm fails, with a warning progress event", async () => { - const root = await createTestTempDirectory("rea-setup-prewarm-fail-"); - const client = supportedClients(root, "darwin", {}).find( - (candidate) => candidate.name === "cursor", - ); - if (client === undefined) throw new Error("Cursor registration is missing"); - await mkdir(dirname(client.configPath), { recursive: true }); - const doctor = createDoctorHostFixture({ - clientRegistrations: () => - readClientRegistrationStatuses(root, undefined, { - platform: "darwin", - environment: {}, - }), - providerInspections: () => Promise.resolve([]), - }); - const progress: { state: string; detail?: string }[] = []; - const host = { - ...systemSetupHost(doctor, packageRunnerEnvironment), - // The host reports the developer machine's Node; pin a supported one - // so readiness does not depend on the local runtime. - nodeVersion: "24.22.1", - detectedClients: () => Promise.resolve([client]), - supportedClients: () => Promise.resolve([client]), - prewarmRegistrationRunner: () => - Promise.resolve({ - status: "failed" as const, - reason: "The pinned package-runner entry could not be materialized", - }), - }; - const result = await runSetup( - { - ...options(true), - clientIds: ["cursor"], - installSkill: false, - proposeHopper: false, - readinessScope: { clients: [], providers: [], skill: false }, - onProgress: (event) => - progress.push({ - state: event.state, - ...(event.detail === undefined ? {} : { detail: event.detail }), - }), - }, - host, - ); - expect(result.status).toBe("ready"); - expect(result.prewarm).toMatchObject({ status: "failed" }); - expect(progress).toContainEqual({ - state: "warning", - detail: "The pinned package-runner entry could not be materialized", - }); -}); - -it("setup omits the prewarm summary when the host offers no capability", async () => { - const root = await createTestTempDirectory("rea-setup-prewarm-absent-"); - const client = supportedClients(root, "darwin", {}).find( - (candidate) => candidate.name === "cursor", - ); - if (client === undefined) throw new Error("Cursor registration is missing"); - await mkdir(dirname(client.configPath), { recursive: true }); - const doctor = createDoctorHostFixture({ - clientRegistrations: () => - readClientRegistrationStatuses(root, undefined, { - platform: "darwin", - environment: {}, - }), - providerInspections: () => Promise.resolve([]), - }); - const { prewarmRegistrationRunner: absent, ...hostWithoutPrewarm } = - systemSetupHost(doctor, packageRunnerEnvironment); - void absent; - const host = { - ...hostWithoutPrewarm, - // The host reports the developer machine's Node; pin a supported one - // so readiness does not depend on the local runtime. - nodeVersion: "24.22.1", - detectedClients: () => Promise.resolve([client]), - supportedClients: () => Promise.resolve([client]), - }; - const result = await runSetup( - { - ...options(true), - clientIds: ["cursor"], - installSkill: false, - proposeHopper: false, - readinessScope: { clients: [], providers: [], skill: false }, - }, - host, - ); - expect(result.status).toBe("ready"); - expect(result.prewarm).toBeUndefined(); -}); - -it("setup skips prewarm when no client registration was applied", async () => { - const root = await createTestTempDirectory("rea-setup-prewarm-skip-"); - const client = supportedClients(root, "darwin", {}).find( - (candidate) => candidate.name === "cursor", - ); - if (client === undefined) throw new Error("Cursor registration is missing"); - await mkdir(dirname(client.configPath), { recursive: true }); - const doctor = createDoctorHostFixture({ - clientRegistrations: () => - readClientRegistrationStatuses(root, undefined, { - platform: "darwin", - environment: {}, - }), - providerInspections: () => Promise.resolve([]), - }); - let prewarmCalls = 0; - const host = { - ...systemSetupHost(doctor, packageRunnerEnvironment), - nodeVersion: "24.22.1", - detectedClients: () => Promise.resolve([client]), - supportedClients: () => Promise.resolve([client]), - prewarmRegistrationRunner: () => { - prewarmCalls += 1; - return Promise.resolve({ status: "warmed" as const }); - }, - }; - const result = await runSetup( - { - ...options(true), - clientIds: [], - installSkill: false, - proposeHopper: false, - readinessScope: { clients: [], providers: [], skill: false }, - }, - host, - ); - expect(result.status).toBe("ready"); - expect(prewarmCalls).toBe(0); - expect(result.prewarm).toBeUndefined(); -}); diff --git a/tests/boundary/filesystem/traeClientConfiguration.test.ts b/tests/boundary/filesystem/traeClientConfiguration.test.ts index 528c81e7be..1020758e0a 100644 --- a/tests/boundary/filesystem/traeClientConfiguration.test.ts +++ b/tests/boundary/filesystem/traeClientConfiguration.test.ts @@ -1,10 +1,11 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, win32 } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; @@ -16,7 +17,9 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment * The registration command setup writes on the host running the tests. Windows * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. */ -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); diff --git a/tests/boundary/filesystem/windowsNpxRegistration.test.ts b/tests/boundary/filesystem/windowsNpxRegistration.test.ts index 72e5e12a56..f6c52d0f75 100644 --- a/tests/boundary/filesystem/windowsNpxRegistration.test.ts +++ b/tests/boundary/filesystem/windowsNpxRegistration.test.ts @@ -1,20 +1,37 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join, isAbsolute } from "node:path"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/client/stdio"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { z } from "zod"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { setupRegistrationCommand } from "../../../src/application/SetupHost.js"; +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { systemUninstallHost } from "../../../src/application/Uninstall.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +// These writer tests model setup running from REA's actual entrypoint, not Vitest's worker. +vi.mock( + "../../../src/process/CurrentEntrypointPath.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/process/CurrentEntrypointPath.js") + >(); + const { fileURLToPath } = await import("node:url"); + return { + ...actual, + CURRENT_ENTRYPOINT_PATH: fileURLToPath( + new URL("../../../scripts/rea.mjs", import.meta.url), + ), + }; + }, +); + const configurationSchema = z.object({ mcpServers: z.object({ rea: z.object({ command: z.string(), args: z.array(z.string()) }), @@ -60,7 +77,7 @@ it.each([ configPath, format: "claude_code", } as const; - const command = setupRegistrationCommand("win32", "npx", undefined); + const command = currentClientRegistrationCommand({ platform: "win32" }); expect( await configureClientConfiguration(client, {}, command), ).toMatchObject({ status: "configured" }); @@ -97,7 +114,7 @@ it.each(["npx", "bunx"] as const)( '\uFEFF# Keep this explanation.\nnotify = ["a", "b"]\nliteral = \'C:\\demo\\path\'\ndisabled_mcp_servers = ["rea"]\n\n[mcp_servers.other]\ncommand = "another-server"\n'; await writeFile(configPath, original); const client = { name: "codex", configPath, format: "toml" } as const; - const command = setupRegistrationCommand("win32", runner, undefined); + const command = currentClientRegistrationCommand({ platform: "win32" }); expect( await configureClientConfiguration(client, {}, command), ).toMatchObject({ status: "configured" }); @@ -153,12 +170,9 @@ it("retains rea-named custom shell registrations without writing a backup", asyn } }); -it.skipIf(process.platform !== "win32").each([ - ["npx", ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]], - ["bunx", [PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]], -] as const)( - "starts the written Windows registration through a native %s.cmd shim", - async (runner, shimArguments) => { +it.skipIf(process.platform !== "win32")( + "starts the written Windows registration without current-directory package runners", + async () => { const root = await createTestTempDirectory("rea-windows-mcp-"); const home = join(root, "home with spaces"); await mkdir(home); @@ -168,33 +182,22 @@ it.skipIf(process.platform !== "win32").each([ configPath, format: "claude_code", } as const; - const command = setupRegistrationCommand("win32", runner, undefined); - // The default writer and npm-run setup must produce the same native launcher. - expect( - await (runner === "npx" - ? configureClientConfiguration(setupClient) - : configureClientConfiguration(setupClient, {}, command)), - ).toMatchObject({ status: "configured" }); - const decoded: unknown = JSON.parse(await readFile(configPath, "utf8")); - const registration = configurationSchema.parse(decoded).mcpServers.rea; + const command = currentClientRegistrationCommand({ platform: "win32" }); + expect(await configureClientConfiguration(setupClient)).toMatchObject({ + status: "configured", + }); + const registration = configurationSchema.parse( + JSON.parse(await readFile(configPath, "utf8")), + ).mcpServers.rea; expect([registration.command, ...registration.args]).toEqual(command); - const entry = fileURLToPath( - new URL("../../../scripts/rea.mjs", import.meta.url), - ); - // Exercise the actual batch-shim boundary without registry access or package installation. - await writeFile( - join(home, `${runner}.cmd`), - [ - "@echo off", - ...shimArguments.map( - (argument, index) => - `if not "%~${String(index + 1)}"=="${argument}" exit /b ${String(11 + index)}`, - ), - `if not "%~${String(shimArguments.length + 1)}"=="" exit /b 19`, - `"${process.execPath}" "${entry}" mcp`, - "", - ].join("\r\n"), - ); + expect(isAbsolute(registration.command)).toBe(true); + const marker = join(home, "runner-called.txt"); + for (const runner of ["npx", "bunx"]) + await writeFile( + join(home, `${runner}.cmd`), + `@echo unexpected>"${marker}"\r\n@exit /b 73\r\n`, + ); + await writeFile(join(home, ".npmrc"), "registry=http://127.0.0.1:9/\n"); const transport = new StdioClientTransport({ command: registration.command, args: registration.args, @@ -202,7 +205,10 @@ it.skipIf(process.platform !== "win32").each([ env: getDefaultEnvironment(), stderr: "pipe", }); - const client = new Client({ name: "windows-setup-launcher", version: "1" }); + const client = new Client({ + name: "windows-direct-registration", + version: "1", + }); try { await client.connect(transport); expect(client.getServerVersion()?.version).toBe( @@ -216,6 +222,7 @@ it.skipIf(process.platform !== "win32").each([ expect(status.structuredContent).toMatchObject({ result: { open: false }, }); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); } finally { await client.close(); await transport.close(); diff --git a/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts b/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts index fc9b746d68..47736c7d37 100644 --- a/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts +++ b/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts @@ -1,10 +1,11 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, win32 } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; @@ -16,7 +17,9 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment * The registration command setup writes on the host running the tests. Windows * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. */ -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts index cda9a8ae3b..36443d7c7b 100644 --- a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -6,7 +7,7 @@ import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; + import { configureClientConfiguration, inspectClientConfiguration, @@ -24,7 +25,9 @@ import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment * The registration command setup writes on the host running the tests. Windows * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. */ -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); diff --git a/tests/conformance/readmeConsistency.test.ts b/tests/conformance/readmeConsistency.test.ts index ee984a100e..7942191403 100644 --- a/tests/conformance/readmeConsistency.test.ts +++ b/tests/conformance/readmeConsistency.test.ts @@ -2,7 +2,6 @@ import { readFile, readdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { PRODUCT_IDENTITY } from "../../src/identity.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { processTest } from "../support/process/processFixture.js"; @@ -31,14 +30,14 @@ describe("onboarding documentation product facts", () => { }, ); - it("keeps the installation MCP example aligned with the package registration command", async () => { + it("documents manual MCP registration with an absolute runtime and entry script", async () => { const content = await readFile(resolve("docs/installation.md"), "utf8"); expect(jsonExamples(content)).toContainEqual( expect.objectContaining({ mcpServers: expect.objectContaining({ rea: expect.objectContaining({ - command: "npx", - args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + command: "/absolute/path/to/node", + args: ["/absolute/path/to/rea/scripts/rea.mjs", "mcp"], }), }), }), diff --git a/tests/conformance/releaseConfiguration.test.ts b/tests/conformance/releaseConfiguration.test.ts index df50961b44..3c2751d0e4 100644 --- a/tests/conformance/releaseConfiguration.test.ts +++ b/tests/conformance/releaseConfiguration.test.ts @@ -15,7 +15,7 @@ const readJson = async (path: string): Promise => const versionedDocumentation = [ { path: "docs/installation.md", - examples: ["doctor --provider ghidra --json", '"command": "npx"'], + examples: ["doctor --provider ghidra --json"], }, ] as const; const pluginManifests = [ diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 9b73fc5949..66a10c2ea7 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -1,15 +1,5 @@ import { vi } from "vitest"; -import { PRODUCT_IDENTITY } from "../../src/identity.js"; - -/** The default npx registration command setup writes on POSIX hosts. */ -export const NPX_REGISTRATION_COMMAND = [ - "npx", - "-y", - PRODUCT_IDENTITY.registrationPackageSpecifier, - "mcp", -] as const; - /** Environment variables that redirect where supported clients keep config. */ export const CLIENT_LOCATION_ENVIRONMENT = [ "APPDATA", diff --git a/tests/fixtures/codexToolPolicy.ts b/tests/fixtures/codexToolPolicy.ts index e62d320856..c45da69435 100644 --- a/tests/fixtures/codexToolPolicy.ts +++ b/tests/fixtures/codexToolPolicy.ts @@ -1,4 +1,4 @@ -import { npxRegistrationCommand } from "../../src/application/ClientRegistrationIdentity.js"; +import { currentClientRegistrationCommand } from "../../src/application/ClientRegistrationIdentity.js"; export const codexToolPolicy = { default_tools_approval_mode: "prompt", @@ -21,8 +21,9 @@ export const codexToolPolicy = { /** The same client policy in native table and inline-table representations. */ export const codexToolPolicySource = ( layout: "tables" | "inline table", + registrationCommand: readonly string[] = currentClientRegistrationCommand(), ): string => { - const [command, ...args] = npxRegistrationCommand(); + const [command, ...args] = registrationCommand; return layout === "tables" ? [ "# retained root comment",