fix(setup): preserve and validate selected JEB endpoints

This commit is contained in:
morluto
2026-10-27 07:12:08 +08:00
parent dbe2c97af3
commit 08f99dbaf2
14 changed files with 173 additions and 46 deletions
+4 -3
View File
@@ -85,11 +85,12 @@ export const readConfigurationFile = async (
}
};
/** Read every applicable user file; malformed lower-priority files also stop setup. */
/** Read each layer; setup may validate its writable layer with proposed overrides. */
export const readClientConfigurationFiles = async (
client: SetupClient,
resources?: ArtifactResourceScope,
platform: NodeJS.Platform = process.platform,
writeOverrides?: Readonly<Record<string, string>>,
): Promise<
Result<readonly ClientConfigurationSource[], ClientConfigurationFileError>
> => {
@@ -104,7 +105,7 @@ export const readClientConfigurationFiles = async (
if (document.value !== undefined) {
const environment = clientRegistrationEnvironment(
document.value,
{},
path === client.configPath ? writeOverrides : undefined,
"fragment",
platform,
);
@@ -115,7 +116,7 @@ export const readClientConfigurationFiles = async (
}
const environment = clientRegistrationEnvironment(
effectiveClientConfiguration(documents, undefined, platform),
{},
writeOverrides,
"effective",
platform,
);
-11
View File
@@ -138,8 +138,6 @@ export interface DoctorHost {
providerInspections?(): Promise<readonly DoctorProviderInspection[]>;
/** Resolve a parsed upstream request budget from the effective client environment. */
resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
/** Validated provider settings preserved by setup, independent of readiness. */
providerRegistrationEnvironment?(): Readonly<Record<string, string>>;
installationPaths?(): Promise<readonly string[]>;
/** Undefined checks installed skill roots; explicit selections stay scoped. */
installedSkillIdentity?(
@@ -523,9 +521,6 @@ export interface SystemDoctorHostOptions {
resourceScope: RetainedResourceScope,
signal: AbortSignal,
) => Promise<readonly DoctorProviderInspection[]>;
readonly providerRegistrationEnvironment?: () => Readonly<
Record<string, string>
>;
readonly resolveProviderRequestBudget?: (
provider: Parameters<ProviderRequestBudgetResolver>[0],
environment: Parameters<ProviderRequestBudgetResolver>[1],
@@ -726,12 +721,6 @@ export const systemDoctorHost = (
options.providerInspections!(resourceScope, inspectionSignal),
),
}),
...(options.providerRegistrationEnvironment === undefined
? {}
: {
providerRegistrationEnvironment:
options.providerRegistrationEnvironment,
}),
...(resolveProviderRequestBudget === undefined
? {}
: { resolveProviderRequestBudget }),
+18 -3
View File
@@ -126,7 +126,12 @@ const configureClientDocument = async (
const format = client.format;
if (format === undefined || format === "unsupported")
return { status: "failed", reason: "readback" };
const files = await readClientConfigurationFiles(client);
const files = await readClientConfigurationFiles(
client,
undefined,
undefined,
environment,
);
if (!files.ok) {
throwCleanupFailure(files.error);
return { status: "failed", reason: "readback" };
@@ -420,7 +425,12 @@ export const clientConfigurationAligned = async (
): Promise<boolean> => {
if (client.configPathError !== undefined) return false;
try {
const files = await readClientConfigurationFiles(client);
const files = await readClientConfigurationFiles(
client,
undefined,
undefined,
providerEnvironment,
);
if (!files.ok) {
throwCleanupFailure(files.error);
return false;
@@ -492,7 +502,12 @@ const inspectClientDocument = async (
if (client.configPathError !== undefined)
return { status: "invalid", remediation: client.configPathError };
if (client.format === "unsupported") return { status: "already_current" };
const files = await readClientConfigurationFiles(client);
const files = await readClientConfigurationFiles(
client,
undefined,
undefined,
providerEnvironment,
);
if (!files.ok) {
throwCleanupFailure(files.error);
return {
-1
View File
@@ -231,7 +231,6 @@ export const systemSetupHost = (
const diagnosis = await runDoctor(undefined, doctorHost, scope);
const providerEnvironment: SetupProviderEnvironment = {
...providerRegistrationEnvironment(diagnosis.providerInspections ?? []),
...doctorHost.providerRegistrationEnvironment?.(),
...(diagnosis.hopperPath === undefined
? {}
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
@@ -66,6 +66,7 @@ const SELECTED_REGISTRATION_SETTINGS = [
"REA_GHIDRA_PROGRAM_NAME",
"REA_GHIDRA_NATIVEAOT_JAR",
"REA_HOPPER_STARTUP_TIMEOUT_MS",
"REA_JEB_MCP_URL",
"REA_JEB_MCP_TIMEOUT_MS",
"REA_MCP_INPUT_SCHEMA_PROFILE",
"REA_MCP_TOOL_FAMILIES",
+15
View File
@@ -39,6 +39,7 @@ import {
import { mcpToolFamiliesSchema } from "./mcpToolFamilies.js";
import { parseLoaderArgs, parseStringArray } from "./parsers.js";
import { processStartConfigurationSchema } from "./processStart.js";
import { parseJebMcpEndpoint } from "./jebMcpEndpoint.js";
const environmentSchema = z.object({
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
@@ -62,6 +63,20 @@ const environmentSchema = z.object({
.refine(isAbsolute, "REA_IDA_MCP_CONFIG must be absolute")
.optional(),
REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema.optional(),
REA_JEB_MCP_URL: z
.string()
.superRefine((value, context) => {
const parsed = parseJebMcpEndpoint(value);
if (!parsed.ok)
context.addIssue({
code: "custom",
message:
typeof parsed.error.cause === "string"
? parsed.error.cause
: parsed.error.message,
});
})
.optional(),
GHIDRA_INSTALL_DIR: z
.string()
.min(1)
+6 -3
View File
@@ -40,15 +40,18 @@ export const defaultHopperLauncherPath = (
};
/**
* Environment native analysis admits. JEB validates its own timeout during
* Environment native analysis admits. JEB validates its own settings during
* optional provider composition; that setting must not block unrelated
* CLI/MCP startup. Registration validation still checks it directly.
*/
const nativeAnalysisEnvironment = (
environment: Readonly<Record<string, string | undefined>>,
): Readonly<Record<string, string | undefined>> => {
const { REA_JEB_MCP_TIMEOUT_MS: _jebTimeout, ...appEnvironment } =
environment;
const {
REA_JEB_MCP_TIMEOUT_MS: _jebTimeout,
REA_JEB_MCP_URL: _jebUrl,
...appEnvironment
} = environment;
return appEnvironment;
};
-9
View File
@@ -9,7 +9,6 @@ import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js";
import { inspectIdaRegistration } from "./ida/IdaDoctor.js";
import { inspectKunaProvider } from "./kuna/KunaDoctor.js";
import { readIdaConfiguration } from "./ida/IdaConfiguration.js";
import { parseJebMcpEndpoint } from "./jeb/JebMcpEndpoint.js";
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
import { ok } from "./domain/result.js";
import { AnalysisCancelledError } from "./domain/analysisErrorCore.js";
@@ -50,17 +49,9 @@ export const createSystemDoctorHost = (
}
return registration;
};
const selectedJebEndpoint = environment.REA_JEB_MCP_URL;
const selectedJebRegistrationEnvironment: Readonly<Record<string, string>> =
selectedJebEndpoint !== undefined &&
selectedJebEndpoint.length > 0 &&
parseJebMcpEndpoint(selectedJebEndpoint).ok
? { REA_JEB_MCP_URL: selectedJebEndpoint }
: {};
return systemDoctorHost({
environment,
...(signal === undefined ? {} : { signal }),
providerRegistrationEnvironment: () => selectedJebRegistrationEnvironment,
resolveProviderRequestBudget: async (
analysisProvider,
registrationEnvironment,
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseJebMcpEndpoint } from "./JebMcpEndpoint.js";
import { parseJebMcpEndpoint } from "../config/jebMcpEndpoint.js";
describe("JEB MCP endpoint", () => {
it("rejects an explicit empty query or fragment instead of dropping it", () => {
+1 -1
View File
@@ -31,7 +31,7 @@ import { err, ok, type Result } from "../domain/result.js";
import {
parseJebMcpEndpoint,
DEFAULT_JEB_MCP_ENDPOINT,
} from "./JebMcpEndpoint.js";
} from "../config/jebMcpEndpoint.js";
import {
createStreamableHttpJebMcpConnection,
JEB_ENGINE_TOOLS,
-14
View File
@@ -180,20 +180,6 @@ it("preserves a validated selected JEB endpoint in setup registrations without c
expect(JSON.stringify(planned.plannedActions)).not.toContain(
ambientEndpoint,
);
expect(
createSystemDoctorHost({}).providerRegistrationEnvironment?.(),
).toEqual({});
expect(
createSystemDoctorHost({
REA_JEB_MCP_URL: "http://127.0.0.1:8425/mcp",
}).providerRegistrationEnvironment?.(),
).toEqual({ REA_JEB_MCP_URL: "http://127.0.0.1:8425/mcp" });
expect(
createSystemDoctorHost({
REA_JEB_MCP_URL: "https://example.com/mcp",
}).providerRegistrationEnvironment?.(),
).toEqual({});
} finally {
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
@@ -461,6 +461,38 @@ it.each(["linux", "win32"] as const)(
},
);
it("retains an unresolved JEB endpoint expression until a selected literal replaces it", async () => {
const parsed = parseClientConfiguration(
`- insert:
- id: rea-mcp
name: "@deepseek-ai/dsh-mcp-client"
config:
env:
REA_JEB_MCP_URL: !!js caller.endpoint
`,
"dsh",
);
const unresolved = await clientRegistrationEnvironmentDetailsWithBudget(
parsed,
{},
"effective",
undefined,
);
expect(unresolved.ok).toBe(true);
if (!unresolved.ok) throw unresolved.error;
expect(unresolved.value.unresolvedSettings).toEqual(
new Set(["REA_JEB_MCP_URL"]),
);
const endpoint = "http://127.0.0.1:8426/mcp/";
const selected = clientRegistrationEnvironmentDetails(parsed, {
REA_JEB_MCP_URL: endpoint,
});
expect(selected.ok).toBe(true);
if (!selected.ok) throw selected.error;
expect(selected.value.values.REA_JEB_MCP_URL).toBe(endpoint);
expect(selected.value.unresolvedSettings.size).toBe(0);
});
it.each(
[
"REA_ANALYSIS_PROVIDER",
@@ -641,6 +673,36 @@ it("rejects a non-string caller environment key before editing configuration", a
});
});
it("does not hide an invalid JEB endpoint in an untouched configuration layer", async () => {
const root = await createTestTempDirectory("rea-jeb-layer-validation-");
const lower = join(root, "lower.json");
const configPath = join(root, "opencode.json");
const bad = JSON.stringify({
mcp: {
rea: clientRegistrationEntry("opencode", ["rea", "mcp"], {
REA_JEB_MCP_URL: "https://example.com/mcp",
}),
},
});
await writeFile(lower, bad);
await writeFile(configPath, "{}\n");
const client = {
name: "opencode",
format: "opencode",
configPath,
configPaths: [lower, configPath],
} as const;
const selected = { REA_JEB_MCP_URL: "http://127.0.0.1:8426/mcp" };
expect(
await inspectClientConfiguration(client, selected, ["rea", "mcp"]),
).toMatchObject({
status: "invalid",
remediation: expect.stringContaining(lower),
});
expect(await readFile(lower, "utf8")).toBe(bad);
expect(await readFile(configPath, "utf8")).toBe("{}\n");
});
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");
@@ -0,0 +1,65 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createCli } from "../../../src/cli.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("validates selected and existing JEB endpoints without losing caller selection", async () => {
const home = await createTestTempDirectory("rea-setup-jeb-selection-");
const path = join(home, ".cursor/mcp.json");
await mkdir(join(home, ".cursor"));
const oldEndpoint = "http://127.0.0.1:8425/mcp";
const invalidEndpoint = "https://example.com/mcp";
const selectedEndpoint = "http://127.0.0.1:8426/mcp/";
const registration = (endpoint: string) =>
JSON.stringify({
mcpServers: {
rea: {
command: "rea-agents",
args: ["mcp"],
env: { REA_JEB_MCP_URL: endpoint },
},
},
});
const original = registration(oldEndpoint);
await writeFile(path, original);
const env: Record<string, string> = {
HOME: home,
USERPROFILE: home,
REA_JEB_MCP_URL: invalidEndpoint,
HOPPER_LAUNCHER_PATH: join(home, "missing"),
};
const setup = async () => {
let stdout = "";
await createCli(env).serve(
["setup", "--client", "cursor", "--skill=false", "--yes", "--json"],
{
env,
stdout: (chunk) => {
stdout += chunk;
},
exit: () => undefined,
},
);
return JSON.parse(stdout) as { status: string; remediation?: string };
};
const refused = await setup();
expect(refused.status).toBe("needs_human");
expect(refused.remediation).toContain("REA_JEB_MCP_URL");
expect(await readFile(path, "utf8")).toBe(original);
expect(parseConfig(env).ok).toBe(true);
delete env.REA_JEB_MCP_URL;
const invalidExisting = registration(invalidEndpoint);
await writeFile(path, invalidExisting);
expect((await setup()).status).toBe("needs_human");
expect(await readFile(path, "utf8")).toBe(invalidExisting);
env.REA_JEB_MCP_URL = selectedEndpoint;
const repaired = await setup();
expect(repaired.status, JSON.stringify(repaired)).toBe("ready");
const saved = JSON.parse(await readFile(path, "utf8"));
expect(saved.mcpServers.rea.env.REA_JEB_MCP_URL).toBe(selectedEndpoint);
});