fix(setup): register explicit MCP runtimes and preserve launcher identity

This commit is contained in:
morluto
2026-10-26 02:31:41 +08:00
parent b8ce3c74cd
commit d69a54d592
19 changed files with 637 additions and 100 deletions
+43
View File
@@ -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 <client-id> --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`.
+18
View File
@@ -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
+41 -10
View File
@@ -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");
};
+72 -14
View File
@@ -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,
+96 -24
View File
@@ -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<NodeJS.ProcessEnv>,
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<NodeJS.ProcessEnv>,
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<NodeJS.ProcessEnv>,
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,
};
};
+3 -4
View File
@@ -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. */
+16 -6
View File
@@ -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"];
};
/**
+6 -3
View File
@@ -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<string> = 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,
+10 -2
View File
@@ -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]);
@@ -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"],
},
});
},
@@ -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"],
+2 -2
View File
@@ -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,
@@ -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"],
},
},
});
@@ -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,
),
},
@@ -19,6 +19,10 @@ describe("MCP doctor help boundary", () => {
expect(result.stdout).toContain("Usage: rea mcp doctor [options]");
expect(result.stdout).toContain("--format <toon|json|yaml|md|jsonl>");
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");
@@ -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");
@@ -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",
);
});
@@ -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,
+3
View File
@@ -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: {