From fa318995f5654b2c2cb76f496da9220d49fc12ca Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:36:34 +0800 Subject: [PATCH] fix(setup): include registered IDA budgets in client deadlines --- docs/client-configuration.md | 11 +- docs/ida-provider.md | 2 +- .../ClientRegistrationEnvironment.ts | 29 +++- src/application/ClientRegistrationStatus.ts | 24 +++- src/application/Doctor.ts | 13 ++ src/application/SetupClientConfiguration.ts | 30 +++- src/application/SetupHost.ts | 25 +++- src/doctorRuntime.ts | 17 +++ src/mcpStartupPolicy.ts | 7 +- .../registrationRuntimeEnvironmentCli.test.ts | 136 ++++++++++++++++++ 10 files changed, 278 insertions(+), 16 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index c209aaf926..add6ac0021 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -148,8 +148,10 @@ startup; an explicit Ghidra startup timeout of 2700000 ms produces a 2820000 ms tool timeout. The startup allowance follows an explicitly selected Ghidra or Hopper provider, and otherwise uses the larger known Ghidra/Hopper deadline. An explicit `REA_JEB_MCP_TIMEOUT_MS` request budget also contributes to that -maximum. Setup forwards these explicitly selected REA deadlines and the input -schema profile into the registration; unrelated process values are omitted. +maximum. For `ida` or `auto` selection, a valid IDA registration's parsed +`timeoutMs` also contributes to that maximum; its default is 300000 ms. +Setup forwards these explicitly selected REA deadlines and the input schema +profile into the registration; unrelated process values are omitted. This headroom is a practical default, not a guarantee for every analysis duration. Setup preserves positive timeout values already in the `rea` entry. You can edit `startupTimeoutMs` and `toolTimeoutMs` there; doctor keeps positive @@ -241,8 +243,9 @@ the default Ghidra allowance; an explicit Ghidra startup timeout of 2700 seconds yields 2820 seconds. Setup preserves positive caller-selected timeout values in the REA entry, and doctor reports a non-blocking advisory when a tool timeout is shorter than the known provider allowance. An explicitly selected -JEB request budget also contributes to the allowance. These values are -client-side deadlines, so increase them in `~/.codex/config.toml` if a +JEB request budget also contributes to the allowance. A configured IDA +registration's parsed `timeoutMs` contributes for `ida` or `auto` selection. +These values are client-side deadlines, so increase them in `~/.codex/config.toml` if a workload needs more time. Devin honors `XDG_CONFIG_HOME` for its `devin/mcp_config.json` on non-Windows systems. diff --git a/docs/ida-provider.md b/docs/ida-provider.md index ce4ba7c683..e32b9054f2 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -133,7 +133,7 @@ Use a package version that actually includes this adapter; repository main can b Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. An upstream SDK request that reaches this limit returns `provider_timeout` with its operation and actual request budget. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Each health request is capped by the time remaining in that overall wait, so a stalled read cannot extend the readiness deadline by another full request timeout. Expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started after the deadline. That error names the busy tool from the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. If a health request itself consumes the remaining wait budget, the same last-busy timeout is returned with the stalled request's captured output. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works. -For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client `. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis. +For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client `. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. For `ida` or `auto` selection, Codex/Kimi setup and doctor use the effective client registration's parsed `timeoutMs` (default 300000 ms) when recommending the agent's per-tool deadline. Existing positive client deadlines remain caller choices. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis. ## Coverage and evidence diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 518ce7abe9..7bbfc7aafb 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -14,6 +14,12 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { dshDocument } from "./DshCordisPatch.js"; import type { McpToolTimeoutInputs } from "../mcpStartupPolicy.js"; +import type { AnalysisProviderSelector } from "../contracts/providerSelection.js"; + +export type ProviderRequestBudgetResolver = ( + analysisProvider: AnalysisProviderSelector, + environment: Readonly>, +) => Result; export interface ClientRegistrationEnvironmentDetails { readonly values: Readonly>; @@ -41,6 +47,7 @@ export const clientRegistrationEnvironmentDetails = ( parsed: ClientConfigurationDocument | undefined, overrides: Readonly> = {}, validation: "fragment" | "effective" = "effective", + resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Result => { if (parsed?.dialect === "dsh") { const environment = dshDocument(parsed.document).environment( @@ -48,7 +55,11 @@ export const clientRegistrationEnvironmentDetails = ( validation, ); if (!environment.ok) return environment; - return validatedDetails(environment.value, validation); + return validatedDetails( + environment.value, + validation, + resolveProviderRequestBudget, + ); } const key = parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2" @@ -78,18 +89,29 @@ export const clientRegistrationEnvironmentDetails = ( // Validate known settings with the runtime's parser, but retain the original // string overrides, including unknown client-specific keys and no defaults. const environment = { ...existing.data, ...overrides }; - return validatedDetails(environment, validation); + return validatedDetails( + environment, + validation, + resolveProviderRequestBudget, + ); }; const validatedDetails = ( environment: Readonly>, validation: "fragment" | "effective", + resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Result => { const validated = validation === "fragment" ? parseEnvironmentValues(environment) : parseEnvironment(environment); if (!validated.ok) return validated; + const providerRequestBudget = + resolveProviderRequestBudget?.( + validated.value.REA_ANALYSIS_PROVIDER, + environment, + ) ?? ok(undefined); + if (!providerRequestBudget.ok) return providerRequestBudget; return ok({ values: Object.fromEntries( Object.entries(environment).sort(([left], [right]) => @@ -103,6 +125,9 @@ const validatedDetails = ( ...(validated.value.REA_JEB_MCP_TIMEOUT_MS === undefined ? {} : { jebMcpTimeoutMs: validated.value.REA_JEB_MCP_TIMEOUT_MS }), + ...(providerRequestBudget.value === undefined + ? {} + : { providerRequestTimeoutMs: providerRequestBudget.value }), }, }); }; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index faa2c42054..1a8352c320 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -35,6 +35,7 @@ import type { SetupClient } from "./SupportedClients.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { dshDocument } from "./DshCordisPatch.js"; import { clientRegistrationEnvironmentDetails } from "./ClientRegistrationEnvironment.js"; +import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; interface ClientRegistrationStatusBase { @@ -98,6 +99,7 @@ interface ClientRegistrationReadOptions { readonly platform?: NodeJS.Platform; readonly environment?: NodeJS.ProcessEnv; readonly resources?: ArtifactResourceScope; + readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver; } /** Registration facts shared by diagnostics and update maintenance. */ @@ -227,10 +229,26 @@ export const readClientRegistrationInspections = async ( const registration = parseRegistration(raw, effectiveClient); const registrationEnvironment = client.name === "kimi" || client.name === "codex" - ? clientRegistrationEnvironmentDetails(parsed) + ? clientRegistrationEnvironmentDetails( + parsed, + {}, + "effective", + options.resolveProviderRequestBudget, + ) : undefined; - if (registrationEnvironment !== undefined && !registrationEnvironment.ok) - throw registrationEnvironment.error; + if ( + registrationEnvironment !== undefined && + !registrationEnvironment.ok + ) { + record({ + client: client.name, + config_path: configPath, + command: [], + state: "invalid", + remediation: registrationEnvironment.error.message, + }); + continue; + } const command: RegistrationCommand = [ registration.command, ...registration.args, diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index bcc6cc47ec..dc761fad84 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -36,6 +36,7 @@ import { type ClientRegistrationStatus, type UnhealthyClientRegistrationStatus, } from "./ClientRegistrationStatus.js"; +import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; import { collectDoctorDiagnostics, doctorHealthy, @@ -105,6 +106,8 @@ export interface DoctorHost { brewHopperPath(): Promise; manualHopperPaths(): Promise; 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; @@ -431,6 +434,7 @@ export interface SystemDoctorHostOptions { readonly providerRegistrationEnvironment?: () => Readonly< Record >; + readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver; readonly linuxDemoRuntimeCheck?: () => Promise; } @@ -547,6 +551,9 @@ export const systemDoctorHost = ( providerRegistrationEnvironment: options.providerRegistrationEnvironment, }), + ...(options.resolveProviderRequestBudget === undefined + ? {} + : { resolveProviderRequestBudget: options.resolveProviderRequestBudget }), async ilspyCmdVersion(path) { try { await access(path, constants.X_OK); @@ -599,6 +606,12 @@ export const systemDoctorHost = ( platform, environment, resources, + ...(options.resolveProviderRequestBudget === undefined + ? {} + : { + resolveProviderRequestBudget: + options.resolveProviderRequestBudget, + }), }), }; }; diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 139b93a490..9b68031af1 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -6,6 +6,7 @@ import { type ClientConfigurationSource, } from "./ClientConfigurationFiles.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; import { clientRegistrationEntry, clientConfigurationValuesEqual, @@ -26,6 +27,7 @@ import writeFileAtomic from "write-file-atomic"; import { clientRegistrationEnvironment, clientRegistrationEnvironmentDetails, + type ProviderRequestBudgetResolver, } from "./ClientRegistrationEnvironment.js"; import { PRODUCT_IDENTITY } from "../identity.js"; @@ -81,16 +83,23 @@ export const configureClientConfiguration = ( client: SetupClient, environment: SetupProviderEnvironment = {}, command: readonly string[] = npxRegistrationCommand(), + resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Promise => { if (client.configPathError !== undefined) return Promise.resolve({ status: "failed", reason: "path" }); - return configureClientDocument(client, environment, command); + return configureClientDocument( + client, + environment, + command, + resolveProviderRequestBudget, + ); }; const configureClientDocument = async ( client: SetupClient, environment: SetupProviderEnvironment, command: readonly string[], + resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Promise => { const format = client.format; if (format === undefined || format === "unsupported") @@ -118,6 +127,7 @@ const configureClientDocument = async ( command, existing, files.value, + resolveProviderRequestBudget, ), ); if (!policy.ok) { @@ -156,6 +166,7 @@ const configureClientDocument = async ( command, parsed, files.value, + resolveProviderRequestBudget, ), ); if (!policy.ok) { @@ -184,6 +195,7 @@ const configureClientDocument = async ( servers: { ...fallback.value, ...parsed.servers }, }, files.value, + resolveProviderRequestBudget, ); if (registrationCurrent(parsed, desired)) return { status: "unchanged" }; return persistClientConfiguration({ @@ -353,6 +365,7 @@ export const clientConfigurationAligned = async ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], + resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Promise => { if (client.configPathError !== undefined) return false; try { @@ -372,6 +385,7 @@ export const clientConfigurationAligned = async ( command, parsed, files.value, + resolveProviderRequestBudget, ), ); if (!policy.ok) { @@ -388,6 +402,7 @@ export const clientConfigurationAligned = async ( command, parsed, files.value, + resolveProviderRequestBudget, ), ) ); @@ -404,11 +419,13 @@ export const inspectClientConfiguration = async ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], + resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Promise => { const inspection = await inspectClientDocument( client, providerEnvironment, command, + resolveProviderRequestBudget, ); if (inspection.status !== "create" && inspection.status !== "update") return inspection; @@ -423,6 +440,7 @@ const inspectClientDocument = async ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], + resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Promise => { if (client.configPathError !== undefined) return { status: "invalid", remediation: client.configPathError }; @@ -456,6 +474,7 @@ const inspectClientDocument = async ( command, existing, files.value, + resolveProviderRequestBudget, ), ); if (!policy.ok) { @@ -505,6 +524,7 @@ const inspectClientDocument = async ( command, parsed, files.value, + resolveProviderRequestBudget, ), ); if (!policy.ok) { @@ -522,6 +542,7 @@ const inspectClientDocument = async ( command, parsed, files.value, + resolveProviderRequestBudget, ); if (registrationCurrent(parsed, desired)) return { status: "already_current" }; @@ -532,6 +553,8 @@ const inspectClientDocument = async ( } } catch (cause: unknown) { if (cause instanceof RegularFileCleanupFailure) throw cause; + if (cause instanceof ConfigurationError) + return { status: "invalid", remediation: cause.message }; // Malformed configuration is reported with the invalid remediation. void cause; return { @@ -660,6 +683,7 @@ const clientConfigurationDesired = ( command: readonly string[], parsed: ClientConfigurationDocument | undefined, sources: readonly ClientConfigurationSource[], + resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ) => { const effective = effectiveClientConfiguration(sources, { client, @@ -685,6 +709,10 @@ const clientConfigurationDesired = ( const validated = clientRegistrationEnvironmentDetails( effective, providerEnvironment, + "effective", + client.name === "codex" || client.name === "kimi" + ? resolveProviderRequestBudget + : undefined, ); if (!validated.ok) throw validated.error; // Preserve explicitly configured server settings, never the ambient process diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index fee6c18c8b..2ae65a0487 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -244,7 +244,10 @@ export const systemSetupHost = ( detectedClients: () => detectClients(homeDirectory, platform, environment), supportedClients: () => Promise.resolve(supportedClients(homeDirectory, platform, environment)), - ...clientConfigurationOperations(resources), + ...clientConfigurationOperations( + resources, + doctorHost.resolveProviderRequestBudget, + ), skillNeedsInstall: (clientIds) => canonicalSkillNeedsInstall( homeDirectory, @@ -288,7 +291,10 @@ const setupInstallerDiagnostic = ( : `${normalized.slice(0, 397).trimEnd()}...`; }; -const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({ +const clientConfigurationOperations = ( + resources: ArtifactResourceScope, + resolveProviderRequestBudget: DoctorHost["resolveProviderRequestBudget"], +) => ({ configureClient: ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, @@ -297,7 +303,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({ runClientConfigurationOperation(resources, async () => client.format === "unsupported" ? { status: "skipped" as const } - : configureClientConfiguration(client, providerEnvironment, command), + : configureClientConfiguration( + client, + providerEnvironment, + command, + resolveProviderRequestBudget, + ), ), clientNeedsConfigure: ( client: SetupClient, @@ -311,6 +322,7 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({ client, providerEnvironment, command, + resolveProviderRequestBudget, )), ), inspectClientConfiguration: ( @@ -319,7 +331,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({ command: readonly string[], ) => runClientConfigurationOperation(resources, () => - inspectClientConfiguration(client, providerEnvironment, command), + inspectClientConfiguration( + client, + providerEnvironment, + command, + resolveProviderRequestBudget, + ), ), }); diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index db6ab20f49..b6ab62d639 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -7,8 +7,10 @@ import { fileURLToPath } from "node:url"; import { inspectJadxProvider } from "./android/JadxDoctor.js"; import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js"; import { inspectIdaRegistration } from "./ida/IdaDoctor.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"; /** Compose provider diagnostics at the outer CLI adapter boundary. */ export const createSystemDoctorHost = ( @@ -24,6 +26,21 @@ export const createSystemDoctorHost = ( return systemDoctorHost({ environment, providerRegistrationEnvironment: () => selectedJebRegistrationEnvironment, + resolveProviderRequestBudget: ( + analysisProvider, + registrationEnvironment, + ) => { + if (analysisProvider !== "ida" && analysisProvider !== "auto") + return ok(undefined); + const path = registrationEnvironment.REA_IDA_MCP_CONFIG; + if (path === undefined) return ok(undefined); + const registration = readIdaConfiguration(path); + if (registration.ok) return ok(registration.value.timeoutMs); + // An invalid IDA registration cannot be selected automatically. Keep it + // out of recommendations for another available provider, but surface the + // parser-owned error when IDA itself was explicitly selected. + return analysisProvider === "ida" ? registration : ok(undefined); + }, providerInspections: async () => [ await inspectSystemGhidraProvider(environment), inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG), diff --git a/src/mcpStartupPolicy.ts b/src/mcpStartupPolicy.ts index 3f054b4484..96feb8a061 100644 --- a/src/mcpStartupPolicy.ts +++ b/src/mcpStartupPolicy.ts @@ -24,6 +24,7 @@ export interface McpToolTimeoutInputs { readonly ghidraStartupTimeoutMs: number; readonly hopperStartupTimeoutMs: number; readonly jebMcpTimeoutMs?: number; + readonly providerRequestTimeoutMs?: number; } /** Allow for known startup phases and caller-selected upstream request budgets. */ @@ -40,7 +41,11 @@ export const mcpProviderDeadlineAllowanceMs = ( inputs.ghidraStartupTimeoutMs, inputs.hopperStartupTimeoutMs, ); - return Math.max(selectedTimeout, inputs.jebMcpTimeoutMs ?? 0); + return Math.max( + selectedTimeout, + inputs.jebMcpTimeoutMs ?? 0, + inputs.providerRequestTimeoutMs ?? 0, + ); }; /** Add operation headroom to the parsed provider deadline allowance. */ diff --git a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts index b4a1169b93..e0597f1fff 100644 --- a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts +++ b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts @@ -322,6 +322,142 @@ cliTest( 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, + HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + }; + 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" },