Files
reverse-engineering-anything/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts
T
47353e494d feat(config): make the bridge request ceiling configurable (#6376)
* 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>
2026-10-23 06:11:21 +08:00

767 lines
25 KiB
TypeScript

import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { expect } from "vitest";
import { Scalar, stringify } from "yaml";
import {
effectiveClientServer,
parseClientConfiguration,
clientRegistrationEntry,
} from "../../../src/application/ClientConfigurationDocument.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
// Registration configuration tests must not discover installed providers or
// plan provider-environment edits based on the machine running the suite.
const missingHopperAndGhidraEnvironment = (home: string) => ({
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
JAVA_HOME: join(home, "missing-java"),
});
cliTest.skipIf(process.platform !== "win32")(
"rejects lowercase Windows provider settings in doctor and setup without writing",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-registration-case-");
const config = join(home, ".cursor/mcp.json");
await mkdir(dirname(config), { recursive: true });
const original = JSON.stringify({
mcpServers: {
rea: clientRegistrationEntry(
"json",
[process.execPath, resolve("scripts/rea.mjs"), "mcp"],
{ rea_analysis_provider: "invalid provider" },
),
},
});
await writeFile(config, original);
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
...missingHopperAndGhidraEnvironment(home),
};
const doctor = await cli.run({
arguments: ["doctor", "--client", "cursor", "--skill=false", "--json"],
cwd: home,
environment,
});
expect(doctor.exitCode).toBe(1);
expect(doctor.json).toMatchObject({
scope_checks: expect.arrayContaining([
expect.objectContaining({
name: "registration:cursor",
ok: false,
classification: "config_drift",
remediation: expect.stringContaining("REA_ANALYSIS_PROVIDER"),
}),
]),
});
const setup = await cli.run({
arguments: [
"setup",
"--client",
"cursor",
"--skill=false",
"--yes",
"--json",
],
cwd: home,
environment,
});
expect(setup.exitCode).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("REA_ANALYSIS_PROVIDER"),
});
expect(await readFile(config, "utf8")).toBe(original);
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
},
);
const scenarios = [
{
client: "cursor",
format: "json",
path: ".cursor/mcp.json",
setting: "HOPPER_LOADER_ARGS_JSON",
invalid: { HOPPER_LOADER_ARGS_JSON: "{" },
repaired: { HOPPER_LOADER_ARGS_JSON: '["", "--loader", "Mach-O"]' },
},
{
client: "dsh",
format: "dsh",
path: ".dsh/cordis.patch.yml",
setting: "REA_GHIDRA_COMPILER_SPEC_ID",
invalid: { REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" },
repaired: {
REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp",
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
},
},
] as const;
cliTest.for([
{
client: "cursor",
format: "json",
path: ".cursor/mcp.json",
position: "value",
},
{
client: "cursor",
format: "json",
path: ".cursor/mcp.json",
position: "name",
},
{
client: "dsh",
format: "dsh",
path: ".dsh/cordis.patch.yml",
position: "value",
},
{
client: "dsh",
format: "dsh",
path: ".dsh/cordis.patch.yml",
position: "name",
},
{
client: "dsh",
format: "dsh",
path: ".dsh/cordis.patch.yml",
position: "expression name",
},
] as const)(
"refuses a NUL environment $position in $client before setup writes",
async (scenario, { cli }) => {
const home = await createTestTempDirectory("rea-registration-nul-");
const config = join(home, scenario.path);
await mkdir(dirname(config), { recursive: true });
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
DSH_HOME: join(home, ".dsh"),
...missingHopperAndGhidraEnvironment(home),
};
const registration = (env: Readonly<Record<string, string>>) => {
const entry = clientRegistrationEntry(
scenario.format,
[process.execPath, resolve("scripts/rea.mjs"), "mcp"],
env,
);
if (scenario.position === "expression name")
entry.env = Object.fromEntries(
Object.entries(env).map(([key, value]) => {
if (!key.includes("\0")) return [key, value];
const expression = new Scalar("process.env.CALLER_SETTING");
expression.tag = "tag:yaml.org,2002:js";
return [key, expression];
}),
);
return scenario.format === "dsh"
? stringify([
{
insert: [
{
id: "rea-mcp",
name: "@deepseek-ai/dsh-mcp-client",
config: entry,
},
],
},
])
: JSON.stringify({ mcpServers: { rea: entry } });
};
const valueMarker = "reaNulFixtureValue_6c04e2";
const original = registration(
scenario.position === "value"
? { CALLER_SETTING: `${valueMarker}\0value` }
: { ["CALLER\0SETTING"]: `${valueMarker} value` },
);
await writeFile(config, original);
const doctor = await cli.run({
arguments: [
"doctor",
"--client",
scenario.client,
"--skill=false",
"--json",
],
cwd: home,
environment,
});
expect(doctor.exitCode).toBe(1);
expect(doctor.json).toMatchObject({
scope_checks: expect.arrayContaining([
expect.objectContaining({
name: `registration:${scenario.client}`,
ok: false,
classification: "config_drift",
remediation: expect.stringContaining("NUL"),
}),
]),
});
const setup = await cli.run({
arguments: [
"setup",
"--client",
scenario.client,
"--skill=false",
"--yes",
"--json",
],
cwd: home,
environment,
});
expect(setup.exitCode).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("NUL"),
});
expect(setup.stdout + doctor.stdout).not.toContain(valueMarker);
expect(await readFile(config, "utf8")).toBe(original);
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
const repaired = registration({
CALLER_SETTING: "ordinary\nvalue=二\t",
EMPTY_SETTING: "",
});
await writeFile(config, repaired);
const healthy = await cli.run({
arguments: [
"doctor",
"--client",
scenario.client,
"--skill=false",
"--json",
],
cwd: home,
environment,
});
expect(healthy.exitCode, healthy.stdout).toBe(0);
expect(healthy.json).toMatchObject({ healthy: true });
expect(await readFile(config, "utf8")).toBe(repaired);
},
);
for (const scenario of scenarios)
cliTest(
`setup and doctor reject ${scenario.client} overrides rejected by MCP startup`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-registration-runtime-");
const config = join(home, scenario.path);
await mkdir(dirname(config), { recursive: true });
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
DSH_HOME: join(home, ".dsh"),
...missingHopperAndGhidraEnvironment(home),
};
const registration = (env: Readonly<Record<string, string>>) => {
const server = {
command: "stale-launcher",
args: ["mcp"],
env: { CALLER_SETTING: "keep exactly", ...env },
};
return scenario.client === "dsh"
? stringify([
{
insert: [
{
id: "rea-mcp",
name: "@deepseek-ai/dsh-mcp-client",
config: {
serverName: "rea",
transport: "stdio",
...server,
},
},
],
},
])
: JSON.stringify({ mcpServers: { rea: server } });
};
const original = registration(scenario.invalid);
await writeFile(config, original);
const startup = await cli.run({
arguments: ["mcp"],
cwd: home,
environment: { ...environment, ...scenario.invalid },
});
expect(startup.exitCode).toBe(1);
expect(startup.stderr).toContain(scenario.setting);
expect(startup.stdout).toBe("");
const run = (args: readonly string[]) =>
cli.run({ arguments: [...args, "--json"], cwd: home, environment });
const setup = await run([
"setup",
"--client",
scenario.client,
"--skill=false",
"--yes",
]);
expect(setup.exitCode).toBe(1);
expect(setup.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining(scenario.setting),
});
const doctor = await run(["doctor", "--client", scenario.client]);
expect(doctor.exitCode).toBe(1);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client: scenario.client,
state: "invalid",
remediation: expect.stringContaining(scenario.setting),
}),
]),
},
});
expect(await readFile(config, "utf8")).toBe(original);
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
// Model the caller repairing the named setting before rerunning setup.
const repaired = registration(scenario.repaired);
await writeFile(config, repaired);
const configured = await run([
"setup",
"--client",
scenario.client,
"--skill=false",
"--yes",
]);
expect(configured.exitCode, configured.stdout).toBe(0);
const text = await readFile(config, "utf8");
const parsed = parseClientConfiguration(text, scenario.format);
expect(effectiveClientServer(parsed, "rea")).toMatchObject({
env: { CALLER_SETTING: "keep exactly", ...scenario.repaired },
});
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(repaired);
const repeated = await run([
"setup",
"--client",
scenario.client,
"--skill=false",
"--yes",
]);
expect(repeated.json).toMatchObject({
status: "ready",
appliedActions: [],
plannedActions: [],
});
expect(await readFile(config, "utf8")).toBe(text);
},
);
cliTest(
"OpenCode CLI validates dependent settings after merging JSON and JSONC",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-layered-runtime-cli-");
const directory = join(home, ".config/opencode");
await mkdir(directory, { recursive: true });
const lower = join(directory, "opencode.json");
const upper = join(directory, "opencode.jsonc");
const content = (env: Record<string, string>) =>
JSON.stringify({
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",
],
env,
),
},
});
const lowerContent = content({
REA_GHIDRA_LANGUAGE_ID: "x86:LE:32:default",
});
const upperContent = content({ REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" });
await writeFile(lower, lowerContent);
await writeFile(upper, upperContent);
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
...missingHopperAndGhidraEnvironment(home),
};
const run = (args: readonly string[]) =>
cli.run({ arguments: [...args, "--json"], cwd: home, environment });
const setup = await run([
"setup",
"--client",
"opencode",
"--skill=false",
"--yes",
]);
expect(setup.exitCode, setup.stdout).toBe(0);
expect(setup.json).toMatchObject({
status: "ready",
plannedActions: [],
appliedActions: [],
});
const doctor = await run(["doctor", "--client", "opencode"]);
expect(doctor.exitCode, doctor.stdout).toBe(0);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "opencode", state: "aligned" }),
]),
},
});
expect(await readFile(lower, "utf8")).toBe(lowerContent);
expect(await readFile(upper, "utf8")).toBe(upperContent);
await writeFile(lower, content({ CALLER_SETTING: "retained" }));
const missing = await run([
"setup",
"--client",
"opencode",
"--skill=false",
"--yes",
]);
expect(missing.exitCode).toBe(1);
expect(missing.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("REA_GHIDRA_COMPILER_SPEC_ID"),
});
const invalidDoctor = await run(["doctor", "--client", "opencode"]);
expect(invalidDoctor.exitCode).toBe(1);
expect(invalidDoctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "opencode", state: "invalid" }),
]),
},
});
expect(await readFile(upper, "utf8")).toBe(upperContent);
for (const path of [lower, upper])
await expect(access(`${path}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
},
);
cliTest(
"public setup propagates selected provider deadlines and input profile to Codex and Kimi",
async ({ cli }) => {
const home = await createTestTempDirectory(
"rea-selected-registration-env-",
);
const codexHome = join(home, "codex");
const kimiHome = join(home, "kimi");
await Promise.all(
[codexHome, kimiHome].map((path) => mkdir(path, { recursive: true })),
);
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
CODEX_HOME: codexHome,
KIMI_CODE_HOME: kimiHome,
...missingHopperAndGhidraEnvironment(home),
REA_ANALYSIS_PROVIDER: "auto",
REA_BRIDGE_MAX_REQUEST_BYTES: "33554432",
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700001",
REA_HOPPER_STARTUP_TIMEOUT_MS: "2800002",
REA_JEB_MCP_TIMEOUT_MS: "2900003",
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256",
REA_REGEX_MATCHING_DEADLINE_MS: "10000",
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256",
REA_MCP_INITIALIZE_BUDGET_MS: "16000",
REA_MCP_TOOLS_LIST_BUDGET_MS: "6000",
REA_MCP_FIRST_CATALOG_BUDGET_MS: "21000",
REA_MCP_DOCTOR_DEADLINE_MS: "31000",
REA_UNRELATED_MARKER: "do-not-persist",
};
const run = (arguments_: readonly string[]) =>
cli.run({
arguments: [...arguments_, "--json"],
cwd: home,
environment,
});
for (const client of ["codex", "kimi"] as const) {
const setup = await run([
"setup",
"--client",
client,
"--skill=false",
"--yes",
]);
expect(setup.exitCode, setup.stderr).toBe(0);
expect(setup.json).toMatchObject({ status: "ready" });
}
const codexConfig = await readFile(join(codexHome, "config.toml"), "utf8");
const codexRegistration = effectiveClientServer(
parseClientConfiguration(codexConfig, "toml"),
"rea",
);
expect(codexRegistration).toMatchObject({ tool_timeout_sec: 3021 });
expect(codexConfig).toContain('REA_BRIDGE_MAX_REQUEST_BYTES = "33554432"');
expect(codexConfig).toContain('REA_JEB_MCP_TIMEOUT_MS = "2900003"');
expect(codexConfig).toContain(
'REA_CAPTURE_DECODER_OLD_GENERATION_MIB = "256"',
);
expect(codexConfig).toContain('REA_REGEX_MATCHING_DEADLINE_MS = "10000"');
expect(codexConfig).toContain('REA_REGEX_WORKER_HEAP_REQUEST_MIB = "256"');
expect(codexConfig).toContain('REA_MCP_INITIALIZE_BUDGET_MS = "16000"');
expect(codexConfig).toContain('REA_MCP_TOOLS_LIST_BUDGET_MS = "6000"');
expect(codexConfig).toContain('REA_MCP_FIRST_CATALOG_BUDGET_MS = "21000"');
expect(codexConfig).toContain('REA_MCP_DOCTOR_DEADLINE_MS = "31000"');
expect(codexConfig).not.toContain("REA_UNRELATED_MARKER");
const kimiPath = join(kimiHome, "mcp.json");
const kimiRegistration = effectiveClientServer(
parseClientConfiguration(await readFile(kimiPath, "utf8"), "kimi"),
"rea",
);
expect(kimiRegistration).toMatchObject({
toolTimeoutMs: 3_020_003,
env: {
REA_ANALYSIS_PROVIDER: "auto",
REA_BRIDGE_MAX_REQUEST_BYTES: "33554432",
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700001",
REA_HOPPER_STARTUP_TIMEOUT_MS: "2800002",
REA_JEB_MCP_TIMEOUT_MS: "2900003",
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256",
REA_REGEX_MATCHING_DEADLINE_MS: "10000",
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256",
REA_MCP_INITIALIZE_BUDGET_MS: "16000",
REA_MCP_TOOLS_LIST_BUDGET_MS: "6000",
REA_MCP_FIRST_CATALOG_BUDGET_MS: "21000",
REA_MCP_DOCTOR_DEADLINE_MS: "31000",
},
});
expect(await readFile(kimiPath, "utf8")).not.toContain(
"REA_UNRELATED_MARKER",
);
const doctor = await run(["doctor", "--client", "kimi"]);
expect(doctor.exitCode, doctor.stderr).toBe(0);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({ client: "kimi", state: "aligned" }),
]),
},
});
},
120_000,
);
for (const [client, provider] of [
["codex", "ida"],
["kimi", "ida"],
["kimi", "auto"],
] as const)
cliTest(
`public ${client} setup and doctor use the effective IDA registration request budget with ${provider} selection`,
async ({ cli }) => {
const home = await createTestTempDirectory(
"rea-ida-registration-budget-",
);
const clientHome = join(home, client);
await mkdir(clientHome, { recursive: true });
const idaConfig = join(home, "ida.json");
const writeRegistration = (timeoutMs: number) =>
writeFile(
idaConfig,
JSON.stringify({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Bearer fixture-credential" },
timeoutMs,
}),
);
await writeRegistration(1_800_001);
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
CODEX_HOME: clientHome,
KIMI_CODE_HOME: clientHome,
...missingHopperAndGhidraEnvironment(home),
};
const setupArguments = [
"setup",
"--client",
client,
"--skill=false",
"--yes",
"--json",
];
const configured = await cli.run({
arguments: setupArguments,
cwd: home,
environment: {
...environment,
REA_ANALYSIS_PROVIDER: provider,
REA_IDA_MCP_CONFIG: idaConfig,
},
});
expect(configured.exitCode, configured.stdout).toBe(0);
const config = join(
clientHome,
client === "codex" ? "config.toml" : "mcp.json",
);
const text = await readFile(config, "utf8");
expect(
effectiveClientServer(
parseClientConfiguration(text, client === "codex" ? "toml" : "kimi"),
"rea",
),
).toMatchObject({
...(client === "codex"
? { tool_timeout_sec: 1921 }
: { toolTimeoutMs: 1_920_001 }),
env: { REA_ANALYSIS_PROVIDER: provider, REA_IDA_MCP_CONFIG: idaConfig },
});
expect(text).not.toContain("fixture-credential");
const run = (arguments_: readonly string[]) =>
cli.run({ arguments: arguments_, cwd: home, environment });
const healthy = await run(["doctor", "--client", client, "--json"]);
expect(healthy.exitCode, healthy.stdout).toBe(0);
expect(JSON.stringify(healthy.json)).not.toContain('"advisory"');
// Only the stored client's provider path identifies this changed budget;
// the doctor/setup process has no IDA configuration in its environment.
await writeRegistration(2_400_001);
const doctor = await run(["doctor", "--client", client, "--json"]);
expect(doctor.exitCode, doctor.stdout).toBe(0);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client,
state: "aligned",
advisory: expect.stringContaining(
client === "codex"
? "tool_timeout_sec = 2521 seconds"
: "toolTimeoutMs = 2520001 ms",
),
}),
]),
},
});
const repeated = await run(setupArguments);
expect(repeated.exitCode, repeated.stdout).toBe(0);
expect(repeated.json).toMatchObject({
status: "ready",
plannedActions: [],
appliedActions: [],
});
expect(await readFile(config, "utf8")).toBe(text);
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
if (provider === "ida") {
await writeRegistration(0);
const invalidDoctor = await run([
"doctor",
"--client",
client,
"--json",
]);
expect(invalidDoctor.exitCode).toBe(1);
expect(invalidDoctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client,
state: "invalid",
remediation: expect.stringContaining("REA_IDA_MCP_CONFIG"),
}),
]),
},
});
const invalidSetup = await run(setupArguments);
expect(invalidSetup.exitCode).toBe(1);
expect(invalidSetup.stdout).toContain("REA_IDA_MCP_CONFIG");
expect(await readFile(config, "utf8")).toBe(text);
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
}
},
120_000,
);
for (const invalidSetting of [
{ setting: "REA_MCP_INPUT_SCHEMA_PROFILE", value: "unknown" },
{ setting: "REA_GHIDRA_STARTUP_TIMEOUT_MS", value: "0" },
{ setting: "REA_JEB_MCP_TIMEOUT_MS", value: "1.5" },
] as const)
cliTest(
`setup rejects malformed selected ${invalidSetting.setting} before writing`,
async ({ cli }) => {
const home = await createTestTempDirectory(
"rea-invalid-selected-registration-env-",
);
const kimiHome = join(home, "kimi");
await mkdir(kimiHome, { recursive: true });
const config = join(kimiHome, "mcp.json");
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
KIMI_CODE_HOME: kimiHome,
...missingHopperAndGhidraEnvironment(home),
[invalidSetting.setting]: invalidSetting.value,
};
const result = await cli.run({
arguments: [
"setup",
"--client",
"kimi",
"--skill=false",
"--yes",
"--json",
],
cwd: home,
environment,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "configuration_invalid",
category: "invalid_input",
details: {
settings: [
expect.objectContaining({ setting: invalidSetting.setting }),
],
},
});
expect(`${result.stdout}\n${result.stderr}`).toContain(
invalidSetting.setting,
);
await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" });
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
},
120_000,
);