fix(setup): launch Windows npx registrations through cmd.exe (#1072)

This commit is contained in:
Ysopwnable 2026-10-08 22:11:54 +08:00 committed by GitHub
parent 5547c1e697
commit ef30343154
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 270 additions and 20 deletions

View File

@ -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

View File

@ -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);
},
);

View File

@ -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 ||

View File

@ -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<typeof registrationSchema>,
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 (

View File

@ -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 = (

View File

@ -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,

View File

@ -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.",
},

View File

@ -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,
);