From d69a54d592e5fed1319f202bfe2991d0a7323d22 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:31:41 +0800 Subject: [PATCH] fix(setup): register explicit MCP runtimes and preserve launcher identity --- docs/client-configuration.md | 43 +++++ docs/client-verification.md | 18 ++ scripts/verify-package-setup.mjs | 51 ++++- scripts/verify-qwen-client.mjs | 86 +++++++-- src/application/ClientRegistrationIdentity.ts | 120 +++++++++--- src/application/ClientRegistrationStatus.ts | 7 +- src/application/SetupHost.ts | 22 ++- src/mcpDoctor.ts | 9 +- src/process/CurrentEntrypointPath.ts | 12 +- .../setup/duplicateJsonMemberSetup.test.ts | 5 +- .../setup/launcherAliasesCli.test.ts | 124 ++++++++++++ tests/acceptance/setup/piClientSetup.test.ts | 4 +- .../acceptance/setup/qwenProfileSetup.test.ts | 10 +- .../registrationRuntimeEnvironmentCli.test.ts | 8 +- tests/boundary/cli/mcpDoctorHelpCli.test.ts | 4 + .../clientConfigurationDialects.test.ts | 9 + .../filesystem/packageClientBackup.test.ts | 22 ++- .../filesystem/setupLauncherAlias.test.ts | 180 +++++++++++++++++- tests/boundary/mcp/mcpDoctor.test.ts | 3 + 19 files changed, 637 insertions(+), 100 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 5d967061eb..7ed34a8499 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -6,6 +6,40 @@ for the reviewed setup transaction and [client IDs](installation.md#supported-ag The versioned verification notes below describe their stated runs; live service acceptance is verified only where explicitly recorded. +## Launchers and PATH + +A client launched from a desktop app, IDE or service can inherit a different +`PATH` from your terminal. An absolute path to `rea-agents` does not resolve its +interpreter: the npm launcher's `#!/usr/bin/env node` still searches `PATH` for +Node. Running that command successfully in a terminal does not establish that +the client can launch it. + +For direct JavaScript installations, setup registers the selected runtime +executable and REA script by absolute path. On POSIX, it prefers executable +`node` or `bun` and REA aliases on setup's `PATH` only when they resolve to the +same selected files. This preserves manager-owned aliases across version updates; +otherwise setup uses the selected paths directly. Package-runner registrations keep +their selected runner. Setup does not copy your ambient environment into client +settings. To refresh an older registration, preview +`rea setup --client --skill=false --dry-run`, then run the same command +without `--dry-run` and approve the displayed changes. Setup preserves unrelated +settings, backs up changed configuration and leaves an aligned registration +unchanged. Restart the client after applying the update. + +If a runtime manager removes the registered executable and no surviving alias +was available, doctor reports its identity as unknown and setup retains the +registration. REA does not infer that an arbitrary missing executable was Node +from its script argument. Back up the client's configuration, verify the absolute +path and supported version of the runtime you intend to use, then update only +the runtime command, preserving the REA script arguments and other settings. +Rerun setup to review the registration and restart the client. + +`rea mcp doctor --json` tests the production MCP adapter using the runtime +executable and environment of the terminal running doctor. It does not launch +the client's configured command or reproduce the client's environment. If doctor +passes but the client disconnects, inspect the client's startup error and registered +launcher; see [verification scope](client-verification.md#launch-environment-verification). + ## Runtime settings and client deadlines `update_analysis_settings` changes curated defaults inside the running REA @@ -267,6 +301,15 @@ Setup and doctor follow Qwen Code's tilde and working-directory-relative home overrides. Qwen Code also discovers personal skills in `~/.agents/skills`, where setup installs the bundled workflow. +If an existing direct-script registration fails with `env: node: No such file or +directory`, refresh it through setup as described under [Launchers and PATH](#launchers-and-path). +For an immediate Qwen-specific workaround, when Node is installed in +`/opt/homebrew/bin`, add `"PATH": "/opt/homebrew/bin:${PATH}"` to that server's +existing `env` object and restart Qwen. Qwen expands `${PATH}` from its parent +environment; other clients can use different substitution syntax. Qwen's stdio +launcher inherits the parent `PATH` and applies server overrides; a reduced +parent environment does not establish that Qwen removed Homebrew from it. + ## Antigravity Antigravity 2.0 discovers personal skills in `~/.gemini/config/skills`. diff --git a/docs/client-verification.md b/docs/client-verification.md index b72aedbe63..86b387cfcc 100644 --- a/docs/client-verification.md +++ b/docs/client-verification.md @@ -11,6 +11,16 @@ and provider lanes. Each record below states its host, client version, model fixture, and remaining coverage; a native-client run with a loopback model does not establish live model-service acceptance. +## Launch environment verification + +`rea mcp doctor --json` starts the production MCP adapter with the current runtime +executable and environment. A healthy result establishes that adapter's +initialization, discovery and target-free request flow; it does not verify a +client's configured launcher or its inherited environment. Test the generated +registration through the actual client, including a reduced `PATH` when checking +desktop or service startup. Record the client version and launch environment +separately from terminal doctor results. See [launcher troubleshooting](client-configuration.md#launchers-and-path). + ## Published Pi code-mode renderer limit A separate probe of the published `@earendil-works/pi-codemode` 1.1.0 and 1.2.0 @@ -53,6 +63,14 @@ skill setting; Hermes discovers it through the selected `HERMES_HOME`. Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of a named sticky profile with `HERMES_HOME` still pointing to its root. +For Qwen on POSIX, set `REA_VERIFY_QWEN_REDUCED_PATH=1` and select its JavaScript +entry point with `REA_VERIFY_QWEN_COMMAND` to test a restricted client environment. +Setup retains the caller's `PATH`; the native Qwen `chat` or `call` run then uses +an isolated `PATH` containing only Bash for the shell-tool fixture. The lane +checks that `node`, `npm` and `npx` cannot be found and uses the generated REA +registration unchanged. Its receipt records `reducedPath`, `clientPath` and +`registrationCommand`; the option alone does not establish a passing native run. + The Qwen lane also supports native Windows (verified with Qwen Code 0.25.1 and Node 24.19.0 for both `chat` and `call`). With Node 24.18.0, the same `call` journey reached full Evidence readback but the client aborted during exit with diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs index 4f93b4af26..5dcd6490d7 100644 --- a/scripts/verify-package-setup.mjs +++ b/scripts/verify-package-setup.mjs @@ -2,12 +2,14 @@ import { lstat, mkdir, readFile, + realpath, readdir, rm, symlink, writeFile, } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; +import { parse as parseToml } from "smol-toml"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; import { PRODUCT_IDENTITY } from "../dist/identity.js"; @@ -37,6 +39,17 @@ const CODEX_ORIGINAL = [ "", ].join("\n"); +const isDirectRegistration = async (command, cli) => + Array.isArray(command) && + command.length === 3 && + typeof command[0] === "string" && + typeof command[1] === "string" && + isAbsolute(command[0]) && + isAbsolute(command[1]) && + command[2] === "mcp" && + (await realpath(command[0])) === (await realpath(process.execPath)) && + (await realpath(command[1])) === (await realpath(cli)); + const verifyMcpAdd = async ({ commands, cli, environment, npxLog }) => { await commands.run(cli, ["mcp", "add"], environment); const mcpRegistration = await readFile(npxLog, "utf8"); @@ -122,7 +135,7 @@ const verifyAbsentOpenCodeSetup = async ({ !result.appliedActions.includes("configured_opencode") || config.mcp?.rea?.type !== "local" || config.mcp.rea.enabled !== true || - JSON.stringify(config.mcp.rea.command) !== JSON.stringify([cli, "mcp"]) + !(await isDirectRegistration(config.mcp.rea.command, cli)) ) throw new Error( `packaged setup did not configure explicitly selected OpenCode without a config file: ${JSON.stringify(result)}`, @@ -172,8 +185,13 @@ const verifyAbsentClaudeCodeSetup = async ({ firstExecution.status !== 0 || first.status !== "ready" || !first.appliedActions.includes("configured_claude_code") || - config.mcpServers?.rea?.command !== cli || - JSON.stringify(config.mcpServers?.rea?.args) !== JSON.stringify(["mcp"]) || + !(await isDirectRegistration( + [ + config.mcpServers?.rea?.command, + ...(config.mcpServers?.rea?.args ?? []), + ], + cli, + )) || (await pathExists(markerPath)) || registration?.state !== "aligned" || registration.config_path !== configPath @@ -270,8 +288,13 @@ export const assertClientConfig = async (cli, configPath, original) => { const config = JSON.parse(await readFile(configPath, "utf8")); if ( config.existing !== true || - config.mcpServers?.rea?.command !== cli || - JSON.stringify(config.mcpServers?.rea?.args) !== JSON.stringify(["mcp"]) + !(await isDirectRegistration( + [ + config.mcpServers?.rea?.command, + ...(config.mcpServers?.rea?.args ?? []), + ], + cli, + )) ) throw new Error("packaged client readback failed"); if (!(await readFile(`${configPath}.rea.backup`)).equals(original)) @@ -296,7 +319,7 @@ const assertOpenCodeConfig = async ({ (expectedRegistration && (mcp?.rea?.type !== "local" || mcp.rea.enabled !== true || - JSON.stringify(mcp.rea.command) !== JSON.stringify([cli, "mcp"]))) || + !(await isDirectRegistration(mcp.rea.command, cli)))) || (!expectedRegistration && mcp?.rea !== undefined) ) throw new Error("packaged OpenCode JSONC registration readback failed"); @@ -309,11 +332,15 @@ const assertCodexSymlink = async ({ codexTarget, }) => { const codex = await readFile(codexTarget, "utf8"); + const registration = parseToml(codex).mcp_servers?.rea; if ( !(await lstat(codexConfig)).isSymbolicLink() || !(await lstat(cursorConfig)).isSymbolicLink() || !codex.includes("[mcp_servers.rea]") || - !codex.includes(`command = "${cli}"`) || + !(await isDirectRegistration( + [registration?.command, ...(registration?.args ?? [])], + cli, + )) || !codex.includes("startup_timeout_sec = 30") || !codex.startsWith(CODEX_ORIGINAL) || (await readFile(`${codexConfig}.rea.backup`, "utf8")) !== CODEX_ORIGINAL @@ -446,11 +473,15 @@ const verifySetupFailureRecovery = async ({ environment, ); const recovered = JSON.parse(recoveredExecution.stdout); + const registration = JSON.parse(await readFile(cursorConfig, "utf8")) + .mcpServers?.rea; if ( recovered.status !== status || recoveredExecution.status !== (status === "ready" ? 0 : 1) || - JSON.parse(await readFile(cursorConfig, "utf8")).mcpServers?.rea - ?.command !== cli + !(await isDirectRegistration( + [registration?.command, ...(registration?.args ?? [])], + cli, + )) ) throw new Error("packaged setup did not recover"); }; diff --git a/scripts/verify-qwen-client.mjs b/scripts/verify-qwen-client.mjs index b92619721d..1acb14f572 100644 --- a/scripts/verify-qwen-client.mjs +++ b/scripts/verify-qwen-client.mjs @@ -6,6 +6,7 @@ import { readFile, mkdtemp, realpath, + symlink, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve, sep } from "node:path"; @@ -34,6 +35,11 @@ const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen"; // npm exposes only qwen.cmd on Windows. Resolve a selected JavaScript entry // before the verifier switches to its disposable workspace. const entryPoint = /\.(c|m)?js$/iu.test(command) ? resolve(command) : undefined; +const reducedPath = process.env.REA_VERIFY_QWEN_REDUCED_PATH === "1"; +assert( + !reducedPath || (process.platform !== "win32" && entryPoint !== undefined), + "REA_VERIFY_QWEN_REDUCED_PATH=1 requires POSIX and a JavaScript REA_VERIFY_QWEN_COMMAND entry point", +); const launch = (args) => entryPoint === undefined ? [command, args] @@ -51,12 +57,15 @@ let evidenceSeen = false; let skillSeen = false; let finalSeen = false; let skillRequested = false; -let searchRequested = false; +let searchCallId; +let analysisCallId; let artifactReadByAgent = false; let artifactPath; let selectedSkillLoaded = false; let artifactDigest; let catalogSize = null; +let registrationCommand = null; +let clientPath = null; const commands = await createVerifierCommands(); await withVerifierCleanup(async () => { await withVerifierCleanup(async () => { @@ -139,12 +148,47 @@ await withVerifierCleanup(async () => { const configured = JSON.parse( await readFile(join(profile, "settings.json"), "utf8"), ); + registrationCommand = [ + configured.mcpServers.rea.command, + ...configured.mcpServers.rea.args, + ]; assert.deepEqual(configured.model, { name: "rea-client-fixture" }); assert.equal(configured.skills, undefined); assert.deepEqual(configured.mcpServers.other, { command: "unrelated-server", disabled: true, }); + clientPath = baseEnvironment.PATH; + if (reducedPath) { + assert.equal( + configured.mcpServers.rea.env?.PATH, + undefined, + "Setup must not persist its ambient PATH into the registration", + ); + clientPath = join(lab, "client-bin"); + await mkdir(clientPath); + // Qwen's Evidence readback uses its shell tool, which launches bare bash. + // Keep that fixture dependency available without exposing node/npm/npx. + const bash = await commands.exec("bash", ["-c", "command -v bash"], { + env: baseEnvironment, + timeout: 5_000, + }); + await symlink( + await realpath(bash.stdout.trim()), + join(clientPath, "bash"), + ); + await commands.exec( + process.execPath, + [ + "-e", + "const {spawnSync}=require('node:child_process');const assert=require('node:assert/strict');for(const command of ['node','npm','npx'])assert.equal(spawnSync(command,['--version'],{timeout:1000}).error?.code,'ENOENT',`${command} unexpectedly available on reduced PATH`);", + ], + { + env: { ...baseEnvironment, PATH: clientPath }, + timeout: 5_000, + }, + ); + } const target = join(lab, "fixture α测试"); await mkdir(target, { recursive: true }); await writeFile( @@ -184,15 +228,21 @@ await withVerifierCleanup(async () => { skillSeen ||= JSON.stringify(body.messages ?? []).includes( "reverse-engineer-anything", ); - const messageText = (body.messages ?? []) - .map((message) => + const messages = (body.messages ?? []).map((message) => ({ + role: message.role, + callId: message.tool_call_id, + text: typeof message.content === "string" ? message.content : (message.content ?? []) .map((part) => part.text ?? "") .join("\n"), - ) - .join("\n"); + })); + const messageText = messages.map((message) => message.text).join("\n"); + const toolResponseText = (callId) => + messages.find( + (message) => message.role === "tool" && message.callId === callId, + )?.text ?? ""; selectedSkillLoaded ||= messageText.includes( join(sharedSkills, "reverse-engineer-anything"), ); @@ -262,9 +312,7 @@ await withVerifierCleanup(async () => { t.name.includes("analyze_javascript_application"), ); const reviewedNames = [ - ...JSON.stringify( - (body.messages ?? []).filter((m) => m.role === "tool"), - ).matchAll( + ...toolResponseText(searchCallId).matchAll( /(?:\\?"name\\?"\s*:\s*\\?")([^"\\]*analyze_javascript_application)(?:\\?")/g, ), ] @@ -274,7 +322,7 @@ await withVerifierCleanup(async () => { const invoke = tools.find((t) => t.name === "tool_call"); const skill = tools.find((t) => t.name === "skill"); const shell = tools.find((t) => t.name === "run_shell_command"); - const persisted = messageText.match( + const persisted = toolResponseText(analysisCallId).match( /Full output saved to: ([^\r\n]+)/u, ); const call = (name, args) => ({ @@ -308,26 +356,32 @@ await withVerifierCleanup(async () => { const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`; // Keep paths and JSON quotes out of shell syntax on Windows and POSIX. const encodedScript = Buffer.from(script).toString("base64"); + const nodeCommand = + process.platform === "win32" + ? "node" + : `'${process.execPath.replaceAll("'", "'\"'\"'")}'`; message = call(shell.name, { - command: `node -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`, + command: `${nodeCommand} -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`, description: "Read the complete saved REA Evidence and verify the fixture facts.", }); - } else if (selected) + } else if (selected) { message = call(selected.name, { input_path: target, format: "directory", }); - else if (reviewedNames.length && invoke) + analysisCallId = message.tool_calls[0].id; + } else if (reviewedNames.length && invoke) { message = call(invoke.name, { name: reviewedNames[0], arguments: { input_path: target, format: "directory" }, }); - else if (search && !searchRequested) { - searchRequested = true; + analysisCallId = message.tool_calls[0].id; + } else if (search && searchCallId === undefined) { message = call(search.name, { query: "analyze_javascript_application", }); + searchCallId = message.tool_calls[0].id; } else throw new Error( `REA analysis tool not exposed; tools: ${tools.map((t) => t.name).join(", ")}`, @@ -342,6 +396,7 @@ await withVerifierCleanup(async () => { : `Use REA to analyze the JavaScript directory ${target}. Report the result.`; const env = { ...baseEnvironment, + PATH: clientPath, OPENAI_API_KEY: "local-fixture-key", OPENAI_BASE_URL: endpoint, }; @@ -413,6 +468,9 @@ await withVerifierCleanup(async () => { client: "Qwen Code", clientVersion, mode, + reducedPath, + ...(reducedPath ? { clientPath } : {}), + registrationCommand, passed, artifacts: lab, runtimeRoot, diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index d71bf1a9d3..e439e1188f 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -1,8 +1,13 @@ import { accessSync, constants, realpathSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { isAbsolute } from "node:path"; import { normalizeEntrypointPath } from "../process/CurrentEntrypointPath.js"; import { valid } from "semver"; +import { + javaScriptRuntime, + type JavaScriptRuntime, +} from "../domain/runtimeVersion.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { PRODUCT_IDENTITY } from "../identity.js"; /** Package runners whose pinned launcher setup can register. */ @@ -110,6 +115,14 @@ const entrypointIdentity = ( ): ClientRegistrationCommandIdentity => { if (!isAbsolute(candidate) || !isAbsolute(current)) return "unknown"; if (candidate === current) return "owned"; + // Bun's realpath can normalize parent segments differently from its launcher. + // Without matching traversal semantics it cannot prove ownership of an alias. + if ( + process.platform !== "win32" && + javaScriptRuntime(process.versions).id === "bun" && + [candidate, current].some((path) => path.split("/").includes("..")) + ) + return "unknown"; try { return realpathSync.native(candidate) === realpathSync.native(current) ? "owned" @@ -125,7 +138,7 @@ const directEntrypointIdentity = ( current: string, ): ClientRegistrationCommandIdentity => { const nativeIdentity = entrypointIdentity(candidate, current); - const scriptPath = normalizeEntrypointPath(candidate); + const scriptPath = normalizeEntrypointPath(candidate, "node"); if (scriptPath === candidate) return nativeIdentity; // A shebang can pass the original spelling to Node, which selects its script // lexically. Without executing the launcher, divergent interpretations are unknown. @@ -135,19 +148,17 @@ const directEntrypointIdentity = ( : "unknown"; }; -/** Prefer an executable PATH alias that proves the same selected REA file. */ -export const registrationEntrypointPath = ( - entrypoint: string, +/** Preserve a manager-owned executable alias only when it selects the same file. */ +const sameFileExecutableAlias = ( + selected: string, + name: string, environment: Readonly, - platform: NodeJS.Platform, + identify: typeof entrypointIdentity, ): string => { - // A Windows command shim is a different program; file identity cannot prove - // its script target. Keep that platform's existing Node/script registration. - if (platform === "win32") return entrypoint; for (const directory of (environment.PATH ?? "").split(":")) { if (!isAbsolute(directory)) continue; - const candidate = join(directory, PRODUCT_IDENTITY.cliBinary); - if (directEntrypointIdentity(candidate, entrypoint) !== "owned") continue; + const candidate = absoluteCallerPath(`${directory}/${name}`); + if (identify(candidate, selected) !== "owned") continue; try { accessSync(candidate, constants.X_OK); return candidate; @@ -155,10 +166,43 @@ export const registrationEntrypointPath = ( // An unexecutable alias cannot launch the selected program. } } - return entrypoint; + return selected; }; -/** Preserve caller spelling only when both direct launchers prove the same file. */ +/** Prefer an executable PATH alias that proves the same selected REA file. */ +export const registrationEntrypointPath = ( + entrypoint: string, + environment: Readonly, + platform: NodeJS.Platform, +): string => + // A Windows command shim is a different program; file identity cannot prove + // its script target. Keep that platform's existing Node/script registration. + platform === "win32" + ? entrypoint + : sameFileExecutableAlias( + entrypoint, + PRODUCT_IDENTITY.cliBinary, + environment, + directEntrypointIdentity, + ); + +/** Prefer a same-file runtime alias that can survive its manager's version switch. */ +export const registrationRuntimePath = ( + executable: string, + runtime: JavaScriptRuntime["id"], + environment: Readonly, + platform: NodeJS.Platform, +): string => + platform === "win32" + ? executable + : sameFileExecutableAlias( + executable, + runtime, + environment, + entrypointIdentity, + ); + +/** Preserve aliases only when the execution form and selected file agree. */ export const equivalentClientRegistrationCommands = ( previous: readonly string[], requested: readonly string[], @@ -168,17 +212,21 @@ export const equivalentClientRegistrationCommands = ( previous.every((value, index) => value === requested[index]) ) return true; - const current = inspectClientRegistrationCommand( - requested, - undefined, - ).entrypoint; + const requestedFacts = inspectClientRegistrationCommand(requested, undefined); + const current = requestedFacts.entrypoint; if (current === undefined) return false; const prior = inspectClientRegistrationCommand(previous, current); - return prior.entrypoint !== undefined && prior.identity === "owned"; + return ( + prior.entrypoint !== undefined && + prior.identity === "owned" && + prior.launch === requestedFacts.launch + ); }; export interface ClientRegistrationCommandFacts { readonly identity: ClientRegistrationCommandIdentity; + /** Execution form is separate from ownership of the selected REA file. */ + readonly launch: "runtime_script" | "direct" | "package_runner" | "unknown"; /** Selected absolute launcher/script path; excludes package and unresolved PATH commands. */ readonly entrypoint: string | undefined; readonly package: @@ -208,6 +256,7 @@ export const inspectClientRegistrationCommand = ( normalized !== null; return { identity: owned ? "owned" : "foreign", + launch: "package_runner", entrypoint: undefined, package: { reference, @@ -235,7 +284,12 @@ const inspectDirectRegistrationCommand = ( const interpreter = command[0] ?? ""; const entrypoint = command[1] ?? ""; if (!isAbsolute(interpreter) || !isAbsolute(entrypoint)) - return { identity: "unknown", entrypoint: undefined, package: undefined }; + return { + identity: "unknown", + launch: "runtime_script", + entrypoint: undefined, + package: undefined, + }; const interpreterIdentity = entrypointIdentity( interpreter, process.execPath, @@ -243,13 +297,15 @@ const inspectDirectRegistrationCommand = ( if (interpreterIdentity !== "owned") return { identity: currentPathAvailable ? interpreterIdentity : "unknown", + launch: "runtime_script", entrypoint: undefined, package: undefined, }; - // Node selects its script from normalized argv; executable paths use native - // traversal semantics, where symlink/.. can denote a different file. + // The proven current runtime owns script selection: Node normalizes argv, + // while Bun follows native traversal through symlink/.. on POSIX. const selectedEntrypoint = normalizeEntrypointPath(entrypoint); return { + launch: "runtime_script", identity: selectedEntrypoint === undefined || !currentPathAvailable ? "unknown" @@ -261,9 +317,15 @@ const inspectDirectRegistrationCommand = ( if (command.length === 2 && command[1] === "mcp") { const executable = command[0] ?? ""; if (executable === PRODUCT_IDENTITY.cliBinary) - return { identity: "owned", entrypoint: undefined, package: undefined }; + return { + identity: "owned", + launch: "direct", + entrypoint: undefined, + package: undefined, + }; if (isAbsolute(executable)) return { + launch: "direct", identity: currentPathAvailable ? directEntrypointIdentity(executable, currentCommandPath) : "unknown", @@ -271,7 +333,17 @@ const inspectDirectRegistrationCommand = ( package: undefined, }; if (/[./\\]/u.test(executable)) - return { identity: "unknown", entrypoint: undefined, package: undefined }; + return { + identity: "unknown", + launch: "direct", + entrypoint: undefined, + package: undefined, + }; } - return { identity: "foreign", entrypoint: undefined, package: undefined }; + return { + identity: "foreign", + launch: "unknown", + entrypoint: undefined, + package: undefined, + }; }; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 6f6bbff0c1..d233a0f7d7 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -9,7 +9,6 @@ import { } from "./ClientConfigurationDocument.js"; import { lstat, stat } from "node:fs/promises"; import { err, ok, type Result } from "../domain/result.js"; -import { isAbsolute } from "node:path"; import { PRODUCT_IDENTITY } from "../identity.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; @@ -478,9 +477,9 @@ const registrationAligned = ({ return "aligned"; // Other owned package references remain stale until pinned to this version. if (commandFacts.package !== undefined) return "stale"; - // The identity owner already established an exact direct launcher match. - // A bare command still depends on the client's PATH, so cannot prove alignment. - return isAbsolute(command[0] ?? "") ? "aligned" : "stale"; + // Owning the script does not establish how its shebang interpreter resolves + // in the client. Setup migrates these launchers without losing ownership. + return commandFacts.launch === "runtime_script" ? "aligned" : "stale"; }; /** A client that rewrites its file from memory must be quit before setup. */ diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 79ef773826..d012eab433 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -12,6 +12,7 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import { invokingPackageRunner, registrationEntrypointPath, + registrationRuntimePath, packageRunnerRegistrationCommand, type PackageRunner, } from "./ClientRegistrationIdentity.js"; @@ -21,6 +22,7 @@ import { } from "./SetupRegistrationPrewarm.js"; import { SUPPORTED_NODE_VERSION_PROSE, + javaScriptRuntime, supportsNodeVersion, } from "../domain/runtimeVersion.js"; import { @@ -91,12 +93,20 @@ export const setupRegistrationCommand = ( if (packageRunner !== undefined) return packageRunnerRegistrationCommand(packageRunner, platform); if (entrypointPath === undefined || entrypointPath.length === 0) return []; - return platform === "win32" - ? [process.execPath, entrypointPath, "mcp"] - : [ - registrationEntrypointPath(entrypointPath, environment, platform), - "mcp", - ]; + const script = registrationEntrypointPath( + entrypointPath, + environment, + platform, + ); + const runtime = registrationRuntimePath( + process.execPath, + javaScriptRuntime(process.versions).id, + environment, + platform, + ); + // The script's env-node shebang depends on the client's PATH, which may + // differ from setup's shell. Bind the runtime that is executing setup. + return [runtime, script, "mcp"]; }; /** diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 79f6d9a6e6..fe50c915ed 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -50,7 +50,9 @@ const MCP_DOCTOR_OPTIONS = z.object({ const MCP_DOCTOR_ALIAS = { help: "h" } as const; const MCP_DOCTOR_COMMAND = "rea mcp doctor"; -const MCP_DOCTOR_DESCRIPTION = "Validate MCP server startup and tool listing"; +const MCP_DOCTOR_DESCRIPTION = "Validate MCP adapter startup and tool listing"; +const MCP_DOCTOR_SCOPE = + "Checks this REA adapter using the current runtime; does not verify a client's registered command or launch environment."; /** Help flags derived from `MCP_DOCTOR_OPTIONS` plus its alias. */ const HELP_FLAGS: ReadonlySet = new Set(["--help", "-h"]); @@ -67,7 +69,7 @@ const renderMcpDoctorHelp = (): string => hideGlobalOptions: true, options: MCP_DOCTOR_OPTIONS, alias: MCP_DOCTOR_ALIAS, - hint: "This entry accepts only the options above.", + hint: `${MCP_DOCTOR_SCOPE} This entry accepts only the options above.`, }); interface McpDoctorOptions { @@ -353,12 +355,13 @@ export const runProductionMcpDoctorCli = async ( exitCode: 1, }; } - const result = await runProductionMcpDoctor({ + const report = await runProductionMcpDoctor({ command: process.execPath, args: [resolve(input.dispatcherPath), "mcp"], cwd: input.packageRoot, environment: process.env, }); + const result = { ...report, scope: MCP_DOCTOR_SCOPE }; const outputValue = parsed.fullOutput ? { ok: result.healthy, diff --git a/src/process/CurrentEntrypointPath.ts b/src/process/CurrentEntrypointPath.ts index 23434472c9..372b89dba9 100644 --- a/src/process/CurrentEntrypointPath.ts +++ b/src/process/CurrentEntrypointPath.ts @@ -1,13 +1,21 @@ import { resolve } from "node:path"; +import { + javaScriptRuntime, + type JavaScriptRuntime, +} from "../domain/runtimeVersion.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; -/** Node's absolute argv script selection, or undefined when none was supplied. +/** Runtime-specific absolute argv script selection, or undefined when none was supplied. * Native executable paths have different traversal semantics and must not use this. */ export const normalizeEntrypointPath = ( entrypoint: string | undefined, + runtime: JavaScriptRuntime["id"] = javaScriptRuntime(process.versions).id, ): string | undefined => entrypoint === undefined || entrypoint.length === 0 ? undefined - : resolve(entrypoint); + : runtime === "bun" + ? absoluteCallerPath(entrypoint) + : resolve(entrypoint); export const CURRENT_ENTRYPOINT_PATH = normalizeEntrypointPath(process.argv[1]); diff --git a/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts b/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts index c7b57ecfc1..97a3611361 100644 --- a/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts +++ b/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts @@ -275,10 +275,7 @@ cliTest( peer: { command: ["/usr/bin/true"] }, rea: { type: "local", - command: - process.platform === "win32" - ? [process.execPath, launcher, "mcp"] - : [launcher, "mcp"], + command: [process.execPath, launcher, "mcp"], }, }); }, diff --git a/tests/acceptance/setup/launcherAliasesCli.test.ts b/tests/acceptance/setup/launcherAliasesCli.test.ts index 21faefff7a..0d1b9e75af 100644 --- a/tests/acceptance/setup/launcherAliasesCli.test.ts +++ b/tests/acceptance/setup/launcherAliasesCli.test.ts @@ -1,10 +1,134 @@ import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { expect } from "vitest"; +import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +cliTest.skipIf(process.platform === "win32")( + "migrates an owned shebang registration and starts its generated MCP command without PATH tools", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-registration-interpreter-"); + const profile = join(home, ".qwen"); + const config = join(profile, "settings.json"); + const entrypoint = resolve("scripts/rea.mjs"); + const retainedEnvironment = { PATH: "", CALLER_SETTING: "keep exactly" }; + const unrelated = { command: "other-server", args: ["keep"] }; + const original = JSON.stringify({ + mcpServers: { + rea: { + command: entrypoint, + args: ["mcp"], + env: retainedEnvironment, + }, + unrelated, + }, + }); + await mkdir(profile); + await writeFile(config, original); + const environment = { + HOME: home, + USERPROFILE: home, + QWEN_HOME: profile, + PATH: "", + HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"), + JAVA_HOME: join(home, "missing-java"), + }; + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: [...arguments_, "--json"], + cwd: home, + environment, + }); + const doctor = await run([ + "doctor", + "--client", + "qwen_code", + "--skill=false", + ]); + expect(doctor.exitCode, doctor.stdout).toBe(1); + expect(doctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "qwen_code", state: "stale" }), + ]), + }, + }); + expect(await readFile(config, "utf8")).toBe(original); + + const setupArguments = [ + "setup", + "--client", + "qwen_code", + "--skill=false", + "--yes", + ]; + const setup = await run(setupArguments); + expect(setup.exitCode, setup.stdout).toBe(0); + const configured = await readFile(config, "utf8"); + const document = z + .object({ + mcpServers: z.object({ + rea: z.object({ + command: z.string(), + args: z.array(z.string()), + env: z.record(z.string(), z.string()), + }), + unrelated: z.unknown(), + }), + }) + .parse(JSON.parse(configured)); + const registration = document.mcpServers.rea; + expect(registration).toMatchObject({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: retainedEnvironment, + }); + expect(document.mcpServers.unrelated).toEqual(unrelated); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + + const transport = new StdioClientTransport({ + command: registration.command, + args: registration.args, + cwd: home, + env: { ...environment, ...registration.env }, + stderr: "pipe", + }); + const client = new Client({ name: "setup-interpreter", version: "1" }); + try { + await client.connect(transport); + expect( + (await client.listTools()).tools.map(({ name }) => name), + ).toContain("binary_session"); + const session = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(session.isError).not.toBe(true); + expect(session.structuredContent).toMatchObject({ + result: { open: false }, + }); + } finally { + await client.close(); + await transport.close(); + } + + const repeated = await run(setupArguments); + expect(repeated.exitCode, repeated.stdout).toBe(0); + expect(repeated.json).toMatchObject({ + plannedActions: [], + appliedActions: [], + }); + expect(await readFile(config, "utf8")).toBe(configured); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + }, +); + cliTest.skipIf(process.platform === "win32").for([ ["rea", "rea-agents"], ["rea-agents", "rea"], diff --git a/tests/acceptance/setup/piClientSetup.test.ts b/tests/acceptance/setup/piClientSetup.test.ts index 52659a8569..26be36b70a 100644 --- a/tests/acceptance/setup/piClientSetup.test.ts +++ b/tests/acceptance/setup/piClientSetup.test.ts @@ -282,8 +282,8 @@ cliTest( other: { command: "other-server" }, rea: { type: "stdio", - command: process.platform === "win32" ? process.execPath : cliPath, - args: process.platform === "win32" ? [cliPath, "mcp"] : ["mcp"], + command: process.execPath, + args: [cliPath, "mcp"], }, }, autoEnableCodemode: false, diff --git a/tests/acceptance/setup/qwenProfileSetup.test.ts b/tests/acceptance/setup/qwenProfileSetup.test.ts index 9f52ba08ff..c14c915131 100644 --- a/tests/acceptance/setup/qwenProfileSetup.test.ts +++ b/tests/acceptance/setup/qwenProfileSetup.test.ts @@ -54,14 +54,8 @@ cliTest( mcpServers: { other: { command: "other-server" }, rea: { - command: - process.platform === "win32" - ? process.execPath - : resolve("scripts/rea.mjs"), - args: - process.platform === "win32" - ? [resolve("scripts/rea.mjs"), "mcp"] - : ["mcp"], + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], }, }, }); diff --git a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts index 2d35e6f40a..a3bdc98360 100644 --- a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts +++ b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts @@ -390,13 +390,7 @@ cliTest( mcp: { rea: clientRegistrationEntry( "opencode", - // Windows launches JavaScript through Node; otherwise setup repairs - // the command before this test can exercise layered environment data. - [ - ...(process.platform === "win32" ? [process.execPath] : []), - resolve("scripts/rea.mjs"), - "mcp", - ], + [process.execPath, resolve("scripts/rea.mjs"), "mcp"], env, ), }, diff --git a/tests/boundary/cli/mcpDoctorHelpCli.test.ts b/tests/boundary/cli/mcpDoctorHelpCli.test.ts index 7ca04e7ccb..1ad07f5032 100644 --- a/tests/boundary/cli/mcpDoctorHelpCli.test.ts +++ b/tests/boundary/cli/mcpDoctorHelpCli.test.ts @@ -19,6 +19,10 @@ describe("MCP doctor help boundary", () => { expect(result.stdout).toContain("Usage: rea mcp doctor [options]"); expect(result.stdout).toContain("--format "); expect(result.stdout).toContain("--full-output"); + expect(result.stdout).toContain("current runtime"); + expect(result.stdout).toContain( + "registered command or launch environment", + ); // Help returns before the diagnostic session starts, so no report // envelope and no startup diagnostics are produced. expect(result.stdout).not.toContain("healthy"); diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts index fd86387ecd..42f5249174 100644 --- a/tests/boundary/filesystem/clientConfigurationDialects.test.ts +++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts @@ -789,6 +789,12 @@ describe("client launcher traversal semantics", () => { await symlink(join(root, "other", "child"), join(root, "link")); const candidate = `${root}/link/../entry.mjs`; expect(await readFile(candidate, "utf8")).toBe("// foreign dispatcher\n"); + expect( + await readFile(normalizeEntrypointPath(candidate, "node")!, "utf8"), + ).toBe("// current dispatcher\n"); + expect( + await readFile(normalizeEntrypointPath(candidate, "bun")!, "utf8"), + ).toBe("// foreign dispatcher\n"); expect( classifyClientRegistrationCommand([candidate, "mcp"], current), ).toBe("unknown"); @@ -802,6 +808,9 @@ describe("client launcher traversal semantics", () => { ), ).toBe("owned"); const unresolvable = `${root}/missing/../entry.mjs`; + await expect( + readFile(normalizeEntrypointPath(unresolvable, "bun")!, "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); expect( classifyClientRegistrationCommand([unresolvable, "mcp"], current), ).toBe("unknown"); diff --git a/tests/boundary/filesystem/packageClientBackup.test.ts b/tests/boundary/filesystem/packageClientBackup.test.ts index 273e940017..2deb0561ca 100644 --- a/tests/boundary/filesystem/packageClientBackup.test.ts +++ b/tests/boundary/filesystem/packageClientBackup.test.ts @@ -1,11 +1,13 @@ import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { expect, it } from "vitest"; import { assertClientConfig } from "../../../scripts/verify-package-setup.mjs"; import { terminalModesRestored } from "../../../scripts/lib/verify-setup-terminal.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +const cli = resolve("scripts/rea.mjs"); + it("requires positive canonical-input and echo terminal mode tokens", () => { expect(terminalModesRestored("speed 38400 baud; icanon echo isig")).toBe( true, @@ -28,16 +30,16 @@ it("rejects a backup replaced with the post-setup client configuration", async ( const configured = JSON.stringify({ existing: true, model: "caller", - mcpServers: { rea: { command: process.execPath, args: ["mcp"] } }, + mcpServers: { rea: { command: process.execPath, args: [cli, "mcp"] } }, }); await writeFile(path, configured); await writeFile(`${path}.rea.backup`, original); - await assertClientConfig(process.execPath, path, original); + await assertClientConfig(cli, path, original); await writeFile(`${path}.rea.backup`, configured); - await expect( - assertClientConfig(process.execPath, path, original), - ).rejects.toThrow("packaged client backup failed"); + await expect(assertClientConfig(cli, path, original)).rejects.toThrow( + "packaged client backup failed", + ); expect(await readFile(path, "utf8")).toBe(configured); }); @@ -47,7 +49,7 @@ it("compares backup bytes even when invalid UTF-8 decodes to the same text", asy const original = Buffer.from('{"existing":true,"note":"\uFFFD"}\n'); const configured = JSON.stringify({ existing: true, - mcpServers: { rea: { command: process.execPath, args: ["mcp"] } }, + mcpServers: { rea: { command: process.execPath, args: [cli, "mcp"] } }, }); const changed = Buffer.concat([ Buffer.from('{"existing":true,"note":"'), @@ -57,7 +59,7 @@ it("compares backup bytes even when invalid UTF-8 decodes to the same text", asy expect(changed.toString("utf8")).toBe(original.toString("utf8")); await writeFile(path, configured); await writeFile(`${path}.rea.backup`, changed); - await expect( - assertClientConfig(process.execPath, path, original), - ).rejects.toThrow("packaged client backup failed"); + await expect(assertClientConfig(cli, path, original)).rejects.toThrow( + "packaged client backup failed", + ); }); diff --git a/tests/boundary/filesystem/setupLauncherAlias.test.ts b/tests/boundary/filesystem/setupLauncherAlias.test.ts index ca535f2311..6adf468f6d 100644 --- a/tests/boundary/filesystem/setupLauncherAlias.test.ts +++ b/tests/boundary/filesystem/setupLauncherAlias.test.ts @@ -1,6 +1,7 @@ import { access, mkdir, + readFile, realpath, rm, symlink, @@ -10,7 +11,12 @@ import { dirname, join } from "node:path"; import { expect, it } from "vitest"; import { setupRegistrationCommand } from "../../../src/application/SetupHost.js"; -import { classifyClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { + classifyClientRegistrationCommand, + registrationRuntimePath, +} from "../../../src/application/ClientRegistrationIdentity.js"; +import { readClientRegistrationInspections } from "../../../src/application/ClientRegistrationStatus.js"; +import { javaScriptRuntime } from "../../../src/domain/runtimeVersion.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it.skipIf(process.platform === "win32")( @@ -39,14 +45,14 @@ it.skipIf(process.platform === "win32")( const command = setupRegistrationCommand("linux", undefined, unstable, { PATH: join(latest, "bin"), }); - expect(command).toEqual([alias, "mcp"]); + expect(command).toEqual([process.execPath, alias, "mcp"]); expect(classifyClientRegistrationCommand(command, unstable)).toBe("owned"); await rm(latest); await symlink(next, latest, "dir"); await rm(old, { recursive: true }); await expect(access(unstable)).rejects.toMatchObject({ code: "ENOENT" }); - await access(command[0]!); - expect(await realpath(command[0]!)).toBe(join(next, scriptWithin("next"))); + await access(command[1]!); + expect(await realpath(command[1]!)).toBe(join(next, scriptWithin("next"))); expect( classifyClientRegistrationCommand( command, @@ -56,6 +62,168 @@ it.skipIf(process.platform === "win32")( }, ); +it.skipIf(process.platform === "win32").each(["node", "bun"] as const)( + "preserves a same-file %s runtime alias across a manager version switch", + async (runtime) => { + const root = await createTestTempDirectory("rea-stable-runtime-alias-"); + const old = join(root, "old"); + const next = join(root, "next"); + for (const [directory, contents] of [ + [old, "old runtime"], + [next, "next runtime"], + ] as const) { + await mkdir(directory); + await writeFile(join(directory, runtime), contents, { mode: 0o700 }); + } + const latest = join(root, "latest"); + await symlink(old, latest, "dir"); + const executable = join(old, runtime); + const alias = join(latest, runtime); + const selected = registrationRuntimePath( + executable, + runtime, + { PATH: latest }, + "linux", + ); + expect(selected).toBe(alias); + expect(await realpath(selected)).toBe(executable); + + await rm(latest); + await symlink(next, latest, "dir"); + await rm(old, { recursive: true }); + await expect(access(executable)).rejects.toMatchObject({ code: "ENOENT" }); + await access(selected); + expect(await realpath(selected)).toBe(join(next, runtime)); + expect( + registrationRuntimePath( + join(next, runtime), + runtime, + { PATH: latest }, + "linux", + ), + ).toBe(selected); + }, +); + +it.skipIf(process.platform === "win32")( + "does not replace the selected runtime with a foreign executable or manager shim", + async () => { + const root = await createTestTempDirectory("rea-runtime-alias-identity-"); + const foreign = join(root, "foreign"); + const missing = join(root, "missing"); + const valid = join(root, "valid"); + const runtime = javaScriptRuntime(process.versions).id; + const script = join(root, "rea.mjs"); + await writeFile(script, "// selected REA script\n"); + for (const directory of [foreign, missing, valid]) await mkdir(directory); + await writeFile(join(foreign, runtime), '#!/bin/sh\nexec node "$@"\n', { + mode: 0o700, + }); + await symlink(join(root, "absent"), join(missing, runtime)); + await symlink(process.execPath, join(valid, runtime)); + expect( + registrationRuntimePath( + process.execPath, + runtime, + { PATH: `${foreign}:${missing}:relative` }, + "linux", + ), + ).toBe(process.execPath); + expect( + registrationRuntimePath( + process.execPath, + runtime, + { PATH: `${foreign}:${valid}` }, + "linux", + ), + ).toBe(join(valid, runtime)); + expect( + setupRegistrationCommand("linux", undefined, script, { PATH: valid }), + ).toEqual([join(valid, runtime), script, "mcp"]); + expect( + registrationRuntimePath( + process.execPath, + runtime, + { PATH: valid }, + "win32", + ), + ).toBe(process.execPath); + }, +); + +it.skipIf(process.platform === "win32")( + "checks the native PATH alias instead of collapsing a symlink parent segment", + async () => { + const root = await createTestTempDirectory("rea-runtime-path-parent-"); + const runtime = javaScriptRuntime(process.versions).id; + await mkdir(join(root, "selected")); + await mkdir(join(root, "other", "child"), { recursive: true }); + await mkdir(join(root, "other", "selected")); + await symlink(join(root, "other", "child"), join(root, "link")); + await symlink(process.execPath, join(root, "selected", runtime)); + await writeFile( + join(root, "other", "selected", runtime), + "foreign runtime\n", + { mode: 0o700 }, + ); + expect( + registrationRuntimePath( + process.execPath, + runtime, + { + PATH: `${root}/link/../selected`, + }, + process.platform, + ), + ).toBe(process.execPath); + }, +); + +it.skipIf(process.platform === "win32")( + "retains unknown runtime identity after its executable disappears despite a known REA script", + async () => { + const home = await createTestTempDirectory("rea-removed-runtime-"); + const executable = join(home, "node"); + const script = join(home, "rea.mjs"); + const config = join(home, ".cursor", "mcp.json"); + await symlink(process.execPath, executable); + await writeFile(script, "// selected REA script\n"); + await mkdir(join(home, ".cursor")); + const command = [executable, script, "mcp"]; + const original = JSON.stringify({ + mcpServers: { + rea: { command: executable, args: [script, "mcp"] }, + }, + }); + await writeFile(config, original); + const inspect = () => + readClientRegistrationInspections(home, script, { + environment: {}, + selectedClientIds: ["cursor"], + }); + expect(await inspect()).toContainEqual( + expect.objectContaining({ + commandIdentity: "owned", + status: expect.objectContaining({ client: "cursor", state: "aligned" }), + }), + ); + await rm(executable); + expect(classifyClientRegistrationCommand(command, script)).toBe("unknown"); + expect(await inspect()).toContainEqual( + expect.objectContaining({ + commandIdentity: "unknown", + status: expect.objectContaining({ + client: "cursor", + state: "unknown", + command, + remediation: expect.any(String), + }), + }), + ); + expect(await readFile(config, "utf8")).toBe(original); + }, +); + it.skipIf(process.platform === "win32")( "keeps selected entrypoint when PATH aliases are foreign, unresolved, unexecutable or relative", async () => { @@ -77,12 +245,12 @@ it.skipIf(process.platform === "win32")( setupRegistrationCommand("linux", undefined, entrypoint, { PATH: `${foreign}:${missing}:relative`, }), - ).toEqual([entrypoint, "mcp"]); + ).toEqual([process.execPath, entrypoint, "mcp"]); expect( setupRegistrationCommand("linux", undefined, selected, { PATH: unexecutable, }), - ).toEqual([selected, "mcp"]); + ).toEqual([process.execPath, selected, "mcp"]); expect( setupRegistrationCommand("win32", undefined, entrypoint, { PATH: foreign, diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 0af3b8d3be..cf1a0193af 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -112,6 +112,9 @@ describe("production MCP doctor", () => { ok: false, error: { healthy: false, + scope: expect.stringContaining( + "does not verify a client's registered command", + ), checks: [{ name: "transport", ok: false }], }, meta: {