From adafef2c9ea17075dd1c849ac285bd9f79274df0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:36:59 +0800 Subject: [PATCH] fix(setup): preserve equivalent caller launcher paths --- docs/client-configuration.md | 6 +++ src/application/ClientRegistration.ts | 12 +++++- src/application/ClientRegistrationIdentity.ts | 34 +++++++++++++++ tests/boundary/filesystem/setupConfig.test.ts | 43 +++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index f1045988e1..ec9066a5a4 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -265,6 +265,12 @@ the diagnostic identifies the setting to repair. See the [Codex configuration reference](https://developers.openai.com/codex/config-reference/) for the supported policy values. +When direct launcher paths prove the same executable or Node entrypoint, +setup retains the existing caller-selected spelling. An otherwise aligned +registration therefore needs no rewrite or recovery backup merely because +REA was invoked through another symlink. Unresolved identity does not prove +equivalence. + | Settings | Setup ownership | | ----------------------------------------- | --------------------------------------------------------------------- | | Launcher, transport and activation | Refreshes the REA stdio connection and enables it. | diff --git a/src/application/ClientRegistration.ts b/src/application/ClientRegistration.ts index b5b7fac068..0d9e4d6841 100644 --- a/src/application/ClientRegistration.ts +++ b/src/application/ClientRegistration.ts @@ -14,6 +14,7 @@ import { type ClientRegistrationDialect, } from "./ClientConfigurationDocument.js"; import type { SetupClient } from "./SupportedClients.js"; +import { equivalentClientRegistrationCommands } from "./ClientRegistrationIdentity.js"; const tomlTimeoutSchema = z.union([ z.number().positive(), @@ -181,8 +182,17 @@ export const refreshClientRegistration = ( const existing = source.success ? source.data : {}; const policy = validateClientPolicy(existing, client); if (!policy.ok) return policy; + const prior = parseClientRegistration(existing, client); + const priorCommand = prior.ok + ? [prior.value.command, ...prior.value.args] + : undefined; + const launcher = + priorCommand !== undefined && + equivalentClientRegistrationCommands(priorCommand, command) + ? priorCommand + : command; const registration = { - ...clientRegistrationEntry(dialect, command, environment), + ...clientRegistrationEntry(dialect, launcher, environment), ...Object.fromEntries( Object.entries(existing).filter( ([key]) => !managedConnectionKeys.has(key), diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index 7b4d457c3a..74ad5a017b 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -149,6 +149,40 @@ const directEntrypointIdentity = ( : "unknown"; }; +/** Preserve caller spelling only when both direct launchers prove the same file. */ +export const equivalentClientRegistrationCommands = ( + previous: readonly string[], + requested: readonly string[], +): boolean => { + if ( + previous.length === requested.length && + previous.every((value, index) => value === requested[index]) + ) + return true; + const entrypoint = (command: readonly string[]): string | undefined => { + if ( + command.length === 2 && + command[1] === "mcp" && + isAbsolute(command[0] ?? "") + ) + return command[0]; + if ( + command.length === 3 && + command[2] === "mcp" && + isAbsolute(command[1] ?? "") && + entrypointIdentity(command[0] ?? "", process.execPath) === "owned" + ) + return normalizeEntrypointPath(command[1]); + return undefined; + }; + const current = entrypoint(requested); + return ( + current !== undefined && + entrypoint(previous) !== undefined && + classifyClientRegistrationCommand(previous, current) === "owned" + ); +}; + /** Classify the command once; uncertainty never grants positive ownership. */ export const classifyClientRegistrationCommand = ( command: readonly string[], diff --git a/tests/boundary/filesystem/setupConfig.test.ts b/tests/boundary/filesystem/setupConfig.test.ts index 1e253a0989..91bbff9301 100644 --- a/tests/boundary/filesystem/setupConfig.test.ts +++ b/tests/boundary/filesystem/setupConfig.test.ts @@ -17,6 +17,7 @@ import { readConfigurationFile } from "../../../src/application/ClientConfigurat import { configureClientConfiguration, inspectClientConfiguration, + clientConfigurationAligned, } from "../../../src/application/SetupClientConfiguration.js"; import { resolveWindowsClaudeDesktopLocation } from "../../../src/application/ClaudeDesktopConfigLocation.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; @@ -26,6 +27,48 @@ import { PRODUCT_IDENTITY } from "../../../src/identity.js"; const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier; +it.skipIf(process.platform === "win32")( + "preserves an aligned caller launcher alias without writes or a backup", + async () => { + const home = await createTestTempDirectory( + "rea-config-equivalent-launcher-", + ); + const real = join(home, "rea-current"); + const alias = join(home, "rea-alias"); + await writeFile(real, "current launcher\n"); + await symlink(real, alias); + await mkdir(join(home, ".cursor")); + const configPath = join(home, ".cursor", "mcp.json"); + const original = JSON.stringify({ + mcpServers: { + rea: { command: alias, args: ["mcp"], future_policy: { retain: true } }, + }, + }); + await writeFile(configPath, original); + const client = { name: "cursor", format: "json", configPath } as const; + expect(await clientConfigurationAligned(client, {}, [real, "mcp"])).toBe( + true, + ); + expect(await inspectClientConfiguration(client, {}, [real, "mcp"])).toEqual( + { status: "already_current" }, + ); + expect( + await configureClientConfiguration(client, {}, [real, "mcp"]), + ).toEqual({ status: "unchanged" }); + expect(await readFile(configPath, "utf8")).toBe(original); + await expect(readFile(`${configPath}.rea.backup`)).rejects.toThrow(); + const other = join(home, "another-launcher"); + await writeFile(other, "different launcher\n"); + expect( + await configureClientConfiguration(client, {}, [other, "mcp"]), + ).toMatchObject({ status: "configured" }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + mcpServers: { rea: { command: other, future_policy: { retain: true } } }, + }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + }, +); + describe("JSON client configuration transaction: writing configuration through safe paths", () => { it.skipIf(typeof process.getuid !== "function")( "rejects a symlink target not owned by the current user",