fix(setup): launch Windows npx registrations through cmd.exe (#1072)
This commit is contained in:
parent
5547c1e697
commit
ef30343154
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
Loading…
Reference in New Issue