mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
fix(setup): preserve and validate selected JEB endpoints
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user