fix(setup): retain proven PATH aliases in installed registrations
This commit is contained in:
1 parent
cae03361cf
commit
0326ad95f6
4 files changed
+132
-3
No files matched your search
@@ -543,3 +543,11 @@ preservation failures identify that operation rather than implying that the
|
||||
configuration bytes or parent directory are unwritable. Setup preserves the
|
||||
original and reports its recovery backup; review local ownership and group
|
||||
membership before retrying.
|
||||
|
||||
For an installed POSIX CLI, setup prefers an executable absolute `rea` alias
|
||||
from its selected `PATH` when filesystem identity proves that it names the
|
||||
running REA entrypoint. This keeps package-manager aliases intact instead of
|
||||
persisting an internal versioned script path. Unresolved aliases and wrappers
|
||||
that are different files do not establish that proof; setup retains its selected
|
||||
entrypoint. A manager-specific upgrade remains subject to that manager's alias
|
||||
policy.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { accessSync, constants, realpathSync } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { normalizeEntrypointPath } from "../process/CurrentEntrypointPath.js";
|
||||
import { valid } from "semver";
|
||||
|
||||
@@ -149,6 +149,29 @@ const directEntrypointIdentity = (
|
||||
: "unknown";
|
||||
};
|
||||
|
||||
/** Prefer an executable PATH alias that proves the same selected REA file. */
|
||||
export const registrationEntrypointPath = (
|
||||
entrypoint: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
): string => {
|
||||
// A Windows command shim is a different program; file identity cannot prove
|
||||
// its script target. Keep that platform's existing Node/script registration.
|
||||
if (platform === "win32") return entrypoint;
|
||||
for (const directory of (environment.PATH ?? "").split(":")) {
|
||||
if (!isAbsolute(directory)) continue;
|
||||
const candidate = join(directory, PRODUCT_IDENTITY.cliBinary);
|
||||
if (directEntrypointIdentity(candidate, entrypoint) !== "owned") continue;
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// An unexecutable alias cannot launch the selected program.
|
||||
}
|
||||
}
|
||||
return entrypoint;
|
||||
};
|
||||
|
||||
/** Preserve caller spelling only when both direct launchers prove the same file. */
|
||||
export const equivalentClientRegistrationCommands = (
|
||||
previous: readonly string[],
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import {
|
||||
invokingPackageRunner,
|
||||
registrationEntrypointPath,
|
||||
packageRunnerRegistrationCommand,
|
||||
type PackageRunner,
|
||||
} from "./ClientRegistrationIdentity.js";
|
||||
@@ -69,13 +70,17 @@ 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 [];
|
||||
return platform === "win32"
|
||||
? [process.execPath, entrypointPath, "mcp"]
|
||||
: [entrypointPath, "mcp"];
|
||||
: [
|
||||
registrationEntrypointPath(entrypointPath, environment, platform),
|
||||
"mcp",
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -91,6 +96,7 @@ const setupRegistration = (
|
||||
platform,
|
||||
invokingPackageRunner(environment, platform),
|
||||
CURRENT_ENTRYPOINT_PATH,
|
||||
environment,
|
||||
);
|
||||
const registrationCommandBlocker =
|
||||
registrationCommand.length === 0
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { setupRegistrationCommand } from "../../../src/application/SetupHost.js";
|
||||
import { classifyClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"registers a same-file PATH launcher whose manager-owned alias survives a version switch",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-stable-launcher-alias-");
|
||||
const installs = join(root, "installs");
|
||||
const old = join(installs, "old");
|
||||
const next = join(installs, "next");
|
||||
const scriptWithin = (tag: string) =>
|
||||
join("node_modules", ".mise", `rea-agents@${tag}`, "scripts", "rea.mjs");
|
||||
for (const [version, tag] of [
|
||||
[old, "old"],
|
||||
[next, "next"],
|
||||
] as const) {
|
||||
await mkdir(join(version, "bin"), { recursive: true });
|
||||
const script = join(version, scriptWithin(tag));
|
||||
await mkdir(dirname(script), { recursive: true });
|
||||
await writeFile(script, "#!/usr/bin/env node\n", { mode: 0o700 });
|
||||
await symlink(script, join(version, "bin", "rea"));
|
||||
}
|
||||
const latest = join(installs, "latest");
|
||||
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"),
|
||||
});
|
||||
expect(command).toEqual([alias, "mcp"]);
|
||||
expect(classifyClientRegistrationCommand(command, unstable)).toBe("owned");
|
||||
await rm(latest);
|
||||
await symlink(next, latest, "dir");
|
||||
await rm(old, { recursive: true });
|
||||
await expect(access(unstable)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await access(command[0]!);
|
||||
expect(await realpath(command[0]!)).toBe(join(next, scriptWithin("next")));
|
||||
expect(
|
||||
classifyClientRegistrationCommand(
|
||||
command,
|
||||
join(next, scriptWithin("next")),
|
||||
),
|
||||
).toBe("owned");
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"keeps selected entrypoint when PATH aliases are foreign, unresolved, unexecutable or relative",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-unproven-launcher-alias-");
|
||||
const entrypoint = join(root, "selected.mjs");
|
||||
await writeFile(entrypoint, "selected", { mode: 0o700 });
|
||||
const foreign = join(root, "foreign");
|
||||
const missing = join(root, "missing");
|
||||
const unexecutable = join(root, "unexecutable");
|
||||
for (const path of [foreign, missing, unexecutable]) await mkdir(path);
|
||||
await writeFile(join(foreign, "rea"), "different program", { mode: 0o700 });
|
||||
await symlink(join(root, "absent"), join(missing, "rea"));
|
||||
// Same identity cannot be reached via an executable launcher when the selected
|
||||
// file itself has no executable bit; no shell wrapper is inferred or run.
|
||||
const selected = join(root, "unexecutable.mjs");
|
||||
await writeFile(selected, "selected", { mode: 0o600 });
|
||||
await symlink(selected, join(unexecutable, "rea"));
|
||||
expect(
|
||||
setupRegistrationCommand("linux", undefined, entrypoint, {
|
||||
PATH: `${foreign}:${missing}:relative`,
|
||||
}),
|
||||
).toEqual([entrypoint, "mcp"]);
|
||||
expect(
|
||||
setupRegistrationCommand("linux", undefined, selected, {
|
||||
PATH: unexecutable,
|
||||
}),
|
||||
).toEqual([selected, "mcp"]);
|
||||
expect(
|
||||
setupRegistrationCommand("win32", undefined, entrypoint, {
|
||||
PATH: foreign,
|
||||
}),
|
||||
).toEqual([process.execPath, entrypoint, "mcp"]);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user