diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 51483d5cb0..a00e945e8d 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -154,6 +154,6 @@ generation error. ## Configuration and coverage -The optional `REA_OBJDUMP_COMMAND`, `REA_GDB_COMMAND`, and `REA_RIZIN_COMMAND` values select executable names or paths; otherwise REA searches `PATH`. Values are passed directly to process spawning, not expanded as shell command lines. Provider versions and capabilities remain unknown until observed from the installed tool and verified in the corresponding real-provider lane. +The optional `REA_OBJDUMP_COMMAND`, `REA_GDB_COMMAND`, and `REA_RIZIN_COMMAND` values select executable names or paths; otherwise REA searches `PATH`. Values are passed directly to process spawning, not expanded as shell command lines. A host executable launch refusal preserves the selected command and host error code, with recovery through the corresponding command setting, PATH, or execute/search permissions. GDB does not retry that refusal with MI2; its MI2 fallback applies to protocol initialization failures. Provider versions and capabilities remain unknown until observed from the installed tool and verified in the corresponding real-provider lane. Provider capture limits are separate from the MCP response budget. A result that fits the provider limit may still exceed the default 10 MiB MCP response budget and return a transport `resource_constraint`. Configure `REA_MCP_MAX_RESPONSE_BYTES` to match a larger client receive buffer when that client supports one; otherwise use retained Evidence/export or complete CLI JSON output. See [MCP response limits](./mcp-contracts.md#tool-results). diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 166fe842cb..3939be949c 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -59,6 +59,23 @@ const copiedTimeoutGuidance = ( ? {} : { remediationAction: cause.remediationAction }), }); +/** A host executable refusal cannot be repaired by changing MI dialect. */ +class GdbExecutableRefusal extends AnalysisCapabilityUnavailableError { + constructor(command: string, systemCode: string, cause: Error) { + super("gdb", "start_gdb_session", cause.message, { + cause, + userMessage: `Could not launch the selected GDB executable ${JSON.stringify(command)} (${systemCode}).`, + remediationAction: + "Set REA_GDB_COMMAND to an installed executable GDB command, or make that command available on PATH. Check execute permission on the command and search permission on its parent directories before retrying.", + diagnostics: { + boundary: "provider-launch", + command, + system_code: systemCode, + }, + }); + } +} + const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024; const MAX_COMMAND_OUTPUT_RECORDS = 16_384; const MAX_MI_LINE_BYTES = 16 * 1024 * 1024; @@ -728,7 +745,10 @@ export class GdbSessionManager { signal, options.inferiorTty, ); - return mi3.ok || signal.aborted || this.#sessions.has(id) + return mi3.ok || + signal.aborted || + this.#sessions.has(id) || + mi3.error instanceof GdbExecutableRefusal ? mi3 : this.#startProtocol( id, @@ -768,11 +788,22 @@ export class GdbSessionManager { cause instanceof AnalysisCancelledError ) return err(new AnalysisCancelledError("start_gdb_session", { cause })); + const code: unknown = + cause instanceof Error && "code" in cause ? cause.code : undefined; + if ( + cause instanceof Error && + typeof code === "string" && + ["ENOENT", "EACCES", "EPERM", "ENOEXEC", "ENOTDIR", "EISDIR"].includes( + code, + ) + ) + return err(new GdbExecutableRefusal(this.#command, code, cause)); return err( new AnalysisCapabilityUnavailableError( "gdb", "start_gdb_session", cause instanceof Error ? cause.message : "Unable to start GDB", + { cause }, ), ); } diff --git a/tests/boundary/cli/gdbStartupCleanupCli.test.ts b/tests/boundary/cli/gdbStartupCleanupCli.test.ts index a241f91634..bbd4a10db7 100644 --- a/tests/boundary/cli/gdbStartupCleanupCli.test.ts +++ b/tests/boundary/cli/gdbStartupCleanupCli.test.ts @@ -6,7 +6,7 @@ import { err } from "../../../src/domain/result.js"; import { registerReverseEngineeringCommands } from "../../../src/cli/reverseEngineeringCommands.js"; import { CLI_COMMANDS } from "../../../src/cliCommandNames.js"; import type { CliInstance } from "../../../src/cli/types.js"; -import type { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; +import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; const registerGdbCommand = ( createManager: () => GdbSessionManager, @@ -130,3 +130,72 @@ it("marks the CLI failed when retained GDB cleanup remains incomplete", async () process.exitCode = originalExitCode; } }); + +it.each(["ENOENT", "EACCES", "EPERM"] as const)( + "keeps selected GDB launch recovery in the public CLI for %s", + async (code) => { + const selected = "/selected/gdb command"; + const launchFailure = Object.assign( + new Error(`spawn ${selected} ${code}`), + { + code, + }, + ); + const spawnProcess = vi.fn(async () => { + throw launchFailure; + }); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { REA_GDB_COMMAND: selected }, + spawnProcess, + }); + const command = registerGdbCommand(() => manager); + const previousExitCode = process.exitCode; + try { + const result = await command.run({ + args: { command: "show version" }, + options: { inferiorTty: "/dev/pts/42" }, + }); + expect(result).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining(JSON.stringify(selected)), + remediation: { action: expect.stringContaining("REA_GDB_COMMAND") }, + details: { + reason: launchFailure.message, + diagnostics: { + boundary: "provider-launch", + command: selected, + system_code: code, + }, + }, + }); + expect(spawnProcess).toHaveBeenCalledOnce(); + expect(spawnProcess).toHaveBeenCalledWith( + expect.objectContaining({ command: selected }), + ); + } finally { + await manager.closeAll(); + process.exitCode = previousExitCode; + } + }, +); + +it("keeps prelaunch caller cancellation distinct from executable refusal", async () => { + const controller = new AbortController(); + const reason = { caller: "stop before GDB launch" }; + const spawnProcess = vi.fn(async () => { + controller.abort(reason); + throw reason; + }); + const manager = new GdbSessionManager({ platform: "linux", spawnProcess }); + try { + const result = await manager.start(controller.signal); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cause: reason }, + }); + expect(spawnProcess).toHaveBeenCalledOnce(); + } finally { + await manager.closeAll(); + } +}); diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index afe3255042..861a89d2b9 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -25,6 +25,7 @@ import type { ProviderProcessHandle, SpawnedOwnedProviderProcess, } from "../../../src/process/ProviderProcess.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; import { parseLinuxProcessStat } from "../../../src/process/LinuxProcessStat.js"; import { expectGdbSessionStarted } from "./gdbTestHelpers.js"; @@ -1592,6 +1593,18 @@ it("rejects a pre-cancelled GDB start and an unconfigured executable", async () if (!missing.ok) { expect(missing.error._tag).toBe("AnalysisCapabilityUnavailableError"); expect(missing.error.message).toContain("does-not-exist-gdb"); + expect(projectAnalysisError(missing.error)).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining("does-not-exist-gdb"), + remediation: { action: expect.stringContaining("REA_GDB_COMMAND") }, + details: { + diagnostics: { + boundary: "provider-launch", + command: join(tmpdir(), "does-not-exist-gdb"), + system_code: "ENOENT", + }, + }, + }); } });