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 1/6] 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: { From 14dd18a7fdf3526a543ff0de7cea6d59bb73dae4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:31:52 +0800 Subject: [PATCH 2/6] fix(setup): preserve native configuration path traversal --- src/application/ClientConfigPath.ts | 9 +++-- .../ClientConfigurationPublication.ts | 5 ++- .../clientConfigurationTransactions.test.ts | 39 +++++++++++++++++++ tests/boundary/filesystem/setupConfig.test.ts | 36 +++++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/application/ClientConfigPath.ts b/src/application/ClientConfigPath.ts index 67e634b01b..52c1091bb8 100644 --- a/src/application/ClientConfigPath.ts +++ b/src/application/ClientConfigPath.ts @@ -1,5 +1,6 @@ import { lstat, realpath } from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, join } from "node:path"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; interface ClientConfigPathStats { readonly uid?: number; @@ -70,9 +71,9 @@ const resolveOwnedConfigSymlink = async ( const canonicalRegularConfigPath = async ( requestedPath: string, fileSystem: ClientConfigPathFileSystem, -): Promise => { +): Promise => { if (fileSystem.realpath === undefined) return requestedPath; - let ancestor = resolve(requestedPath); + let ancestor = absoluteCallerPath(requestedPath); const missing: string[] = []; while (true) { try { @@ -80,6 +81,8 @@ const canonicalRegularConfigPath = async ( } catch (cause: unknown) { if (!isAbsentPath(cause) || dirname(ancestor) === ancestor) return requestedPath; + // An absent directory before '..' cannot establish the selected parent. + if (basename(ancestor) === "..") return undefined; missing.unshift(basename(ancestor)); ancestor = dirname(ancestor); } diff --git a/src/application/ClientConfigurationPublication.ts b/src/application/ClientConfigurationPublication.ts index a2af20999d..1fb69059d9 100644 --- a/src/application/ClientConfigurationPublication.ts +++ b/src/application/ClientConfigurationPublication.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, resolve, sep } from "node:path"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { fileHandleMatchesBytes } from "../filesystem/fileHandleMatchesBytes.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js"; import { @@ -290,10 +291,10 @@ const isParentAliasRetarget = async ( ); } if (requestedStats.isSymbolicLink()) return false; - const requestedParent = dirname(resolve(requestedPath)); + const requestedParent = dirname(absoluteCallerPath(requestedPath)); try { const canonicalParent = await realpath(requestedParent); - return resolve(canonicalParent) !== requestedParent; + return canonicalParent !== requestedParent; } catch (cause: unknown) { void cause; return false; diff --git a/tests/boundary/filesystem/clientConfigurationTransactions.test.ts b/tests/boundary/filesystem/clientConfigurationTransactions.test.ts index f00eaf6fb2..6802427ada 100644 --- a/tests/boundary/filesystem/clientConfigurationTransactions.test.ts +++ b/tests/boundary/filesystem/clientConfigurationTransactions.test.ts @@ -2,9 +2,11 @@ import { chmod, copyFile, lstat, + mkdir, readFile, realpath, rm, + symlink, writeFile, } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; @@ -25,6 +27,43 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { ClientConfigurationPublication } from "../../../src/application/ClientConfigurationPublication.js"; +it + .skipIf(process.platform === "win32") + .each(["direct", "parent", "lexical-parent"] as const)( + "keeps the selected file after a parent alias retarget: %s", + async (variant) => { + const parentTraversal = variant !== "direct"; + const root = await createTestTempDirectory("rea-config-retarget-"); + const first = join(root, "first"); + const second = variant === "lexical-parent" ? root : join(root, "second"); + for (const directory of [first, second]) { + await mkdir(join(directory, "child"), { recursive: true }); + await writeFile(join(directory, "config.json"), "original"); + } + const alias = join(root, "link"); + await symlink(parentTraversal ? join(first, "child") : first, alias); + const requestedPath = `${alias}/${parentTraversal ? "../" : ""}config.json`; + const selected = join(first, "config.json"); + const state = await lstat(selected); + await rm(alias); + await symlink(parentTraversal ? join(second, "child") : second, alias); + const publication = await ClientConfigurationPublication.write( + selected, + "updated", + "original", + undefined, + { requestedPath, state }, + ); + await publication.verify(async () => { + await publication.assertCurrent(); + expect(await readFile(selected, "utf8")).toBe("updated"); + expect(await readFile(join(second, "config.json"), "utf8")).toBe( + "original", + ); + }); + }, +); + const failedVerificationFileSystem = ( failure: "verification" | "recovery", ): UninstallFileSystem => { diff --git a/tests/boundary/filesystem/setupConfig.test.ts b/tests/boundary/filesystem/setupConfig.test.ts index ea21eec1b3..94117bc95f 100644 --- a/tests/boundary/filesystem/setupConfig.test.ts +++ b/tests/boundary/filesystem/setupConfig.test.ts @@ -121,6 +121,42 @@ it.skipIf(process.platform === "win32")( }, ); +it.skipIf(process.platform === "win32").each([false, true])( + "preserves filesystem parent traversal when the configuration exists=%s", + async (exists) => { + const root = await createTestTempDirectory("rea-config-parent-traversal-"); + const selectedDirectory = join(root, "selected"); + await mkdir(join(selectedDirectory, "child"), { recursive: true }); + await symlink(join(selectedDirectory, "child"), join(root, "link"), "dir"); + const selected = join(selectedDirectory, "config.json"); + const unrelated = join(root, "config.json"); + const original = '{"theme":"keep"}\n'; + await writeFile(unrelated, original); + if (exists) await writeFile(selected, original); + const requested = `${root}/link/../config.json`; + expect(await resolveClientConfigTransactionPath(requested)).toBe(selected); + expect( + await configureClientConfiguration({ + name: "cursor", + format: "json", + configPath: requested, + }), + ).toMatchObject({ status: "configured" }); + expect(JSON.parse(await readFile(selected, "utf8"))).toMatchObject({ + mcpServers: { rea: { command: "npx" } }, + }); + expect(await readFile(unrelated, "utf8")).toBe(original); + await expect(lstat(`${unrelated}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + if (exists) + expect(await readFile(`${selected}.rea.backup`, "utf8")).toBe(original); + expect( + await resolveClientConfigTransactionPath(`${root}/absent/../config.json`), + ).toBeUndefined(); + }, +); + describe("JSON client configuration transaction: writing configuration through safe paths", () => { it.skipIf(typeof process.getuid !== "function")( "rejects a symlink target not owned by the current user", From 82fe820060bfd3fac705b5840ebe660146252f22 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:32:00 +0800 Subject: [PATCH 3/6] fix(setup): retain explicitly selected ILSpy paths --- .../SetupRegistrationEnvironment.ts | 1 + src/setupEnvironment.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index a08ded1798..6d5c96d03f 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -2,6 +2,7 @@ import type { DoctorProviderInspection } from "./Doctor.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; const SELECTED_REGISTRATION_SETTINGS = [ + "REA_ILSPY_CMD_PATH", "REA_ANALYSIS_PROVIDER", "REA_ASAR_MAX_HEADER_BYTES", "REA_BRIDGE_MAX_REQUEST_BYTES", diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 21ebeb268c..93e9917dca 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -23,6 +23,29 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; afterEach(() => vi.unstubAllEnvs()); +it.each([undefined, "/caller/tools with spaces/ilspycmd"])( + "preserves the explicit ILSpy path %s without inventing an unset value", + async (selected) => { + const host = systemSetupHost( + createDoctorHostFixture({ + ...(selected === undefined ? {} : { configuredIlspyCmdPath: selected }), + ilspyCmdVersion: async () => "ilspycmd 9.1.0", + }), + selected === undefined ? {} : { REA_ILSPY_CMD_PATH: selected }, + ); + try { + const state = await host.initialSetupState(); + expect(state.providerEnvironment.REA_ILSPY_CMD_PATH).toBe(selected); + if (selected !== undefined) + expect(state.doctor.checks).toContainEqual( + expect.objectContaining({ name: "ilspycmd", ok: true }), + ); + } finally { + await host.close?.(); + } + }, +); + it("plans setup and uninstall against selected home and client overrides", async () => { const root = await mkdtemp(join(tmpdir(), "rea-selected-setup-")); const environment = { From 37a7c2d6522171b2b0e8a52d56459749684ee713 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:32:01 +0800 Subject: [PATCH 4/6] fix(doctor): retain host uncertainty and resolved Java selection --- src/application/Doctor.test.ts | 60 ++++++++++++++++++++------- src/application/Doctor.ts | 4 +- src/application/DoctorDiagnostics.ts | 22 ++++++++-- src/ghidra/GhidraInstallation.test.ts | 18 ++++---- src/ghidra/GhidraInstallation.ts | 44 +++++++++++++------- 5 files changed, 105 insertions(+), 43 deletions(-) diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 5a1b36cb71..1c3a3adf74 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -495,6 +495,37 @@ describe("doctor runtime check", () => { ); }); +it.each([undefined, "", "not-a-version", "14garbage"])( + "retains unknown host support for macOS probe value %s", + async (version) => { + const result = await runDoctor( + undefined, + host({ macosVersion: () => Promise.resolve(version) }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "host", + ok: false, + classification: "unknown", + }), + ); + }, +); + +it.skipIf(process.platform !== "darwin")( + "reads the macOS version without PATH utilities", + async () => { + const system = systemDoctorHost({ + environment: { PATH: "/nonexistent" }, + }); + try { + expect(await system.macosVersion()).toMatch(/^\d+(?:\.\d+)*$/u); + } finally { + await system.close?.(); + } + }, +); + describe("doctor", () => { it("returns exact recovery for every failed diagnostic", async () => { const result = await runDoctor( @@ -518,22 +549,19 @@ describe("doctor", () => { }); }); - it.each(["11.7", undefined])( - "rejects unsupported macOS version %s", - async (version) => { - const result = await runDoctor( - undefined, - host({ macosVersion: () => Promise.resolve(version) }), - ); - expect(result.checks).toContainEqual( - expect.objectContaining({ - name: "host", - ok: false, - classification: "unsupported_host", - }), - ); - }, - ); + it.each(["11.7"])("rejects unsupported macOS version %s", async (version) => { + const result = await runDoctor( + undefined, + host({ macosVersion: () => Promise.resolve(version) }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "host", + ok: false, + classification: "unsupported_host", + }), + ); + }); it("admits only the experimental Windows x64 host boundary", async () => { const windows = await runDoctor( diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 26e0e66fd2..9b56c8190e 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -764,7 +764,9 @@ const readMacosVersion = async ( options: { readonly env: NodeJS.ProcessEnv }, ): Promise => { try { - return (await run("sw_vers", ["-productVersion"], options)).stdout.trim(); + return ( + await run("/usr/bin/sw_vers", ["-productVersion"], options) + ).stdout.trim(); } catch (cause: unknown) { // best-effort cleanup: optional OS version probing; failure means unknown. void cause; diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 0713c6b602..b927c3f2a4 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -129,8 +129,25 @@ const hostCheck = async (host: DoctorHost): Promise => { host.platform === "darwin" ? await host.macosVersion() : undefined; const linuxDistribution = host.platform === "linux" ? await host.linuxDistribution() : undefined; + const macosMajor = + macosVersion !== undefined && /^\d+(?:\.\d+)*$/u.test(macosVersion) + ? Number(macosVersion.split(".")[0]) + : undefined; + if (host.platform === "darwin" && macosMajor === undefined) + return { + name: "host", + ok: false, + classification: "unknown", + detail: + macosVersion === undefined + ? "The macOS version could not be determined." + : `The macOS version probe returned an unrecognized value: ${JSON.stringify(macosVersion)}.`, + remediation: "Check /usr/bin/sw_vers -productVersion and rerun doctor.", + }; const supported = - (host.platform === "darwin" && parseMajor(macosVersion) >= 12) || + (host.platform === "darwin" && + macosMajor !== undefined && + macosMajor >= 12) || (host.platform === "linux" && supportsReaHost(linuxDistribution)) || (host.platform === "win32" && host.architecture === "x64"); const detail = @@ -359,8 +376,5 @@ const unsupportedHopperRemediation = (configured: boolean): string => ? "Check HOPPER_LAUNCHER_PATH and its publisher verification evidence. Run rea setup to install and retain an authenticated official Hopper package for offline use." : "Run rea setup to install and retain an authenticated official Hopper package for offline use."; -const parseMajor = (version: string | undefined): number => - Number.parseInt(version?.split(".")[0] ?? "0", 10); - const providerCheckName = (providerId: string, name: string): boolean => name === providerId || name.startsWith(`${providerId}-`); diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 20da7d3fb7..45ee8169c2 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -1714,7 +1714,7 @@ it("explains the macOS keg-only JDK case without probing an unselected installat }, }), ); - expect(probed).toEqual(["java"]); + expect(probed).toEqual(["/usr/bin/java"]); expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({ status: "failed", code: "runtime_missing", @@ -1813,8 +1813,8 @@ it.each([ scenario === "explicit home" ? ["/selected-jdk/bin/java"] : scenario === "PATH succeeds" - ? ["java"] - : ["java", keg], + ? ["/usr/bin/java"] + : ["/usr/bin/java", keg], ); expect(inspection.status).toBe( scenario === "PATH succeeds" || scenario === "keg succeeds" @@ -1825,19 +1825,21 @@ it.each([ expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({ java_discovery_failures: expect.arrayContaining([ { - command: "java", + command: "/usr/bin/java", code: "runtime_missing", detail: expect.any(String), }, ]), }); } - if (scenario === "both fail") expect(inspection.javaCommand).toBe("java"); + if (scenario === "both fail") + expect(inspection.javaCommand).toBe("/usr/bin/java"); }, ); it.each([ - ["darwin", undefined, "/selected-bin/java", "java"], + ["darwin", undefined, "/selected-bin/java", "/selected-bin/java"], + ["darwin", undefined, "/linked/../jdk/java", "/linked/../jdk/java"], ["darwin", "/selected-jdk", undefined, "/selected-jdk/bin/java"], ["linux", undefined, undefined, "java"], ["win32", undefined, undefined, "java.exe"], @@ -1849,7 +1851,9 @@ it.each([ const inspection = await inspectGhidraInstallation( { resourceOwner: inspectionResources.owner, - environment: { PATH: "/selected-bin" }, + environment: { + PATH: `:relative-bin:${pathJava === undefined ? "/selected-bin" : pathJava.slice(0, -"/java".length)}`, + }, installDir: INSTALL, platform, architecture: "arm64", diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 3b7bde3d51..1866beec1a 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -11,6 +11,7 @@ import { ok, type Result } from "../domain/result.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { readGhidraExecutableHeader } from "./GhidraExecutableHeader.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { readGhidraInstallationProperties, type GhidraInstallationFileOptions, @@ -524,24 +525,37 @@ const installationCoordinates = async ( ), ) .filter((candidate) => host.executable(candidate)); - let javaCommand = (() => { + const selectedJava: { + command: string; + source: "configured" | "path" | "homebrew" | "default"; + } = (() => { if (options.javaHome !== undefined) - return path.join( - options.javaHome, - "bin", - platform === "win32" ? "java.exe" : "java", - ); + return { + source: "configured", + command: path.join( + options.javaHome, + "bin", + platform === "win32" ? "java.exe" : "java", + ), + }; if (platform !== "darwin") - return platform === "win32" ? "java.exe" : "java"; + return { + source: "default", + command: platform === "win32" ? "java.exe" : "java", + }; // Check the caller's PATH, never a file named java in the working directory. - const javaOnPath = options.environment.PATH?.split(":").some( - (directory) => - path.isAbsolute(directory) && - host.executable(path.join(directory, "java")), - ); - if (javaOnPath) return "java"; - return homebrewJavaCandidates()[0] ?? "java"; + const javaOnPath = options.environment.PATH?.split(":") + .filter((directory) => path.isAbsolute(directory)) + .map((directory) => absoluteCallerPath(`${directory}/java`, { platform })) + .find((candidate) => host.executable(candidate)); + if (javaOnPath !== undefined) + return { source: "path", command: javaOnPath }; + const homebrew = homebrewJavaCandidates()[0]; + return homebrew === undefined + ? { source: "default", command: "/usr/bin/java" } + : { source: "homebrew", command: homebrew }; })(); + let javaCommand = selectedJava.command; // Java is a prerequisite of a selected installation. Without one, or with a // relative JAVA_HOME, configuration validation refuses Ghidra before Java can // matter, so callers such as setup and doctor must not execute a host JVM. @@ -574,7 +588,7 @@ const installationCoordinates = async ( if ( platform === "darwin" && options.javaHome === undefined && - javaCommand === "java" && + (selectedJava.source === "path" || selectedJava.source === "default") && javaProbe !== undefined && !javaProbe.ok && !(javaProbe.error instanceof AnalysisError) && From db4d3abcda9be7c03cdc4cc97aa8f98b9cb49726 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:32:09 +0800 Subject: [PATCH 5/6] fix(setup): retain bounded startup diagnostics without losing failures --- .../SetupRegistrationPrewarm.test.ts | 42 ++++++++++++++++ src/application/SetupRegistrationPrewarm.ts | 40 +++++++--------- src/process/BoundedOutputExcerpt.ts | 46 ++++++++++++++++++ src/process/ExecFileBoundedOutput.ts | 48 ++++--------------- 4 files changed, 114 insertions(+), 62 deletions(-) create mode 100644 src/process/BoundedOutputExcerpt.ts diff --git a/src/application/SetupRegistrationPrewarm.test.ts b/src/application/SetupRegistrationPrewarm.test.ts index 9b6d3288d7..0362089381 100644 --- a/src/application/SetupRegistrationPrewarm.test.ts +++ b/src/application/SetupRegistrationPrewarm.test.ts @@ -147,6 +147,48 @@ describe("materializeRegistrationRunnerCache", () => { }); describe("launchRegistrationRunnerCommand", () => { + it("retains bounded head and tail bytes and preserves UTF-8 across writes", async () => { + const result = await launchRegistrationRunnerCommand( + process.execPath, + [ + "-e", + ` + process.stdout.write(Buffer.from([0xe4])); + setTimeout(() => { + process.stdout.write(Buffer.from([0xb8, 0xad])); + process.stderr.write('x'.repeat(60000)); + setTimeout(() => { + process.stderr.write('y'.repeat(60000)); + process.stderr.write('FINAL: package permission denied'); + process.exitCode = 1; + }, 30); + }, 30); + `, + ], + { + env: process.env, + cwd: process.cwd(), + timeoutMs: 5000, + platform: process.platform, + }, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe("中"); + expect(Buffer.byteLength(result.stderr)).toBeLessThan(64 * 1024 + 50); + expect(result.stderr).toContain("[output omitted]"); + expect(result.stderr).toContain("FINAL: package permission denied"); + const prewarm = await materializeRegistrationRunnerCache( + ["npx", "-y", specifier, "mcp"], + {}, + async () => result, + ); + expect(prewarm.status).toBe("failed"); + if (prewarm.status === "failed") { + expect(prewarm.reason).toContain("exit 1"); + expect(prewarm.reason).toContain("FINAL: package permission denied"); + } + }); + it("captures a successful command exit", async () => { const result = await launchRegistrationRunnerCommand( process.execPath, diff --git a/src/application/SetupRegistrationPrewarm.ts b/src/application/SetupRegistrationPrewarm.ts index 63cf3878f0..7457ff87c3 100644 --- a/src/application/SetupRegistrationPrewarm.ts +++ b/src/application/SetupRegistrationPrewarm.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import { tmpdir } from "node:os"; +import { BoundedOutputExcerpt } from "../process/BoundedOutputExcerpt.js"; import { cleanupWindowsProcessTree } from "../process/ProcessOwnership.js"; import { inspectClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; @@ -123,8 +124,10 @@ export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = ( // group no longer receives the terminal's signal. ...(options.platform === "win32" ? {} : { detached: true }), }); - const stdout = boundedStream(child.stdout); - const stderr = boundedStream(child.stderr); + const stdout = new BoundedOutputExcerpt(PREWARM_CAPTURE_LIMIT); + const stderr = new BoundedOutputExcerpt(PREWARM_CAPTURE_LIMIT); + child.stdout?.on("data", (chunk: Buffer) => stdout.append(chunk)); + child.stderr?.on("data", (chunk: Buffer) => stderr.append(chunk)); let timedOut = false; let spawnError: string | undefined; let interruptedSignal: string | undefined; @@ -164,8 +167,8 @@ export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = ( resolve({ exitCode: code ?? signal, signal: signal ?? null, - stdout: stdout(), - stderr: stderr(), + stdout: stdout.text, + stderr: stderr.text, timedOut, ...(spawnError === undefined ? {} : { spawnError }), ...(interruptedSignal === undefined ? {} : { interruptedSignal }), @@ -176,32 +179,25 @@ export const launchRegistrationRunnerCommand: RegistrationRunnerLauncher = ( /** Terminal interrupts that must also stop a detached package-runner tree. */ const FORWARD_SIGNALS: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; -const boundedStream = ( - stream: NodeJS.ReadableStream | null, -): (() => string) => { - let captured = ""; - stream?.on("data", (chunk: Buffer) => { - if (captured.length < PREWARM_CAPTURE_LIMIT) - captured += chunk.toString("utf8").slice(0, PREWARM_CAPTURE_LIMIT); - }); - return () => captured; -}; - const prewarmFailureReason = (launch: RegistrationRunnerLaunch): string => { - const observed = + const outcome = launch.spawnError !== undefined - ? launch.spawnError + ? "spawn failed" : launch.interruptedSignal !== undefined ? `the package runner was stopped by ${launch.interruptedSignal}` : launch.timedOut ? `the package runner timed out after the bounded prewarm window` - : `exit ${launch.exitCode === null ? "unknown" : String(launch.exitCode)}${ - launch.stderr.trim() === "" ? "" : `: ${launch.stderr.trim()}` - }`; - const normalized = observed + : `exit ${launch.exitCode === null ? "unknown" : String(launch.exitCode)}`; + const normalized = (launch.spawnError ?? launch.stderr) .replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "") .replace(/[\u0000-\u001f\u007f]+/gu, " ") .replace(/\s+/gu, " ") .trim(); - return `The pinned package-runner entry could not be materialized before the first client start (${normalized.slice(0, PREWARM_REASON_LIMIT)}); the first client start will install it inside its startup deadline.`; + const detailLimit = PREWARM_REASON_LIMIT - outcome.length - 2; + const detail = + normalized.length > detailLimit + ? `… ${normalized.slice(-(detailLimit - 2))}` + : normalized; + const observed = `${outcome}${detail === "" ? "" : `: ${detail}`}`; + return `The pinned package-runner entry could not be materialized before the first client start (${observed}); the first client start will install it inside its startup deadline.`; }; diff --git a/src/process/BoundedOutputExcerpt.ts b/src/process/BoundedOutputExcerpt.ts new file mode 100644 index 0000000000..439df43cb2 --- /dev/null +++ b/src/process/BoundedOutputExcerpt.ts @@ -0,0 +1,46 @@ +/** Byte-bounded head and tail diagnostics, decoded after stream chunks are joined. */ +export class BoundedOutputExcerpt { + #head: Buffer = Buffer.alloc(0); + #tail: Buffer = Buffer.alloc(0); + #total = 0; + readonly #headLimit: number; + readonly #tailLimit: number; + + constructor(readonly limit: number) { + this.#headLimit = Math.ceil(limit / 2); + this.#tailLimit = limit - this.#headLimit; + } + + append(chunk: Buffer): void { + this.#total += chunk.byteLength; + const headCount = Math.min( + chunk.byteLength, + this.#headLimit - this.#head.byteLength, + ); + if (headCount > 0) + this.#head = Buffer.concat([this.#head, chunk.subarray(0, headCount)]); + const rest = chunk.subarray(headCount); + if (rest.byteLength > 0 && this.#tailLimit > 0) { + const retained = rest.subarray(-this.#tailLimit); + this.#tail = Buffer.concat([ + this.#tail.subarray( + Math.max( + 0, + this.#tail.byteLength + retained.byteLength - this.#tailLimit, + ), + ), + retained, + ]); + } + } + + get truncated(): boolean { + return this.#total > this.limit; + } + + get text(): string { + return this.truncated + ? `${this.#head.toString("utf8")} … [output omitted] … ${this.#tail.toString("utf8")}` + : Buffer.concat([this.#head, this.#tail]).toString("utf8"); + } +} diff --git a/src/process/ExecFileBoundedOutput.ts b/src/process/ExecFileBoundedOutput.ts index fad51e2bce..a7d01e6411 100644 --- a/src/process/ExecFileBoundedOutput.ts +++ b/src/process/ExecFileBoundedOutput.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { BoundedOutputExcerpt } from "./BoundedOutputExcerpt.js"; export interface BoundedCommandOutput { readonly exitCode: string | number | null; @@ -21,31 +22,10 @@ export const execFileBoundedOutput = ( env: options.env, stdio: ["ignore", "pipe", "pipe"], }); - const headLimit = Math.ceil(options.limit / 2); - const tailLimit = options.limit - headLimit; - const stdout = { head: Buffer.alloc(0), tail: Buffer.alloc(0), total: 0 }; - const stderr = { head: Buffer.alloc(0), tail: Buffer.alloc(0), total: 0 }; - let outputTruncated = false; - const capture = - (stream: typeof stdout) => - (chunk: Buffer): void => { - stream.total += chunk.length; - const headCount = Math.min( - chunk.length, - Math.max(0, headLimit - stream.head.length), - ); - if (headCount > 0) - stream.head = Buffer.concat([ - stream.head, - chunk.subarray(0, headCount), - ]); - const rest = chunk.subarray(headCount); - if (rest.length > 0 && tailLimit > 0) - stream.tail = Buffer.concat([stream.tail, rest]).subarray(-tailLimit); - if (stream.total > options.limit) outputTruncated = true; - }; - child.stdout.on("data", capture(stdout)); - child.stderr.on("data", capture(stderr)); + const stdout = new BoundedOutputExcerpt(options.limit); + const stderr = new BoundedOutputExcerpt(options.limit); + child.stdout.on("data", (chunk: Buffer) => stdout.append(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.append(chunk)); let spawnError: NodeJS.ErrnoException | undefined; child.on("error", (error: NodeJS.ErrnoException) => { spawnError = error; @@ -53,21 +33,9 @@ export const execFileBoundedOutput = ( child.on("close", (code, signal) => { resolve({ exitCode: spawnError?.code ?? code ?? signal, - stdout: retainedOutput(stdout, options.limit), - stderr: retainedOutput(stderr, options.limit), - outputTruncated, + stdout: stdout.text, + stderr: stderr.text, + outputTruncated: stdout.truncated || stderr.truncated, }); }); }); - -const retainedOutput = ( - stream: { - readonly head: Buffer; - readonly tail: Buffer; - readonly total: number; - }, - limit: number, -): string => - stream.total > limit - ? `${stream.head.toString("utf8")} … [output omitted] … ${stream.tail.toString("utf8")}` - : Buffer.concat([stream.head, stream.tail]).toString("utf8"); From 1173c0a9dc1b352a60943a491462294db8f353ae Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:20:13 +0800 Subject: [PATCH 6/6] test(setup): align launch fixtures with runtime and host requirements --- tests/acceptance/setup/launcherAliasesCli.test.ts | 14 +++++++++++--- .../filesystem/clientConfigurationBom.test.ts | 2 +- .../filesystem/doctorSystemHostFilesystem.test.ts | 6 +++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/acceptance/setup/launcherAliasesCli.test.ts b/tests/acceptance/setup/launcherAliasesCli.test.ts index 0d1b9e75af..90d679f095 100644 --- a/tests/acceptance/setup/launcherAliasesCli.test.ts +++ b/tests/acceptance/setup/launcherAliasesCli.test.ts @@ -10,13 +10,21 @@ 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", + "migrates an owned shebang registration and starts its generated MCP command without a runtime on PATH", 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 restrictedPath = join(home, "path-without-runtime"); + await mkdir(restrictedPath); + // Linux process ownership requires procps; no JavaScript runtime is exposed. + if (process.platform === "linux") + await symlink("/bin/ps", join(restrictedPath, "ps")); + const retainedEnvironment = { + PATH: restrictedPath, + CALLER_SETTING: "keep exactly", + }; const unrelated = { command: "other-server", args: ["keep"] }; const original = JSON.stringify({ mcpServers: { @@ -34,7 +42,7 @@ cliTest.skipIf(process.platform === "win32")( HOME: home, USERPROFILE: home, QWEN_HOME: profile, - PATH: "", + PATH: restrictedPath, HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"), JAVA_HOME: join(home, "missing-java"), diff --git a/tests/boundary/filesystem/clientConfigurationBom.test.ts b/tests/boundary/filesystem/clientConfigurationBom.test.ts index c10c76741a..554577e60f 100644 --- a/tests/boundary/filesystem/clientConfigurationBom.test.ts +++ b/tests/boundary/filesystem/clientConfigurationBom.test.ts @@ -43,7 +43,7 @@ describe.each(["vscode", "copilot_cli", "opencode", "commandcode"])( await mkdir(dirname(client.configPath), { recursive: true }); await writeFile(client.configPath, original); const currentCommandPath = join(home, "rea"); - const command = [currentCommandPath, "mcp"]; + const command = [process.execPath, currentCommandPath, "mcp"]; expect(await inspectClientConfiguration(client, {}, command)).toEqual({ status: "update", backupPath: `${client.configPath}.rea.backup`, diff --git a/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts b/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts index 0abf149071..cf1396f0c8 100644 --- a/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts +++ b/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts @@ -23,7 +23,7 @@ it("threads the selected host and environment into system diagnostics", async () environment, execFileOutput: async (command, arguments_, options) => { calls.push({ command, arguments: arguments_, environment: options?.env }); - if (command === "sw_vers") + if (command === "/usr/bin/sw_vers") return { stdout: "15.0\n", stderr: "", @@ -52,12 +52,12 @@ it("threads the selected host and environment into system diagnostics", async () expect(host.platform).toBe("linux"); expect(host.architecture).toBe("x64"); await host.installationPaths?.(); - await host.macosVersion(); + expect(await host.macosVersion()).toBe("15.0"); await host.executable(process.execPath); expect(calls.map(({ command }) => command)).toEqual([ "which", - "sw_vers", + "/usr/bin/sw_vers", "ldd", ]); expect(