Files
rea/src/cli/reverseEngineeringCommands.ts
T
Kaoru0822andClaude Opus 5.5 144843ccc2 fix(debugger): let REA exit when a debugger could not be stopped (#8652)
* fix(debugger): let REA exit when a debugger could not be stopped

`rea debug-with-gdb` and `rea debug-with-rizin` printed cleanup_incomplete
and set exit status 1, but the retained child and its pipes kept the event
loop alive, so the process never exited. A stdio MCP server whose client
disconnected hung the same way.

closeOwnedDebuggerSessions is the owner both managers finish through. It now
unreferences retained sessions, as supervised providers already do after
incomplete cleanup, restores the references on retry, and names the retained
process IDs. The one-shot commands report those IDs, since no later command
can retry. Debugger ownership, signals and detach behavior are unchanged.

GDB cleanup requests also no longer hold the host until their 120 s command
deadline, because cleanup already bounds its own wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(debugger): name retained debuggers when an MCP server shuts down

An MCP server that exits with a retained debugger logged only "REA provider
cleanup failed" and "could not close cleanly", so nothing named the process
left for the caller to check. The PIDs were on OwnedDebuggerCleanupError, but
each shutdown reporter read only the outer aggregate's message, and the debug
failure_cause description dropped the field too.

The failure type now lives in its own dependency-free module with one reader
that walks composed failures. The shutdown report, the server's onclose log,
the debug description and the one-shot CLI all use it, and the shutdown report
writes the same remediation the CLI returns.

The fake debuggers now model each request exactly, with real reply shapes, and
a request they do not model fails the test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(debugger): compare retained-debugger guidance with its formatter

The new checks froze the wording "Check process ... and its target".
docs/testing.md asks guidance checks to compare with the owning formatter,
alongside independent checks of the facts. They now do, and each still proves
the process ID is named, because a formatter comparison alone would pass if the
formatter dropped it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 05:16:34 -08:00

481 lines
17 KiB
TypeScript

import { z } from "incur";
import type { Logger } from "pino";
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { evidenceSchema, type Evidence } from "../domain/evidence.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Result } from "../domain/result.js";
import { err, ok } from "../domain/result.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
OBJDUMP_ARTIFACT_OPERATIONS,
reverseCommandTimeoutSchema,
} from "../domain/reverseEngineering.js";
import { logCliCommand } from "../cliLogging.js";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
import { gdbOneShotCommandMayInvalidateDetachProof } from "../gdb/GdbSessionManager.js";
import {
createGdbSessionManager,
createRizinDebugSessionManager,
} from "../composition/debuggerSessions.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
retainedDebuggerDiagnostics,
retainedDebuggerProcessIds,
retainedDebuggerRemediation,
} from "../process/ownedDebuggerCleanupError.js";
import {
analysisEvidenceDeliveryOptions,
cliEvidenceDeliveryInputError,
deliverCliAnalysisEvidence,
publishCliAnalysisEvidence,
} from "./analysisEvidenceDelivery.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
const executeWithCleanup = async (
service: ReverseEngineeringService,
signal: AbortSignal,
execute: () => Promise<Result<Evidence, AnalysisError>>,
): Promise<Result<Evidence, AnalysisError>> => {
let result: Result<Evidence, AnalysisError>;
try {
result = await execute();
} catch (cause) {
await service.close().catch(() => undefined);
throw cause;
}
const hadIncompleteCleanup = !result.ok && result.error.cleanupIncomplete;
try {
await service.close();
} catch (cause) {
if (result.ok) return err(asCleanupError(cause));
const cleanup = cause instanceof AnalysisError ? cause.cleanup : undefined;
const error = new AnalysisOutputError(
"reverse_engineering_cleanup",
"Provider cleanup remains incomplete after the command failed.",
{
cause: result.error,
...(cleanup === undefined ? {} : { cleanup }),
...(result.error.capturedOutput === undefined
? {}
: { capturedOutput: result.error.capturedOutput }),
...(result.error.partialObservation === undefined
? {}
: { partialObservation: result.error.partialObservation }),
},
);
return err(error);
}
if (!result.ok && hadIncompleteCleanup && !result.error.cleanupIncomplete) {
const evidence = cleanupOnlyCompleteEvidence(result.error);
if (evidence !== undefined)
return cancelledAfterCleanup({ ok: true, value: evidence }, signal);
}
return cancelledAfterCleanup(result, signal);
};
/** Late cancellation observed during finalization keeps the completed Evidence as partial data. */
const cancelledAfterCleanup = (
result: Result<Evidence, AnalysisError>,
signal: AbortSignal,
): Result<Evidence, AnalysisError> =>
result.ok && signal.aborted
? err(
new AnalysisCancelledError(result.value.operation, {
cause: signal.reason,
partialObservation: result.value,
}),
)
: result;
const cleanupOnlyCompleteEvidence = (
error: AnalysisError,
): Evidence | undefined => {
if (
!(error instanceof AnalysisOutputError) ||
!error.cleanupOnly ||
error.partialObservation === undefined
)
return undefined;
const parsed = evidenceSchema.safeParse(error.partialObservation);
if (!parsed.success) return undefined;
const normalized = parsed.data.normalized_result;
return typeof normalized === "object" &&
normalized !== null &&
!Array.isArray(normalized) &&
normalized.completion_status === "complete"
? parsed.data
: undefined;
};
const asCleanupError = (cause: unknown): AnalysisError =>
cause instanceof AnalysisError
? cause
: new AnalysisOutputError(
"reverse_engineering_cleanup",
"Provider process cleanup failed.",
{ cause },
);
/** One-shot debugger cleanup: the retained owner's closeAll decides whether cleanup remains incomplete. */
const closeDebuggerCommandSession = async (
manager: {
close(sessionId: string): Promise<Result<unknown, AnalysisError>>;
closeAll(): Promise<void>;
},
sessionId: string | undefined,
providerId: string,
operation: string,
log: (cleanup: JsonValue | string) => void,
): Promise<void> => {
if (sessionId !== undefined) {
try {
const closed = await manager.close(sessionId);
if (!closed.ok) log(projectAnalysisError(closed.error));
} catch (cause) {
log(cause instanceof Error ? cause.message : "Unknown cleanup failure");
}
}
try {
await manager.closeAll();
} catch (cause) {
// This process is the only owner, so a caller gets the debugger to check
// rather than a retry that no later command could perform.
const retainedPids = retainedDebuggerProcessIds(cause);
throw new ProviderCleanupError(
providerId,
sessionId === undefined ? [] : [sessionId],
{
reason:
cause instanceof AggregateError
? cause.errors
.map((error: unknown) =>
error instanceof Error ? error.message : String(error),
)
.join("; ")
: cause instanceof Error
? cause.message
: String(cause),
...retainedDebuggerDiagnostics(cause),
},
{
cause,
operation,
...(retainedPids.length === 0
? {}
: { remediationAction: retainedDebuggerRemediation(retainedPids) }),
},
);
}
};
/** Add one-shot reverse-engineering commands using the shared service layer. */
export const registerReverseEngineeringCommands = (
cli: CliInstance,
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
createGdbManager: (
environment: Readonly<NodeJS.ProcessEnv>,
) => ReturnType<typeof createGdbSessionManager> = createGdbSessionManager,
createService: typeof createReverseEngineeringService = createReverseEngineeringService,
): void => {
const service = createService(environment);
cli.command(CLI_COMMANDS.inspectWithObjdump, {
description:
"Inspect a local artifact with GNU objdump and retain its raw output as Evidence",
args: z.object({
path: z
.string()
.min(1)
.describe("Path to the local artifact to inspect."),
operation: z
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
.describe("GNU objdump inspection operation to run."),
}),
options: z.object({
...analysisEvidenceDeliveryOptions,
symbol: z
.string()
.optional()
.describe(
"Native symbol to select with the disassemble operation; GNU objdump controls matching and boundaries.",
),
followDebugLinks: z
.boolean()
.default(false)
.describe(
"Follow local DWARF debug links. External debug-file identity is not bound to Evidence.",
),
commandTimeoutMs: reverseCommandTimeoutSchema,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => {
const delivery = {
providerId: "gnu.objdump",
operation: "inspect_with_objdump",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined) return projectAnalysisError(inputError);
const result = await executeWithCleanup(service, signal, () =>
service.inspectWithObjdump(
{
...args,
follow_debug_links: options.followDebugLinks,
...(options.symbol === undefined
? {}
: { symbol: options.symbol }),
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
);
return result.ok
? publishCliAnalysisEvidence(
{ ok: true, value: result.value },
delivery,
)
: projectAnalysisError(result.error);
}),
),
});
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
description: "List BFD targets advertised by the installed GNU objdump",
args: z.object({}),
options: z.object({
...analysisEvidenceDeliveryOptions,
commandTimeoutMs: reverseCommandTimeoutSchema,
}),
run: ({ options }) =>
withCommandCancellation((signal) =>
logCliCommand(
logger,
CLI_COMMANDS.listObjdumpArchitectures,
async () => {
const delivery = {
providerId: "gnu.objdump",
operation: "inspect_with_objdump",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined)
return projectAnalysisError(inputError);
const result = await executeWithCleanup(service, signal, () =>
service.inspectWithObjdump(
{
operation: "architectures",
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
);
return result.ok
? publishCliAnalysisEvidence(
{ ok: true, value: result.value },
delivery,
)
: projectAnalysisError(result.error);
},
),
),
});
cli.command(CLI_COMMANDS.executeRizinCommand, {
description: "Execute one Rizin command against a local artifact",
args: z.object({
path: z
.string()
.min(1)
.describe("Path to the local artifact to analyze."),
command: z.string().min(1).describe("One caller-selected Rizin command."),
}),
options: z.object({
commandTimeoutMs: reverseCommandTimeoutSchema,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
const result = await executeWithCleanup(service, signal, () =>
service.executeRizinCommand(
{
...args,
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
),
});
cli.command(CLI_COMMANDS.debugWithGdb, {
description:
"Run one unrestricted GDB console command in an ephemeral MI session; provide an inferior TTY so a live target can be safely detached",
args: z.object({
command: z
.string()
.min(1)
.describe("One unrestricted GDB console command."),
}),
options: z.object({
inferiorTty: z
.string()
.min(1)
.describe(
"Caller-selected durable TTY for output from a live GDB inferior.",
),
...analysisEvidenceDeliveryOptions,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.debugWithGdb, async () => {
const delivery = {
providerId: "gdb",
operation: "gdb_console",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined) return projectAnalysisError(inputError);
if (gdbOneShotCommandMayInvalidateDetachProof(args.command))
return projectAnalysisError(
new AnalysisInputError("gdb_console", undefined, [
{
path: ["command"],
reason: "invalid_value",
message:
"This command can invalidate the verified inferior output route; use the persistent GDB session workflow.",
},
]),
);
const manager = createGdbManager(environment);
let sessionId: string | undefined;
return deliverCliAnalysisEvidence({
...delivery,
run: async () => {
const started = await manager.start(signal, {
inferiorTty: options.inferiorTty,
});
if (!started.ok) return err(started.error);
sessionId = started.value.session_id;
const executed = await manager.execute(
sessionId,
args.command,
signal,
);
return executed.ok
? ok(executed.value.evidence)
: err(executed.error);
},
service: {
close: () =>
closeDebuggerCommandSession(
manager,
sessionId,
"gdb",
"gdb_console",
(cleanup) =>
logger.error(
{ command: CLI_COMMANDS.debugWithGdb, cleanup },
"GDB cleanup failed after collecting command output",
),
),
},
});
}),
),
});
cli.command(CLI_COMMANDS.debugWithRizin, {
description:
"Run one unrestricted Rizin debugger command in an ephemeral session",
args: z.object({
path: z.string().min(1).describe("Path to the local artifact to debug."),
command: z
.string()
.min(1)
.describe("One unrestricted Rizin debugger command.")
.refine((value) => !/[\r\n]/u.test(value)),
}),
options: z.object({
inferiorTty: z
.string()
.min(1)
.describe("Caller-selected Linux TTY for inferior stdout and stderr."),
backend: z
.string()
.min(1)
.optional()
.describe("Optional Rizin IO debugger backend name."),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.debugWithRizin, async () => {
const manager = createRizinDebugSessionManager(environment);
let sessionId: string | undefined;
return finishAnalysisCommand(
async () => {
const started = await manager.start(
{
...args,
inferior_tty: options.inferiorTty,
...(options.backend === undefined
? {}
: { backend: options.backend }),
},
signal,
);
if (!started.ok) return err(started.error);
sessionId = started.value.session_id;
const executed = await manager.execute(
sessionId,
args.command,
signal,
);
return executed.ok
? ok(executed.value.evidence)
: err(executed.error);
},
{
close: () =>
closeDebuggerCommandSession(
manager,
sessionId,
"rizin",
"rizin_debug_command",
(cleanup) =>
logger.error(
{ command: CLI_COMMANDS.debugWithRizin, cleanup },
"Rizin cleanup failed after collecting command output",
),
),
},
"rizin",
"rizin_debug_command",
signal,
).then(projectCliAnalysisResult);
}),
),
});
};