diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts index c4048e9488..a40d069c89 100644 --- a/src/application/ClientConfigurationFiles.ts +++ b/src/application/ClientConfigurationFiles.ts @@ -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>, ): Promise< Result > => { @@ -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, ); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 9501438f99..355d24ba58 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -138,8 +138,6 @@ export interface DoctorHost { providerInspections?(): Promise; /** Resolve a parsed upstream request budget from the effective client environment. */ resolveProviderRequestBudget?: ProviderRequestBudgetResolver; - /** Validated provider settings preserved by setup, independent of readiness. */ - providerRegistrationEnvironment?(): Readonly>; installationPaths?(): Promise; /** Undefined checks installed skill roots; explicit selections stay scoped. */ installedSkillIdentity?( @@ -523,9 +521,6 @@ export interface SystemDoctorHostOptions { resourceScope: RetainedResourceScope, signal: AbortSignal, ) => Promise; - readonly providerRegistrationEnvironment?: () => Readonly< - Record - >; readonly resolveProviderRequestBudget?: ( provider: Parameters[0], environment: Parameters[1], @@ -726,12 +721,6 @@ export const systemDoctorHost = ( options.providerInspections!(resourceScope, inspectionSignal), ), }), - ...(options.providerRegistrationEnvironment === undefined - ? {} - : { - providerRegistrationEnvironment: - options.providerRegistrationEnvironment, - }), ...(resolveProviderRequestBudget === undefined ? {} : { resolveProviderRequestBudget }), diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index ca0e1b3d05..d58d49158e 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -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 => { 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 { diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index f76396e916..569a0b3d16 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -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 }), diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index fff31b3223..4eb04d06d1 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -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", diff --git a/src/config/environment.ts b/src/config/environment.ts index 63bea529b7..ba19d64982 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -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) diff --git a/src/jeb/JebMcpEndpoint.ts b/src/config/jebMcpEndpoint.ts similarity index 100% rename from src/jeb/JebMcpEndpoint.ts rename to src/config/jebMcpEndpoint.ts diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 6a88086d50..7905af5a33 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -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>, ): Readonly> => { - 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; }; diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index 94dd414460..242ea5fb96 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -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> = - 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, diff --git a/src/jeb/JebMcpEndpoint.test.ts b/src/jeb/JebMcpEndpoint.test.ts index d07ad0d586..3415f7d650 100644 --- a/src/jeb/JebMcpEndpoint.test.ts +++ b/src/jeb/JebMcpEndpoint.test.ts @@ -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", () => { diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 6ce87b99f0..8f2380528e 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -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, diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 0f22cafabe..00a2347709 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -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 }); diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index 54d6b46ac0..8c0a103205 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -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"); diff --git a/tests/boundary/setup/setupJebEndpointSelection.test.ts b/tests/boundary/setup/setupJebEndpointSelection.test.ts new file mode 100644 index 0000000000..1353d35817 --- /dev/null +++ b/tests/boundary/setup/setupJebEndpointSelection.test.ts @@ -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 = { + 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); +});