Use one registration classifier for doctor, maintenance and uninstall. Preserve unknown identity when launcher resolution fails, and compare native paths without collapsing symlink traversal. Keep Node argv script selection separate; ambiguous direct-launch interpretations cannot authorize removal. Remove the superseded boolean ownership parser. Validation: 183 focused tests passed, 2 Windows cases skipped; check:fast and docs:check passed. Five public regressions fail on the baseline, including foreign registration removal in an isolated fixture. Source review and authored Node shebang controls completed on macOS with Node 24.18.0. Native Windows, Linux and real mise upgrades remain unverified. Closes #4895 Closes #4896
194 lines
7.0 KiB
TypeScript
194 lines
7.0 KiB
TypeScript
import { realpathSync } from "node:fs";
|
|
import { isAbsolute } from "node:path";
|
|
import { normalizeEntrypointPath } from "../process/CurrentEntrypointPath.js";
|
|
import { valid } from "semver";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
|
|
/** Package runners whose pinned launcher setup can register. */
|
|
export type PackageRunner = "npx" | "bunx";
|
|
|
|
/** Read one variable; Windows variable names are case-insensitive. */
|
|
const environmentValue = (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
name: string,
|
|
platform: NodeJS.Platform,
|
|
): string | undefined => {
|
|
if (platform !== "win32") return environment[name];
|
|
const target = name.toLowerCase();
|
|
const key = Object.keys(environment).findLast(
|
|
(candidate) => candidate.toLowerCase() === target,
|
|
);
|
|
return key === undefined ? undefined : environment[key];
|
|
};
|
|
|
|
/** Identify the package runner that launched this process, if any. */
|
|
export const invokingPackageRunner = (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): PackageRunner | undefined => {
|
|
if (environmentValue(environment, "npm_command", platform) !== "exec")
|
|
return undefined;
|
|
// `bunx` and `bun x` also report npm_command=exec, with a bun user agent.
|
|
return environmentValue(
|
|
environment,
|
|
"npm_config_user_agent",
|
|
platform,
|
|
)?.startsWith("bun/") === true
|
|
? "bunx"
|
|
: "npx";
|
|
};
|
|
|
|
/** Copy an environment without the variable that marks a package-runner launch. */
|
|
export const withoutPackageRunnerIdentity = (
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): NodeJS.ProcessEnv =>
|
|
Object.fromEntries(
|
|
Object.entries(environment).filter(([key]) =>
|
|
platform === "win32"
|
|
? key.toLowerCase() !== "npm_command"
|
|
: key !== "npm_command",
|
|
),
|
|
);
|
|
|
|
/** Resolve a pinned package-runner launcher that stdio clients can spawn on the host. */
|
|
export const packageRunnerRegistrationCommand = (
|
|
runner: PackageRunner,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): readonly string[] => {
|
|
const command =
|
|
runner === "npx"
|
|
? ["npx", "-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]
|
|
: ["bunx", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"];
|
|
// Both runners may be .cmd shims on Windows, which clients cannot spawn directly.
|
|
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
|
|
};
|
|
|
|
/** 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;
|
|
if (command[1]?.toLowerCase() === "/c") return command.slice(2);
|
|
if (command[1]?.toLowerCase() === "/d" && command[2]?.toLowerCase() === "/c")
|
|
return command.slice(3);
|
|
return [];
|
|
};
|
|
|
|
/** Preserve the package argument from a supported package-runner command. */
|
|
export const clientRegistrationPackageReference = (
|
|
command: readonly string[],
|
|
): string | undefined => {
|
|
const arguments_ = unwrapCmdCommand(command);
|
|
if (
|
|
arguments_.length === 4 &&
|
|
arguments_[0] === "npx" &&
|
|
arguments_[1] === "-y" &&
|
|
arguments_[3] === "mcp"
|
|
)
|
|
return arguments_[2];
|
|
if (
|
|
arguments_.length === 3 &&
|
|
arguments_[0] === "bunx" &&
|
|
arguments_[2] === "mcp"
|
|
)
|
|
return arguments_[1];
|
|
return undefined;
|
|
};
|
|
|
|
const isOwnedPackageReference = (packageReference: string): boolean => {
|
|
if (
|
|
packageReference === PRODUCT_IDENTITY.packageName ||
|
|
packageReference === PRODUCT_IDENTITY.packageSpecifier ||
|
|
packageReference === PRODUCT_IDENTITY.registrationPackageSpecifier
|
|
)
|
|
return true;
|
|
const versionPrefix = `${PRODUCT_IDENTITY.packageName}@`;
|
|
return (
|
|
packageReference.startsWith(versionPrefix) &&
|
|
valid(packageReference.slice(versionPrefix.length)) !== null
|
|
);
|
|
};
|
|
|
|
/** Check that one parsed command points to REA's MCP entry point. */
|
|
export type ClientRegistrationCommandIdentity = "owned" | "foreign" | "unknown";
|
|
|
|
/** Compare entrypoint paths without turning failed resolution into a foreign identity. */
|
|
const entrypointIdentity = (
|
|
candidate: string,
|
|
current: string,
|
|
): ClientRegistrationCommandIdentity => {
|
|
if (!isAbsolute(candidate) || !isAbsolute(current)) return "unknown";
|
|
if (candidate === current) return "owned";
|
|
try {
|
|
return realpathSync.native(candidate) === realpathSync.native(current)
|
|
? "owned"
|
|
: "foreign";
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
};
|
|
|
|
/** A direct launcher may be a native executable or a Node shebang script. */
|
|
const directEntrypointIdentity = (
|
|
candidate: string,
|
|
current: string,
|
|
): ClientRegistrationCommandIdentity => {
|
|
const nativeIdentity = entrypointIdentity(candidate, current);
|
|
const scriptPath = normalizeEntrypointPath(candidate);
|
|
if (scriptPath === candidate) return nativeIdentity;
|
|
// A shebang can pass the original spelling to Node, which selects its script
|
|
// lexically. Without executing the launcher, divergent interpretations are unknown.
|
|
return scriptPath !== undefined &&
|
|
entrypointIdentity(scriptPath, current) === nativeIdentity
|
|
? nativeIdentity
|
|
: "unknown";
|
|
};
|
|
|
|
/** Classify the command once; uncertainty never grants positive ownership. */
|
|
export const classifyClientRegistrationCommand = (
|
|
command: readonly string[],
|
|
currentCommandPath: string | undefined,
|
|
): ClientRegistrationCommandIdentity => {
|
|
const packageReference = clientRegistrationPackageReference(command);
|
|
if (packageReference !== undefined)
|
|
return isOwnedPackageReference(packageReference) ? "owned" : "foreign";
|
|
const currentPathAvailable =
|
|
currentCommandPath !== undefined && currentCommandPath.length > 0;
|
|
if (command.length === 3 && command[2] === "mcp") {
|
|
const interpreter = command[0] ?? "";
|
|
const entrypoint = command[1] ?? "";
|
|
if (
|
|
!isAbsolute(interpreter) ||
|
|
!isAbsolute(entrypoint) ||
|
|
!currentPathAvailable
|
|
)
|
|
return "unknown";
|
|
const interpreterIdentity = entrypointIdentity(
|
|
interpreter,
|
|
process.execPath,
|
|
);
|
|
if (interpreterIdentity !== "owned") return interpreterIdentity;
|
|
// Node selects its script from normalized argv; executable paths use native
|
|
// traversal semantics, where symlink/.. can denote a different file.
|
|
const selectedEntrypoint = normalizeEntrypointPath(entrypoint);
|
|
return selectedEntrypoint === undefined
|
|
? "unknown"
|
|
: entrypointIdentity(selectedEntrypoint, currentCommandPath);
|
|
}
|
|
if (command.length === 2 && command[1] === "mcp") {
|
|
const executable = command[0] ?? "";
|
|
if (executable === PRODUCT_IDENTITY.cliBinary) return "owned";
|
|
if (isAbsolute(executable))
|
|
return currentPathAvailable
|
|
? directEntrypointIdentity(executable, currentCommandPath)
|
|
: "unknown";
|
|
if (/[./\\]/u.test(executable)) return "unknown";
|
|
}
|
|
return "foreign";
|
|
};
|