From 6136c6f3babd5cf2ada552341003829d41a06f71 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:23:49 +0100 Subject: [PATCH] fix(cli): refuse silent direct command-registry execution (#11204) Fail visibly with a dispatcher hint and exit status 1 when the compiled command registry is executed directly. Reuse the existing argv/module identity owner for both entry modules and preserve normal imports and supported dispatch. Fixes #10762. Builds on #10766. Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- src/cli.ts | 13 ++++++ src/main.ts | 20 ++------- src/process/CurrentEntrypointPath.test.ts | 37 +++++++++++++++ src/process/CurrentEntrypointPath.ts | 17 +++++++ tests/boundary/cli/cliDirectExecution.test.ts | 45 +++++++++++++++++++ 5 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 src/process/CurrentEntrypointPath.test.ts create mode 100644 tests/boundary/cli/cliDirectExecution.test.ts diff --git a/src/cli.ts b/src/cli.ts index 7b66580b55..394f0ccc3a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,8 @@ import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js"; +import { + CURRENT_ENTRYPOINT_PATH, + isDirectEntrypoint, +} from "./process/CurrentEntrypointPath.js"; import { CLI_COMMAND_REGISTRATIONS, CLI_REGISTRATION_ORDER, @@ -141,3 +145,12 @@ export const createCli = ( module: CLI_COMMAND_MODULES[id], })), }); + +// Direct execution needs a visible failure pointing to the dispatcher. Imports +// only expose the registry. A normal exit lets piped stderr drain completely. +if (isDirectEntrypoint(CURRENT_ENTRYPOINT_PATH, import.meta.url)) { + process.stderr.write( + "dist/cli.js is the command registry, not the CLI entry point. Run `rea` (scripts/rea.mjs) instead.\n", + ); + process.exitCode = 1; +} diff --git a/src/main.ts b/src/main.ts index f236de436d..f00638e858 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,9 +2,10 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { HOST_PLATFORM } from "./platform/HostPlatform.js"; -import { CURRENT_ENTRYPOINT_PATH } from "./process/CurrentEntrypointPath.js"; -import { pathToFileURL } from "node:url"; -import { realpathSync } from "node:fs"; +import { + CURRENT_ENTRYPOINT_PATH, + isDirectEntrypoint, +} from "./process/CurrentEntrypointPath.js"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; @@ -206,19 +207,6 @@ export const runMcpHttpEntrypoint = async ( } }; -/** Return false when the runtime's argv path is synthetic or no longer exists. */ -const isDirectEntrypoint = ( - entryPath: string | undefined, - moduleUrl: string, -): boolean => { - if (entryPath === undefined) return false; - try { - return pathToFileURL(realpathSync(entryPath)).href === moduleUrl; - } catch { - return false; - } -}; - if (isDirectEntrypoint(CURRENT_ENTRYPOINT_PATH, import.meta.url)) { void runEntrypoint(); } diff --git a/src/process/CurrentEntrypointPath.test.ts b/src/process/CurrentEntrypointPath.test.ts new file mode 100644 index 0000000000..9ed8e5e419 --- /dev/null +++ b/src/process/CurrentEntrypointPath.test.ts @@ -0,0 +1,37 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { isDirectEntrypoint } from "./CurrentEntrypointPath.js"; + +const temporaryScript = async (): Promise<{ + path: string; + url: string; +}> => { + const directory = await createTestTempDirectory("rea-entrypoint-"); + const path = join(directory, "module.js"); + await writeFile(path, "export {};\n"); + return { path, url: pathToFileURL(path).href }; +}; + +it("matches a symlinked argv path by its real location", async () => { + const { path, url } = await temporaryScript(); + const directory = await createTestTempDirectory("rea-entrypoint-link-"); + const link = join(directory, "link.js"); + await symlink(path, link); + expect(isDirectEntrypoint(link, url)).toBe(true); +}); + +it("reports no direct run when no argv path was supplied", async () => { + const { url } = await temporaryScript(); + expect(isDirectEntrypoint(undefined, url)).toBe(false); +}); + +it("reports no direct run when the argv path no longer exists", async () => { + const { url } = await temporaryScript(); + const directory = await createTestTempDirectory("rea-entrypoint-gone-"); + expect(isDirectEntrypoint(join(directory, "missing.js"), url)).toBe(false); +}); diff --git a/src/process/CurrentEntrypointPath.ts b/src/process/CurrentEntrypointPath.ts index 372b89dba9..b4f7a1dc54 100644 --- a/src/process/CurrentEntrypointPath.ts +++ b/src/process/CurrentEntrypointPath.ts @@ -1,4 +1,6 @@ +import { realpathSync } from "node:fs"; import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { javaScriptRuntime, type JavaScriptRuntime, @@ -19,3 +21,18 @@ export const normalizeEntrypointPath = ( : resolve(entrypoint); export const CURRENT_ENTRYPOINT_PATH = normalizeEntrypointPath(process.argv[1]); + +/** True when the runtime executed `moduleUrl` directly rather than importing it. + * A synthetic argv path that no longer resolves is not a direct run. + */ +export const isDirectEntrypoint = ( + entryPath: string | undefined, + moduleUrl: string, +): boolean => { + if (entryPath === undefined) return false; + try { + return pathToFileURL(realpathSync(entryPath)).href === moduleUrl; + } catch { + return false; + } +}; diff --git a/tests/boundary/cli/cliDirectExecution.test.ts b/tests/boundary/cli/cliDirectExecution.test.ts new file mode 100644 index 0000000000..585cdac4be --- /dev/null +++ b/tests/boundary/cli/cliDirectExecution.test.ts @@ -0,0 +1,45 @@ +import { expect } from "vitest"; + +import { processTest } from "../../support/process/processFixture.js"; + +// Exercise the compiled registry and supported dispatcher; test:focused builds them. +processTest.for([[], ["--version"]] as const)( + "refuses a direct run of the compiled command registry with %j", + async (arguments_, { processes }) => { + const direct = await processes.run(process.execPath, [ + "dist/cli.js", + ...arguments_, + ]); + expect(direct.exitCode).toBe(1); + expect(direct.stdout).toBe(""); + expect(direct.stderr).toContain("dist/cli.js is the command registry"); + expect(direct.stderr).toContain("scripts/rea.mjs"); + }, +); + +processTest( + "still serves --version through the dispatcher", + async ({ processes }) => { + const dispatched = await processes.run(process.execPath, [ + "scripts/rea.mjs", + "--version", + ]); + expect(dispatched.exitCode).toBe(0); + expect(dispatched.stderr).toBe(""); + expect(dispatched.stdout.trim()).not.toBe(""); + }, +); + +processTest( + "imports the registry without serving or replacing the caller exit status", + async ({ processes }) => { + const imported = await processes.run(process.execPath, [ + "--input-type=module", + "--eval", + 'process.exitCode = 23; const registry = await import("./dist/cli.js"); process.stdout.write(typeof registry.createCli);', + ]); + expect(imported.exitCode).toBe(23); + expect(imported.stdout).toBe("function"); + expect(imported.stderr).toBe(""); + }, +);