fix(setup)!: bind managed registrations to selected installation

Use one command owner for setup, client writers, Gemini policy and CLI defaults.
Migrate recognized runner registrations with backups and remove runner prewarm.
Retain unknown identities and exercise real offline npm, CLI and MCP workflows.

Fixes #7757

BREAKING CHANGE: managed MCP registrations use the selected absolute runtime
and REA entry script, including package-runner cache paths. Keep that installation
available or restore it before setup; unknown registrations require confirmed
manual repair. Setup no longer returns prewarm or emits its progress events.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-28 11:34:28 +00:00
1 parent 18a8e71c9f
commit c77735fa83
56 files changed
+768 -1346

No files matched your search

@@ -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.
@@ -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,
+4 -1
View File
@@ -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
+8 -3
View File
@@ -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 <client-id> --skill=false --dry-run`, then run the same command
without `--dry-run` and approve the displayed changes. Setup preserves unrelated
+47 -46
View File
@@ -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:
<!-- x-release-please-start-version -->
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 [email protected] 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
```
<!-- x-release-please-end -->
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.
<!-- x-release-please-start-version -->
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", "[email protected]", "mcp"]
"command": "/absolute/path/to/node",
"args": ["/absolute/path/to/rea/scripts/rea.mjs", "mcp"]
}
}
}
```
<!-- x-release-please-end -->
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@<version> 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 [email protected] -- 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@<version> mcp`; setup run through `bunx` or `bun x` registers
`bunx rea-agents@<version> 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
@@ -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");
+6 -2
View File
@@ -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 {
+25 -6
View File
@@ -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",
+30 -19
View File
@@ -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<NodeJS.ProcessEnv>;
} = {},
): 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<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
+1 -12
View File
@@ -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.
+2 -2
View File
@@ -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(
+15 -5
View File
@@ -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([]);
});
});
+2 -6
View File
@@ -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<string | undefined> =>
-45
View File
@@ -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();
});
-40
View File
@@ -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 }),
};
};
+9 -2
View File
@@ -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<ClientConfigurationResult> => {
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(
+4 -49
View File
@@ -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<NodeJS.ProcessEnv> = {},
): 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<NodeJS.ProcessEnv>,
): Pick<SetupHost, "registrationCommand" | "registrationCommandBlocker"> => {
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<SetupRegistrationPrewarm> =>
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,
@@ -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> = {},
): 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);
});
-259
View File
@@ -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<NodeJS.ProcessEnv>;
/** 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<RegistrationRunnerLaunch>;
/**
* 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<NodeJS.ProcessEnv>,
resources: ArtifactResourceScope,
launchCommand: RegistrationRunnerLauncher = launchRegistrationRunnerCommand,
): Promise<SetupRegistrationPrewarm> => {
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<NodeJS.Signals, () => 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.`;
};
-8
View File
@@ -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<Result<void, SetupSkillInstallError>>;
installSkill(clientIds: readonly string[]): Promise<SkillInstallResult>;
doctor(scope?: DoctorScope): Promise<Awaited<ReturnType<typeof runDoctor>>>;
/** 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<SetupRegistrationPrewarm>;
}
/** 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. */
+5 -2
View File
@@ -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.",
},
+5
View File
@@ -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(" ");
-2
View File
@@ -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."
+5 -5
View File
@@ -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?.();
@@ -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[]) =>
@@ -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<string, unknown> & { servers: Record<string, unknown> };
};
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);
},
);
+8 -13
View File
@@ -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({
@@ -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);
},
);
@@ -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("");
},
);
}
@@ -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,
})
@@ -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),
});
@@ -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",
});
});
@@ -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");
@@ -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"),
@@ -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);
@@ -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());
@@ -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());
@@ -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());
@@ -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());
@@ -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");
@@ -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"],
@@ -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(
(
@@ -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());
@@ -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);
+14 -14
View File
@@ -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 },
},
},
@@ -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"]);
},
@@ -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?.();
}
},
);
@@ -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();
});
@@ -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());
@@ -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();
@@ -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());
@@ -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());
+3 -4
View File
@@ -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"],
}),
}),
}),
@@ -15,7 +15,7 @@ const readJson = async (path: string): Promise<unknown> =>
const versionedDocumentation = [
{
path: "docs/installation.md",
examples: ["doctor --provider ghidra --json", '"command": "npx"'],
examples: ["doctor --provider ghidra --json"],
},
] as const;
const pluginManifests = [
-10
View File
@@ -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",
+3 -2
View File
@@ -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",