From ef30343154d64d78b620d41c37457eee5d99fc71 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:11:54 +0800 Subject: [PATCH] fix(setup): launch Windows npx registrations through cmd.exe (#1072) --- .github/workflows/ci.yml | 2 + .../ClientRegistrationIdentity.test.ts | 52 ++++++ src/application/ClientRegistrationIdentity.ts | 33 +++- src/application/ClientRegistrationStatus.ts | 21 ++- src/application/SetupClientConfiguration.ts | 8 +- src/application/SetupHost.ts | 3 +- src/cli.ts | 3 +- .../filesystem/windowsNpxRegistration.test.ts | 168 ++++++++++++++++++ 8 files changed, 270 insertions(+), 20 deletions(-) create mode 100644 src/application/ClientRegistrationIdentity.test.ts create mode 100644 tests/boundary/filesystem/windowsNpxRegistration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c863aaeb..413fdb5c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -443,6 +443,8 @@ jobs: tests/boundary/filesystem/safeOutputTree.test.ts tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts tests/boundary/filesystem/setupUnavailableProvider.test.ts + tests/boundary/filesystem/windowsNpxRegistration.test.ts + src/application/ClientRegistrationIdentity.test.ts tests/boundary/cli/referenceSourceImportStatus.test.ts src/cliReferenceSourceImportStatus.test.ts tests/boundary/filesystem/asarInventoryFreshness.test.ts diff --git a/src/application/ClientRegistrationIdentity.test.ts b/src/application/ClientRegistrationIdentity.test.ts new file mode 100644 index 000000000..96e8d155c --- /dev/null +++ b/src/application/ClientRegistrationIdentity.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from "vitest"; +import { PRODUCT_IDENTITY } from "../identity.js"; +import { + isOwnedClientRegistrationCommand, + npxRegistrationCommand, +} from "./ClientRegistrationIdentity.js"; +import { setupRegistrationCommand } from "./SetupHost.js"; + +it("uses a Windows executable for npm-run setup and preserves POSIX launchers", () => { + expect(setupRegistrationCommand("win32", true)).toEqual([ + "cmd.exe", + "/d", + "/c", + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ]); + for (const platform of ["linux", "darwin"] as const) + expect(setupRegistrationCommand(platform, true)).toEqual([ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ]); +}); + +it.each( + [ + npxRegistrationCommand("win32"), + ["cmd", "/c", "npx", "-y", "rea-agents@5.0.0", "mcp"], + ["npx", "-y", "rea-agents@5.0.0", "mcp"], + ].map((command) => ({ command })), +)("recognizes owned current and legacy launchers: $command", ({ command }) => { + expect(isOwnedClientRegistrationCommand(command)).toBe(true); +}); + +it.each( + [ + ["cmd.exe", "/d", "/c", "node", "custom.js", "mcp"], + ["cmd.exe", "/k", "npx", "-y", "rea-agents@5.0.0", "mcp"], + ["cmd.exe", "/c", "npx -y rea-agents@5.0.0 mcp"], + ["cmd.exe", "/c", "npx", "-y", "another-package", "mcp"], + ["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0", "mcp", "&", "other"], + ["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0 & other", "mcp"], + ].map((command) => ({ command })), +)( + "does not claim custom shell commands as REA-owned: $command", + ({ command }) => { + expect(isOwnedClientRegistrationCommand(command)).toBe(false); + }, +); diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index 2bc4bd105..7a887d7c4 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -3,6 +3,28 @@ import { valid } from "semver"; import { PRODUCT_IDENTITY } from "../identity.js"; +/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */ +export const npxRegistrationCommand = ( + platform: NodeJS.Platform = process.platform, +): readonly string[] => { + const command = [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ]; + return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command; +}; + +const unwrapNpxCommand = (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 []; +}; + /** Check that one parsed command points to REA's MCP entry point. */ export const isOwnedClientRegistrationCommand = ( command: readonly string[], @@ -27,15 +49,16 @@ export const isOwnedClientRegistrationCommand = ( return true; } + const npxCommand = unwrapNpxCommand(command); if ( - command.length !== 4 || - command[0] !== "npx" || - command[1] !== "-y" || - command[3] !== "mcp" + npxCommand.length !== 4 || + npxCommand[0] !== "npx" || + npxCommand[1] !== "-y" || + npxCommand[3] !== "mcp" ) return false; - const packageReference = command[2] ?? ""; + const packageReference = npxCommand[2] ?? ""; if ( packageReference === PRODUCT_IDENTITY.packageName || packageReference === PRODUCT_IDENTITY.packageSpecifier || diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 3a820aa8e..91acc4293 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -9,7 +9,10 @@ import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js"; -import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { + isOwnedClientRegistrationCommand, + npxRegistrationCommand, +} from "./ClientRegistrationIdentity.js"; import { supportedClients } from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js"; @@ -108,7 +111,12 @@ export const readClientRegistrationStatuses = async ( client.name, client.configPath, command, - registrationAligned(registration, client, currentCommandPath) + registrationAligned( + registration, + client, + currentCommandPath, + options.platform ?? process.platform, + ) ? "aligned" : "stale", ), @@ -132,6 +140,7 @@ const registrationAligned = ( registration: z.output, client: SetupClient, currentCommandPath: string, + platform: NodeJS.Platform, ): boolean => { const command = [registration.command, ...registration.args]; if (registration.disabled === true || registration.enabled === false) @@ -160,12 +169,10 @@ const registrationAligned = ( resolve(command[1] ?? "") === currentCommandPath ) return true; + const npxCommand = npxRegistrationCommand(platform); if ( - command.length === 4 && - command[0] === "npx" && - command[1] === "-y" && - command[2] === PRODUCT_IDENTITY.registrationPackageSpecifier && - command[3] === "mcp" + command.length === npxCommand.length && + command.every((argument, index) => argument === npxCommand[index]) ) return true; return ( diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index a9a231d99..d59b06f17 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -15,6 +15,7 @@ import { dirname } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import type { @@ -24,12 +25,7 @@ import type { } from "./SetupTypes.js"; import type { SetupClient } from "./SupportedClients.js"; -const defaultCommand = (): readonly string[] => [ - "npx", - "-y", - PRODUCT_IDENTITY.registrationPackageSpecifier, - "mcp", -]; +const defaultCommand = (): readonly string[] => npxRegistrationCommand(); /** Back up, atomically update, and semantically read back one JSON MCP configuration. */ export const configureJsonClient = ( diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 0b1c9910b..b4a8a4944 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { SUPPORTED_NODE_VERSION_PROSE, supportsNodeVersion, @@ -35,7 +36,7 @@ export const setupRegistrationCommand = ( useNpmRunner: boolean = process.env.npm_command === "exec", ): readonly string[] => useNpmRunner - ? PRODUCT_IDENTITY.mcpCommand.split(" ") + ? npxRegistrationCommand(platform) : platform === "win32" ? [ process.execPath, diff --git a/src/cli.ts b/src/cli.ts index 38b5ab759..6f6b84c3f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { Cli } from "incur"; import { createLogger, parseLogLevel } from "./logger.js"; import { PRODUCT_IDENTITY } from "./identity.js"; +import { npxRegistrationCommand } from "./application/ClientRegistrationIdentity.js"; import { registerSetupCommands } from "./cli/setupCommands.js"; import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js"; import { registerUtilityCommands } from "./cli/utilityCommands.js"; @@ -47,7 +48,7 @@ export const createCli = ( version: PRODUCT_IDENTITY.packageVersion, description: "Reverse engineer anything from your terminal or agent.", mcp: { - command: PRODUCT_IDENTITY.mcpCommand, + command: npxRegistrationCommand().join(" "), instructions: "Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.", }, diff --git a/tests/boundary/filesystem/windowsNpxRegistration.test.ts b/tests/boundary/filesystem/windowsNpxRegistration.test.ts new file mode 100644 index 000000000..dc50f232e --- /dev/null +++ b/tests/boundary/filesystem/windowsNpxRegistration.test.ts @@ -0,0 +1,168 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/client"; +import { + StdioClientTransport, + getDefaultEnvironment, +} from "@modelcontextprotocol/client/stdio"; +import { expect, it } 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 { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const configurationSchema = z.object({ + mcpServers: z.object({ + rea: z.object({ command: z.string(), args: z.array(z.string()) }), + }), +}); + +it("migrates bare Windows npx registrations idempotently and uninstalls only REA", async () => { + const home = await createTestTempDirectory("rea-windows-registration-"); + const configPath = join(home, ".claude.json"); + const original = JSON.stringify({ + theme: "dark", + mcpServers: { + other: { command: "another-server" }, + rea: { + command: "npx", + args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + }, + }, + }); + await writeFile(configPath, original); + expect( + await readClientRegistrationStatuses(home, undefined, { + platform: "win32", + environment: {}, + }), + ).toContainEqual( + expect.objectContaining({ client: "claude_code", state: "stale" }), + ); + const client = { name: "claude_code", configPath, format: "json" } as const; + const command = setupRegistrationCommand("win32", true); + expect(await configureClientConfiguration(client, {}, command)).toMatchObject( + { status: "configured" }, + ); + expect( + await readClientRegistrationStatuses(home, undefined, { + platform: "win32", + environment: {}, + }), + ).toContainEqual( + expect.objectContaining({ client: "claude_code", state: "aligned" }), + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({ + status: "removed", + }); + const remaining: unknown = JSON.parse(await readFile(configPath, "utf8")); + expect(remaining).toEqual({ + theme: "dark", + mcpServers: { other: { command: "another-server" } }, + }); +}); + +it("keeps the Windows launcher aligned through Codex TOML setup and removal", async () => { + const home = await createTestTempDirectory("rea-windows-codex-"); + await mkdir(join(home, ".codex")); + const configPath = join(home, ".codex", "config.toml"); + const original = '[mcp_servers.other]\ncommand = "another-server"\n'; + await writeFile(configPath, original); + const client = { name: "codex", configPath, format: "toml" } as const; + const command = setupRegistrationCommand("win32", true); + expect(await configureClientConfiguration(client, {}, command)).toMatchObject( + { status: "configured" }, + ); + expect( + await readClientRegistrationStatuses(home, undefined, { + platform: "win32", + environment: {}, + }), + ).toContainEqual( + expect.objectContaining({ client: "codex", state: "aligned" }), + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({ + status: "removed", + }); + const remaining = await readFile(configPath, "utf8"); + expect(remaining).toContain("[mcp_servers.other]"); + expect(remaining).toContain('command = "another-server"'); + expect(remaining).not.toContain("mcp_servers.rea"); +}); + +it.skipIf(process.platform !== "win32")( + "starts the written Windows registration through a native npx.cmd shim", + async () => { + const root = await createTestTempDirectory("rea-windows-mcp-"); + const home = join(root, "home with spaces"); + await mkdir(home); + const configPath = join(home, ".claude.json"); + const setupClient = { + name: "claude_code", + configPath, + format: "json", + } as const; + // The default writer and npm-run setup must produce the same native launcher. + expect(await configureClientConfiguration(setupClient)).toMatchObject({ + status: "configured", + }); + const decoded: unknown = JSON.parse(await readFile(configPath, "utf8")); + const registration = configurationSchema.parse(decoded).mcpServers.rea; + expect([registration.command, ...registration.args]).toEqual( + setupRegistrationCommand("win32", true), + ); + 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, "npx.cmd"), + [ + "@echo off", + 'if not "%~1"=="-y" exit /b 11', + `if not "%~2"=="${PRODUCT_IDENTITY.registrationPackageSpecifier}" exit /b 12`, + 'if not "%~3"=="mcp" exit /b 13', + `"${process.execPath}" "${entry}" mcp`, + "", + ].join("\r\n"), + ); + const transport = new StdioClientTransport({ + command: registration.command, + args: registration.args, + cwd: home, + env: getDefaultEnvironment(), + stderr: "pipe", + }); + const client = new Client({ name: "windows-setup-launcher", 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(); + } + }, + 30_000, +);