mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(setup): register explicit MCP runtimes and preserve launcher identity
This commit is contained in:
1 parent
b8ce3c74cd
commit
d69a54d592
19 files changed
+637
-100
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user