diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index a1fa9a2e01..f1f43b9b2b 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { valid } from "semver"; @@ -115,6 +116,19 @@ const isOwnedPackageReference = (packageReference: string): boolean => { /** Check that one parsed command points to REA's MCP entry point. */ export type ClientRegistrationCommandIdentity = "owned" | "foreign" | "unknown"; +/** POSIX bin links can name the same dispatcher without changing stored commands. */ +const sameEntrypointPath = (candidate: string, current: string): boolean => { + if (!isAbsolute(candidate) || !isAbsolute(current)) return false; + if (resolve(candidate) === current) return true; + // Windows package bins are command shims, not links to the dispatcher. + if (process.platform === "win32") return false; + try { + return realpathSync(candidate) === realpathSync(current); + } catch { + return false; + } +}; + /** Classify only what the command and current executable identity establish. */ export const classifyClientRegistrationCommand = ( command: readonly string[], @@ -164,7 +178,7 @@ export const isOwnedClientRegistrationCommand = ( isAbsolute(command[0] ?? "") && isAbsolute(command[1] ?? "") && resolve(command[0] ?? "") === resolve(process.execPath) && - resolve(command[1] ?? "") === currentCommandPath + sameEntrypointPath(command[1] ?? "", currentCommandPath) ) return true; @@ -175,7 +189,7 @@ export const isOwnedClientRegistrationCommand = ( (isAbsolute(executable) && currentCommandPath !== undefined && currentCommandPath.length > 0 && - resolve(executable) === currentCommandPath) + sameEntrypointPath(executable, currentCommandPath)) ) return true; } diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 1a8352c320..4c8c6c9a56 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -7,7 +7,7 @@ import { type ClientConfigurationDocument, } from "./ClientConfigurationDocument.js"; import { access } from "node:fs/promises"; -import { isAbsolute, resolve } from "node:path"; +import { isAbsolute } from "node:path"; import { z } from "zod"; import { valid } from "semver"; @@ -403,27 +403,11 @@ const registrationAligned = ({ }) ) return "aligned"; - if ( - currentCommandPath !== undefined && - currentCommandPath.length > 0 && - command.length === 3 && - command[2] === "mcp" && - isAbsolute(command[0] ?? "") && - isAbsolute(command[1] ?? "") && - resolve(command[0] ?? "") === resolve(process.execPath) && - resolve(command[1] ?? "") === currentCommandPath - ) - return "aligned"; - if ( - currentCommandPath !== undefined && - currentCommandPath.length > 0 && - command.length === 2 && - command[1] === "mcp" && - isAbsolute(command[0] ?? "") && - resolve(command[0] ?? "") === currentCommandPath - ) - return "aligned"; - return "stale"; + // Other owned package references remain stale until pinned to this version. + if (clientRegistrationPackageReference(command) !== undefined) return "stale"; + // The identity owner already established an exact direct launcher match. + // A bare command still depends on the client's PATH, so cannot prove alignment. + return isAbsolute(command[0] ?? "") ? "aligned" : "stale"; }; const parseRegistration = ( diff --git a/tests/acceptance/setup/launcherAliasesCli.test.ts b/tests/acceptance/setup/launcherAliasesCli.test.ts new file mode 100644 index 0000000000..7b2db98c47 --- /dev/null +++ b/tests/acceptance/setup/launcherAliasesCli.test.ts @@ -0,0 +1,64 @@ +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.skipIf(process.platform === "win32").for([ + ["rea", "rea-agents"], + ["rea-agents", "rea"], +] as const)( + "recognizes setup through %s when doctor and uninstall use %s", + async ([registered, alternate], { processes }) => { + const root = await createTestTempDirectory("rea-launcher-registration-"); + const home = join(root, "home"); + const config = join(home, ".cursor/mcp.json"); + await mkdir(join(home, ".cursor"), { recursive: true }); + const unrelated = { command: "other-server", args: [] }; + await writeFile(config, JSON.stringify({ mcpServers: { unrelated } })); + for (const name of [registered, alternate]) + await symlink(resolve("scripts/rea.mjs"), join(root, name)); + const run = (alias: string, arguments_: readonly string[]) => + processes.run(process.execPath, [join(root, alias), ...arguments_], { + cwd: home, + env: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + PATH: process.env.PATH ?? "", + }, + timeoutMs: 30_000, + }); + + const setup = await run(registered, [ + "setup", + "--client", + "cursor", + "--skill=false", + "--yes", + "--json", + ]); + expect(setup.exitCode, setup.stderr).toBe(0); + for (const alias of [registered, alternate]) { + const doctor = await run(alias, [ + "doctor", + "--client", + "cursor", + "--skill=false", + "--json", + ]); + expect(doctor.exitCode, doctor.stdout).toBe(0); + expect(JSON.parse(doctor.stdout)).toMatchObject({ + scope_checks: expect.arrayContaining([ + expect.objectContaining({ name: "registration:cursor", ok: true }), + ]), + }); + } + const uninstall = await run(alternate, ["uninstall", "--json"]); + expect(uninstall.exitCode, uninstall.stderr).toBe(0); + expect(JSON.parse(await readFile(config, "utf8"))).toEqual({ + mcpServers: { unrelated }, + }); + }, +); diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts index fce0fdb961..7e79e544a8 100644 --- a/tests/boundary/filesystem/clientConfigurationDialects.test.ts +++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -706,6 +706,54 @@ describe("platform-aware client config paths", () => { }); describe("client registration command ownership", () => { + it.skipIf(process.platform === "win32").each([ + ["rea", "rea-agents"], + ["rea-agents", "rea"], + ])( + "recognizes %s through its %s launcher alias", + async (registered, current) => { + const root = await createTestTempDirectory("rea-launcher-alias-"); + const script = join(root, "entry.mjs"); + await writeFile(script, "// owned dispatcher\n"); + await symlink(script, join(root, registered)); + await symlink(script, join(root, current)); + for (const command of [ + [join(root, registered), "mcp"], + [process.execPath, join(root, registered), "mcp"], + ]) { + expect( + classifyClientRegistrationCommand(command, join(root, current)), + ).toBe("owned"); + expect( + isOwnedClientRegistrationCommand(command, join(root, current)), + ).toBe(true); + } + }, + ); + + it.skipIf(process.platform === "win32")( + "does not own a same-named launcher for another script or a dangling link", + async () => { + const root = await createTestTempDirectory("rea-launcher-foreign-"); + const owned = join(root, "entry.mjs"); + const foreign = join(root, "other.mjs"); + await writeFile(owned, "// owned\n"); + await writeFile(foreign, "// foreign\n"); + await symlink(owned, join(root, "rea")); + await symlink(foreign, join(root, "rea-agents")); + await symlink(join(root, "missing.mjs"), join(root, "missing")); + for (const candidate of ["rea-agents", "missing"]) { + const command = [join(root, candidate), "mcp"]; + expect( + classifyClientRegistrationCommand(command, join(root, "rea")), + ).toBe("foreign"); + expect( + isOwnedClientRegistrationCommand(command, join(root, "rea")), + ).toBe(false); + } + }, + ); + it("recognizes prior exact REA releases while rejecting foreign package commands", () => { expect(isOwnedClientRegistrationCommand(["rea", "mcp"], undefined)).toBe( true,