mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
* fix(reconstruction): dedupe residual unknown ids with uniqueSorted Current main calls uniqueBy, which is not defined, so typecheck fails. Residual unknown ids are strings, and the adjacent lists already use uniqueSorted. Co-authored-by: morluto <morluto@users.noreply.github.com> * feat(config): make the bridge request ceiling configurable REA_BRIDGE_MAX_REQUEST_BYTES replaces the hard-coded 16 MiB Hopper and Cutter request payload ceiling. The default stays 16777216 bytes. Values outside 7464 to 268435456 bytes are configuration_invalid. Part of #6176. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): keep cleanup ownership when a sent command's reply is oversized An authenticated command may already have changed the Cutter document when its reply exceeds the frame ceiling. Return the existing unknown execution, with the ceiling diagnostic in the message, so receipt cleanup still waits. A frame that overflows before the command is sent remains a resource constraint. Co-authored-by: morluto <morluto@users.noreply.github.com> * style: format the bridge request ceiling sources oxfmt --check failed on the five files that set and test REA_BRIDGE_MAX_REQUEST_BYTES. Formatting only; an oversized Cutter reply still stays an unknown execution. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): exclude the LF from the response frame budget REA_BRIDGE_MAX_REQUEST_BYTES counts payload bytes. The Cutter response reader and authenticated encoder were reserving one byte for the terminating LF, so an exact-limit response was rejected. A reply that exceeds the ceiling after the command was sent still stays an unknown execution. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(bridge): measure escaped JSON before allocating the request A command of U+0001 characters can expand sixfold. JSON.stringify of that expansion can throw past Node's string ceiling before the payload check. Count the escaped size within REA_BRIDGE_MAX_REQUEST_BYTES first and refuse it as a resource constraint. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(config): derive the bridge request maximum from JSON expansion A 256 MiB ceiling still lets a shorter U+0001 command expand past Node's 536870888-character string limit. The maximum is now floor((536870888 - 296) / 7) = 76695798, the longest such command whose authenticated frame fits in one string. Requests are still measured before JSON.stringify. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(bridge): size the Cutter frame deadline and send the measured Hopper payload The authenticated Cutter command is encoded after the challenge, so a one-second read rejected an in-budget frame. That read now allows one second per mebibyte of the ceiling and never waits longer than 25 seconds. Hello stays at one second. Hopper writes the payload it already measured instead of serializing the same request again. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(config): bound the bridge request maximum by the serialized string The ceiling already measures escaped JSON, so dividing Node's string limit by the seven-byte expansion rejected encoded payloads that still fit. The maximum is 536870887, one byte under that string ceiling, leaving room for the framing LF. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(hopper): serialize a bridge request when it reaches the wire The queue kept each encoded frame, plus the caller's params, until Hopper finished the active request. A raised ceiling made a few waiting calls enough to exhaust the heap. The FIFO now keeps the params, and the frame is encoded only for the request being written. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): hash the bridge proof prefix and payload separately An exact-limit Cutter frame plus the HMAC prefix exceeds Node's single-string ceiling, so the client threw before sending an admitted command. The prefix and payload are now separate HMAC updates, including the response proof. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): keep recovery fields when a status path exceeds the frame A long current_file made the status reply miss the ceiling, and the fallback then dropped session_id and the completion receipts. The status now omits the path and version and still returns the session, connection proof, and up to 64 receipts. That required recovery frame is smaller than the 7464 floor. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): report an unknown path when status identity does not fit A bounded status kept the new document generation and omitted the path, so the client filled in the descriptor's older path. The observed path is sent when it fits. When it does not, current_file is null and is not replaced with that stale path. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): count structured replies by their encoded JSON size The response guard charged 16 bytes per primitive and two bytes per list slot, so a dense array under the configured ceiling was replaced with output_truncated. Numbers, booleans, null, and container punctuation now use their JSON lengths. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): stream authenticated responses in bounded chunks A fitting reply was signed from retained raw bytes, escaped bytes, and a joined frame, so a ceiling near 512 MiB could exhaust Cutter before send. Measure and sign JSON pieces, then write the same frame in 64 KiB chunks. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(bridge): keep Cutter admission from stalling or hiding sessions Read an unauthenticated frame off the accept loop. A command reply that does not fit keeps completion and generation and reports an oversized path as null. Discovery reports a ceiling mismatch instead of an absent session. The maximum HMAC proof is opt-in, and both bridges reject non-ASCII digits. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): bound frame scans and client response copies Search only newly read bytes for the framing LF. Unescape each socket chunk into one response string, check the proof, and then parse it. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): keep completion, admission, and dense replies bounded Report completed_connection only through connections that have terminated. Cap connection threads and keep the listener up if one fails to start. Batch scalar JSON and fail discovery when any ceiling mismatches. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): hash requests in parts and scan escapes once Update the client-proof prefix and request separately. Stop the response decoder at the first quote or backslash instead of rescanning the tail. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(cutter): confirm live ceiling mismatches and omitted output A stale descriptor from a previous ceiling is not a discovery mismatch until its hello proves a live authenticated bridge. A response discarded for the frame ceiling after dispatch reports output_truncated. Co-authored-by: morluto <morluto@users.noreply.github.com> * test(cutter): close the unauthenticated ceiling-mismatch fixture The listener that fails the hello proof is destroyed after its challenge so discovery cleanup does not wait on that socket. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
606 lines
20 KiB
TypeScript
606 lines
20 KiB
TypeScript
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { spawnSync } from "node:child_process";
|
|
import { join } from "node:path";
|
|
import { expect, it } from "vitest";
|
|
|
|
import {
|
|
clientRegistrationEntry,
|
|
effectiveClientServer,
|
|
parseClientConfiguration,
|
|
serializeClientConfiguration,
|
|
} from "../../../src/application/ClientConfigurationDocument.js";
|
|
import {
|
|
clientConfigurationAligned,
|
|
configureClientConfiguration,
|
|
inspectClientConfiguration,
|
|
} from "../../../src/application/SetupClientConfiguration.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
|
|
import {
|
|
clientRegistrationEnvironmentDetails,
|
|
clientRegistrationEnvironmentDetailsWithBudget,
|
|
} from "../../../src/application/ClientRegistrationEnvironment.js";
|
|
import { snapshotEnvironment } from "../../../src/process/snapshotEnvironment.js";
|
|
import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
|
import { ok } from "../../../src/domain/result.js";
|
|
import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js";
|
|
|
|
it("awaits the owner-parsed provider request budget without changing caller strings", async () => {
|
|
const parsed = parseClientConfiguration(
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
rea: {
|
|
command: "rea",
|
|
args: ["mcp"],
|
|
env: {
|
|
REA_ANALYSIS_PROVIDER: "ida",
|
|
REA_IDA_MCP_CONFIG: "/tmp/rea-registration.json/",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
"json",
|
|
);
|
|
const details = await clientRegistrationEnvironmentDetailsWithBudget(
|
|
parsed,
|
|
{},
|
|
"effective",
|
|
async (provider) => {
|
|
expect(provider).toBe("ida");
|
|
return ok(17);
|
|
},
|
|
);
|
|
expect(details).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
values: { REA_IDA_MCP_CONFIG: "/tmp/rea-registration.json/" },
|
|
timeoutInputs: { providerRequestTimeoutMs: 17 },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reuses desired registration facts within a snapshot and revalidates later inspections", async () => {
|
|
const home = await createTestTempDirectory("rea-registration-snapshot-");
|
|
const configPath = join(home, "mcp.json");
|
|
const environment = {
|
|
REA_ANALYSIS_PROVIDER: "ida",
|
|
REA_IDA_MCP_CONFIG: "/tmp/rea-registration.json/",
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
|
};
|
|
const original = JSON.stringify({
|
|
mcpServers: {
|
|
rea: {
|
|
command: "rea",
|
|
args: ["mcp"],
|
|
env: environment,
|
|
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
|
toolTimeoutMs: 450_000,
|
|
},
|
|
},
|
|
});
|
|
await writeFile(configPath, original);
|
|
const client = { name: "kimi", format: "kimi", configPath } as const;
|
|
const observations: string[] = [];
|
|
const resolveBudget = async (
|
|
provider: string,
|
|
observedEnvironment: Readonly<Record<string, string>>,
|
|
) => {
|
|
expect(observedEnvironment.REA_IDA_MCP_CONFIG).toBe(
|
|
environment.REA_IDA_MCP_CONFIG,
|
|
);
|
|
observations.push(provider);
|
|
return ok(17);
|
|
};
|
|
|
|
expect(
|
|
await clientConfigurationAligned(client, {}, ["rea", "mcp"], resolveBudget),
|
|
).toBe(true);
|
|
expect(observations).toEqual(["ida"]);
|
|
|
|
expect(
|
|
await inspectClientConfiguration(client, {}, ["rea", "mcp"], resolveBudget),
|
|
).toEqual({ status: "already_current" });
|
|
// Inspection checks both the layered preflight and the freshly read document.
|
|
expect(observations).toEqual(["ida", "ida", "ida"]);
|
|
expect(await readFile(configPath, "utf8")).toBe(original);
|
|
await expect(access(`${configPath}.rea.backup`)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
|
|
it.each(["win32", "linux"] as const)(
|
|
"uses the selected %s identity when inspecting lowercase registration settings",
|
|
async (platform) => {
|
|
const home = await createTestTempDirectory("rea-selected-platform-env-");
|
|
const path = join(home, ".cursor/mcp.json");
|
|
await mkdir(join(home, ".cursor"));
|
|
await writeFile(
|
|
path,
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
rea: clientRegistrationEntry(
|
|
"json",
|
|
npxRegistrationCommand(platform),
|
|
{ rea_analysis_provider: "invalid provider" },
|
|
),
|
|
},
|
|
}),
|
|
);
|
|
const statuses = await readClientRegistrationStatuses(home, "rea", {
|
|
platform,
|
|
environment: {},
|
|
});
|
|
expect(statuses.find(({ client }) => client === "cursor")).toMatchObject({
|
|
state: platform === "win32" ? "invalid" : "aligned",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform !== "win32").each([
|
|
{
|
|
key: "rea_analysis_provider",
|
|
override: "REA_ANALYSIS_PROVIDER",
|
|
value: "ghidra",
|
|
duplicate: false,
|
|
},
|
|
{ key: "true", override: "TRUE", value: "chosen", duplicate: false },
|
|
{ key: '"true"', override: "true", value: "chosen", duplicate: true },
|
|
{
|
|
key: "rea_analysis_provider",
|
|
override: "REA_ANALYSIS_PROVIDER",
|
|
value: "ghidra",
|
|
duplicate: true,
|
|
},
|
|
])(
|
|
"preserves DSH scalar-key identity and comments when replacing $key",
|
|
async ({ key, override, value, duplicate }) => {
|
|
const root = await createTestTempDirectory("rea-dsh-alias-comments-");
|
|
const configPath = join(root, "cordis.patch.yml");
|
|
const source = `- insert:\n - id: rea-mcp\n name: "@deepseek-ai/dsh-mcp-client"\n config:\n serverName: rea\n transport: stdio\n command: old-rea\n args: [mcp]\n env:\n # retain preceding rationale\n ${key}: "hopper" # retain inline rationale\n${duplicate ? ` ${override}: "ghidra" # retain winning rationale\n` : ""} CUSTOM: !!js caller.custom\n`;
|
|
await writeFile(configPath, source);
|
|
const client = { name: "dsh", format: "dsh", configPath } as const;
|
|
const result = await configureClientConfiguration(
|
|
client,
|
|
{ [override]: value },
|
|
["rea", "mcp"],
|
|
);
|
|
expect(result, JSON.stringify(result)).toMatchObject({
|
|
status: "configured",
|
|
});
|
|
const text = await readFile(configPath, "utf8");
|
|
expect(text).toContain("# retain preceding rationale");
|
|
expect(text).toContain("# retain inline rationale");
|
|
if (duplicate) expect(text).toContain("# retain winning rationale");
|
|
expect(text).toContain("!!js caller.custom");
|
|
const env = clientRegistrationEnvironmentDetails(
|
|
parseClientConfiguration(text, "dsh"),
|
|
);
|
|
expect(env.ok).toBe(true);
|
|
if (!env.ok) throw env.error;
|
|
expect(env.value.values).toHaveProperty(override, value);
|
|
expect(
|
|
Object.keys(env.value.values).filter(
|
|
(name) => name.toUpperCase() === override.toUpperCase(),
|
|
),
|
|
).toEqual([override]);
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(source);
|
|
expect(
|
|
await configureClientConfiguration(client, { [override]: value }, [
|
|
"rea",
|
|
"mcp",
|
|
]),
|
|
).toEqual({ status: "unchanged" });
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform !== "win32")(
|
|
"preserves a lowercase explicit Kimi schema profile instead of inserting a conflicting default",
|
|
async () => {
|
|
const home = await createTestTempDirectory("rea-kimi-case-");
|
|
await mkdir(join(home, ".kimi-code"));
|
|
const configPath = join(home, ".kimi-code/mcp.json");
|
|
await writeFile(
|
|
configPath,
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
rea: {
|
|
command: "old-rea",
|
|
args: ["mcp"],
|
|
env: { rea_mcp_input_schema_profile: "full" },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const client = { name: "kimi", format: "kimi", configPath } as const;
|
|
expect(
|
|
await configureClientConfiguration(
|
|
client,
|
|
{},
|
|
npxRegistrationCommand(process.platform),
|
|
),
|
|
).toMatchObject({ status: "configured" });
|
|
const parsed = parseClientConfiguration(
|
|
await readFile(configPath, "utf8"),
|
|
"kimi",
|
|
);
|
|
expect(effectiveClientServer(parsed, "rea")).toHaveProperty("env", {
|
|
rea_mcp_input_schema_profile: "full",
|
|
});
|
|
const statuses = await readClientRegistrationStatuses(home, "rea", {
|
|
environment: {},
|
|
});
|
|
expect(statuses.find(({ client }) => client === "kimi")).toMatchObject({
|
|
state: "aligned",
|
|
});
|
|
},
|
|
);
|
|
|
|
it
|
|
.skipIf(process.platform !== "win32")
|
|
.each(["hopper", "!!js caller.provider"])(
|
|
"replaces a Windows DSH environment alias without evaluating %s",
|
|
async (stored) => {
|
|
const root = await createTestTempDirectory("rea-dsh-case-");
|
|
const configPath = join(root, "cordis.patch.yml");
|
|
const source = `- insert:\n - id: rea-mcp\n name: "@deepseek-ai/dsh-mcp-client"\n config:\n serverName: rea\n transport: stdio\n command: old-rea\n args: [mcp]\n env:\n rea_analysis_provider: ${stored}\n CUSTOM: !!js caller.custom\n`;
|
|
await writeFile(configPath, source);
|
|
const client = { name: "dsh", format: "dsh", configPath } as const;
|
|
const overrides = { REA_ANALYSIS_PROVIDER: "ghidra" };
|
|
const configured = await configureClientConfiguration(client, overrides, [
|
|
"rea",
|
|
"mcp",
|
|
]);
|
|
expect(configured, JSON.stringify(configured)).toMatchObject({
|
|
status: "configured",
|
|
});
|
|
const text = await readFile(configPath, "utf8");
|
|
expect(text).not.toContain("rea_analysis_provider:");
|
|
expect(text).toContain("REA_ANALYSIS_PROVIDER: ghidra");
|
|
expect(text).toContain("!!js caller.custom");
|
|
const details = clientRegistrationEnvironmentDetails(
|
|
parseClientConfiguration(text, "dsh"),
|
|
);
|
|
expect(details.ok).toBe(true);
|
|
if (!details.ok) throw details.error;
|
|
expect(details.value.timeoutInputs.analysisProvider).toBe("ghidra");
|
|
expect(
|
|
await configureClientConfiguration(client, overrides, ["rea", "mcp"]),
|
|
).toEqual({ status: "unchanged" });
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform !== "win32").each([
|
|
{
|
|
existing: { rea_analysis_provider: "hopper" },
|
|
overrides: { REA_ANALYSIS_PROVIDER: "ghidra" },
|
|
expected: "ghidra",
|
|
},
|
|
{
|
|
existing: { REA_ANALYSIS_PROVIDER: "ghidra" },
|
|
overrides: { rea_analysis_provider: "hopper" },
|
|
expected: "hopper",
|
|
},
|
|
])(
|
|
"keeps Windows override precedence consistent with the launched environment: $expected",
|
|
({ existing, overrides, expected }) => {
|
|
const parsed = parseClientConfiguration(
|
|
JSON.stringify({
|
|
mcpServers: { rea: { command: "rea", args: ["mcp"], env: existing } },
|
|
}),
|
|
"json",
|
|
);
|
|
const result = clientRegistrationEnvironmentDetails(parsed, overrides);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.timeoutInputs.analysisProvider).toBe(expected);
|
|
expect(snapshotEnvironment(result.value.values).REA_ANALYSIS_PROVIDER).toBe(
|
|
expected,
|
|
);
|
|
expect(Object.keys(result.value.values)).toHaveLength(1);
|
|
const child = spawnSync(
|
|
process.execPath,
|
|
["-e", "process.stdout.write(process.env.REA_ANALYSIS_PROVIDER ?? '')"],
|
|
{
|
|
env: result.value.values,
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
},
|
|
);
|
|
expect(child.status, child.stderr).toBe(0);
|
|
expect(child.stdout).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform !== "win32")(
|
|
"rejects invalid lowercase known settings before writing a Windows registration",
|
|
async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-invalid-lowercase-environment-",
|
|
);
|
|
const configPath = join(root, "config.json");
|
|
const original = JSON.stringify({
|
|
mcpServers: {
|
|
rea: {
|
|
command: "rea",
|
|
args: ["mcp"],
|
|
env: { rea_analysis_provider: "invalid provider" },
|
|
},
|
|
},
|
|
});
|
|
await writeFile(configPath, original);
|
|
const result = await configureClientConfiguration(
|
|
{ name: "cursor", format: "json", configPath },
|
|
{},
|
|
["rea", "mcp"],
|
|
);
|
|
expect(result).toMatchObject({ status: "failed" });
|
|
expect(await readFile(configPath, "utf8")).toBe(original);
|
|
await expect(access(`${configPath}.rea.backup`)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["cursor", "json", "mcpServers", "env"],
|
|
["codex", "toml", "mcp_servers", "env"],
|
|
["opencode", "opencode", "mcp", "environment"],
|
|
["hermes", "hermes", "mcp_servers", "env"],
|
|
] as const)(
|
|
"preserves %s server environment through command and provider updates",
|
|
async (name, format, serversKey, environmentKey) => {
|
|
const root = await createTestTempDirectory("rea-setup-environment-");
|
|
const configPath = join(root, "config");
|
|
const client = { name, format, configPath };
|
|
const custom = {
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
|
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
|
|
REA_MCP_MAX_RESPONSE_BYTES: "33554432",
|
|
REA_BROWSER_EXECUTABLE: join(root, "custom-browser"),
|
|
GHIDRA_INSTALL_DIR: join(root, "old-ghidra"),
|
|
HOPPER_LOADER_ARGS_JSON: '["", "--loader", "Mach-O"]',
|
|
REA_REFERENCE_SECRET_PATTERNS_JSON: '["*.private", "local/**"]',
|
|
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
|
|
REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp",
|
|
CALLER_SETTING: "preserve exactly",
|
|
};
|
|
const serialized = serializeClientConfiguration(
|
|
{
|
|
[serversKey]: {
|
|
rea: clientRegistrationEntry(format, ["rea-old", "mcp"], custom),
|
|
other: clientRegistrationEntry(format, ["other"], { OTHER: "keep" }),
|
|
},
|
|
},
|
|
format,
|
|
);
|
|
if (!serialized.ok) throw new Error("fixture serialization failed");
|
|
const original = serialized.value;
|
|
await writeFile(configPath, original);
|
|
const providers = { GHIDRA_INSTALL_DIR: join(root, "new-ghidra") };
|
|
const command = ["rea", "mcp"];
|
|
expect(
|
|
await inspectClientConfiguration(client, providers, command),
|
|
).toMatchObject({ status: "update" });
|
|
expect(
|
|
await configureClientConfiguration(client, providers, command),
|
|
).toMatchObject({ status: "configured" });
|
|
const configured = await readFile(configPath, "utf8");
|
|
const parsed = parseClientConfiguration(configured, format);
|
|
expect(effectiveClientServer(parsed, "rea")).toHaveProperty(
|
|
environmentKey,
|
|
{ ...custom, ...providers },
|
|
);
|
|
expect(effectiveClientServer(parsed, "other")).toEqual(
|
|
effectiveClientServer(
|
|
parseClientConfiguration(original, format),
|
|
"other",
|
|
),
|
|
);
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
|
|
expect(await clientConfigurationAligned(client, providers, command)).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
await inspectClientConfiguration(client, providers, command),
|
|
).toEqual({ status: "already_current" });
|
|
expect(
|
|
await configureClientConfiguration(client, providers, command),
|
|
).toEqual({ status: "unchanged" });
|
|
expect(await readFile(configPath, "utf8")).toBe(configured);
|
|
},
|
|
);
|
|
|
|
it("retains legacy OpenCode environment when migrating into a native server table", async () => {
|
|
const root = await createTestTempDirectory("rea-opencode-environment-");
|
|
const configPath = join(root, "opencode.json");
|
|
const client = { name: "opencode", format: "opencode", configPath } as const;
|
|
const custom = { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" };
|
|
await writeFile(
|
|
configPath,
|
|
JSON.stringify({
|
|
mcp: {
|
|
rea: clientRegistrationEntry("opencode", ["old", "mcp"], custom),
|
|
servers: {
|
|
other: clientRegistrationEntry("opencode_v2", ["other"], {}),
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toMatchObject({ status: "configured" });
|
|
const parsed = parseClientConfiguration(
|
|
await readFile(configPath, "utf8"),
|
|
"opencode",
|
|
);
|
|
expect(parsed.legacyServers).toEqual({});
|
|
expect(parsed.servers.rea).toEqual(
|
|
clientRegistrationEntry("opencode_v2", ["rea", "mcp"], custom),
|
|
);
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toEqual({ status: "unchanged" });
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"non-string member",
|
|
{ REA_MCP_INPUT_SCHEMA_PROFILE: "compact", PORT: 8080 },
|
|
"env.PORT",
|
|
],
|
|
[
|
|
"boolean member",
|
|
{ REA_MCP_INPUT_SCHEMA_PROFILE: "compact", FLAG: true },
|
|
"env.FLAG",
|
|
],
|
|
[
|
|
"null member",
|
|
{ REA_MCP_INPUT_SCHEMA_PROFILE: "compact", VALUE: null },
|
|
"env.VALUE",
|
|
],
|
|
["array", [], "env"],
|
|
["string", "not-a-map", "env"],
|
|
["null map", null, "env"],
|
|
[
|
|
"empty profile",
|
|
{ REA_MCP_INPUT_SCHEMA_PROFILE: "" },
|
|
"REA_MCP_INPUT_SCHEMA_PROFILE",
|
|
],
|
|
[
|
|
"invalid profile",
|
|
{ REA_MCP_INPUT_SCHEMA_PROFILE: "bogus" },
|
|
"REA_MCP_INPUT_SCHEMA_PROFILE",
|
|
],
|
|
[
|
|
"empty output profile",
|
|
{ REA_MCP_OUTPUT_SCHEMA_PROFILE: "" },
|
|
"REA_MCP_OUTPUT_SCHEMA_PROFILE",
|
|
],
|
|
[
|
|
"invalid output profile",
|
|
{ REA_MCP_OUTPUT_SCHEMA_PROFILE: "bogus" },
|
|
"REA_MCP_OUTPUT_SCHEMA_PROFILE",
|
|
],
|
|
[
|
|
"invalid response budget",
|
|
{ REA_MCP_MAX_RESPONSE_BYTES: "1" },
|
|
"REA_MCP_MAX_RESPONSE_BYTES",
|
|
],
|
|
[
|
|
"ASAR header admission above its maximum",
|
|
{ REA_ASAR_MAX_HEADER_BYTES: "268435457" },
|
|
"REA_ASAR_MAX_HEADER_BYTES",
|
|
],
|
|
[
|
|
"regex matching deadline of zero",
|
|
{ REA_REGEX_MATCHING_DEADLINE_MS: "0" },
|
|
"REA_REGEX_MATCHING_DEADLINE_MS",
|
|
],
|
|
[
|
|
"capture decoder heap below its floor",
|
|
{ REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "7" },
|
|
"REA_CAPTURE_DECODER_OLD_GENERATION_MIB",
|
|
],
|
|
[
|
|
"regex worker heap request below its floor",
|
|
{ REA_REGEX_WORKER_HEAP_REQUEST_MIB: "6" },
|
|
"REA_REGEX_WORKER_HEAP_REQUEST_MIB",
|
|
],
|
|
[
|
|
"bridge request ceiling below its floor",
|
|
{ REA_BRIDGE_MAX_REQUEST_BYTES: "7463" },
|
|
"REA_BRIDGE_MAX_REQUEST_BYTES",
|
|
],
|
|
[
|
|
"bridge request ceiling above its maximum",
|
|
{ REA_BRIDGE_MAX_REQUEST_BYTES: "536870888" },
|
|
"REA_BRIDGE_MAX_REQUEST_BYTES",
|
|
],
|
|
[
|
|
"MCP initialize budget of zero",
|
|
{ REA_MCP_INITIALIZE_BUDGET_MS: "0" },
|
|
"REA_MCP_INITIALIZE_BUDGET_MS",
|
|
],
|
|
[
|
|
"MCP tools/list budget above its maximum",
|
|
{ REA_MCP_TOOLS_LIST_BUDGET_MS: "2147483648" },
|
|
"REA_MCP_TOOLS_LIST_BUDGET_MS",
|
|
],
|
|
[
|
|
"MCP first-catalog budget with a leading zero",
|
|
{ REA_MCP_FIRST_CATALOG_BUDGET_MS: "020000" },
|
|
"REA_MCP_FIRST_CATALOG_BUDGET_MS",
|
|
],
|
|
[
|
|
"MCP doctor deadline that is not decimal",
|
|
{ REA_MCP_DOCTOR_DEADLINE_MS: "30_000" },
|
|
"REA_MCP_DOCTOR_DEADLINE_MS",
|
|
],
|
|
[
|
|
"malformed loader JSON",
|
|
{ HOPPER_LOADER_ARGS_JSON: "{" },
|
|
"HOPPER_LOADER_ARGS_JSON",
|
|
],
|
|
[
|
|
"non-array loader JSON",
|
|
{ HOPPER_LOADER_ARGS_JSON: "{}" },
|
|
"HOPPER_LOADER_ARGS_JSON",
|
|
],
|
|
[
|
|
"NUL loader argument",
|
|
{ HOPPER_LOADER_ARGS_JSON: JSON.stringify(["a\0b"]) },
|
|
"HOPPER_LOADER_ARGS_JSON",
|
|
],
|
|
[
|
|
"malformed exclusion JSON",
|
|
{ REA_REFERENCE_SECRET_PATTERNS_JSON: "{" },
|
|
"REA_REFERENCE_SECRET_PATTERNS_JSON",
|
|
],
|
|
[
|
|
"empty exclusion pattern",
|
|
{ REA_REFERENCE_SECRET_PATTERNS_JSON: '[""]' },
|
|
"REA_REFERENCE_SECRET_PATTERNS_JSON",
|
|
],
|
|
[
|
|
"unpaired compiler specification",
|
|
{ REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" },
|
|
"REA_GHIDRA_COMPILER_SPEC_ID",
|
|
],
|
|
] as const)(
|
|
"refuses %s before changing the configuration",
|
|
async (_name, env, setting) => {
|
|
const home = await createTestTempDirectory("rea-invalid-environment-");
|
|
const directory = join(home, ".cursor");
|
|
const configPath = join(directory, "mcp.json");
|
|
await mkdir(directory);
|
|
const original = JSON.stringify({
|
|
mcpServers: { rea: { command: "rea", args: ["mcp"], env } },
|
|
});
|
|
await writeFile(configPath, original);
|
|
const client = { name: "cursor", format: "json", configPath } as const;
|
|
expect(
|
|
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toMatchObject({
|
|
status: "invalid",
|
|
remediation: expect.stringContaining(setting),
|
|
});
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toEqual({ status: "failed", reason: "readback" });
|
|
expect(await clientConfigurationAligned(client, {}, ["rea", "mcp"])).toBe(
|
|
false,
|
|
);
|
|
const statuses = await readClientRegistrationStatuses(home, "rea", {
|
|
environment: {},
|
|
});
|
|
expect(statuses.find(({ client }) => client === "cursor")).toMatchObject({
|
|
state: "invalid",
|
|
remediation: expect.stringContaining(setting),
|
|
});
|
|
expect(await readFile(configPath, "utf8")).toBe(original);
|
|
await expect(access(`${configPath}.rea.backup`)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|