mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
fix(reverse): reconcile cleanup ownership and mutation diagnostics (#2799)
* feat(process): add owned debugger shutdown lifecycle * feat(reverse): add local inspection commands * feat(gdb): add persistent debugger integration * feat(cutter): add Cutter bridge integration * test(reverse): enforce complete integration coverage * fix(process): preserve debugger shutdown ownership * fix(reverse): retain evidence across interrupted commands * fix(gdb): honor captured provider environment * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * fix(server): snapshot provider environment at creation * fix(reverse): close review gaps with complete coverage * fix(reverse): address inspection review findings * fix(gdb): bound sessions and retain cleanup ownership * fix(ci): require reverse-tools quality lane * fix(process): unref inherited debugger output pipes * fix(reverse): address remaining PR 1960 findings * fix(gdb): address remaining PR 1961 findings * fix(reverse): account for unrestricted provider execution * fix(process): retain debugger ownership until safe shutdown * fix(process): preserve replacement debugger sessions * fix(reverse): preserve admission and output provenance * fix(process): retain exited debugger until output settles * fix(mcp): bound provider admission and debugger cleanup * fix(reverse): preserve failure provenance and classify E2BIG * fix(gdb): preserve startup cleanup and detach safety * fix(process): retain debugger ownership after detach failure * fix(reverse): preserve mutation and failure provenance * fix(gdb): address debugger review findings * fix(reverse): rebind sessions after external mutation * fix(process): cap debugger output after exit * fix(gdb): protect one-shot detach verification * feat(debugger): add reverse tool coverage gate * test(debugger): cover integrated failure paths * fix(gdb): bound and preserve session observations * fix(gdb): address review regressions * style: format merged integration files * fix(reverse): preserve session and output evidence * refactor(gdb): share repeated test and partial result setup * fix(gdb): invalidate TTY proof for Python aliases * fix(reverse): retain failure output byte counts * test(process): synchronize late debugger output * fix(gdb): invalidate TTY proof for wrapped commands * fix(gdb): recognize wit command abbreviation * test(reverse): wait for cancelled descendant readiness * test(reverse): bound descendant cancellation cleanup * style(reverse): format integration tests and adapters * style: format reverse engineering integration files * test(reverse): remove duplicate environment harness * test(gdb): share session startup assertions * fix(gdb): invalidate output proof for command abbreviations * test(reverse): share composition edge case scenarios * fix(ci): recognize verification and mutation tool entries * test(rizin): cover successful fake inferior detach * test(cli): align full output failure envelope * test(cli): isolate reverse command lifecycle service * test(process): share debugger output fixture setup * fix(binary): preserve high-bit Windows signature bytes * style(binary): format Windows signature parser * test(mcp): preserve call coverage in unchanged comparison * fix(ci): include reverse tools Vitest configs in lint cache * fix(mcp): preserve target content on delivery errors * ci: align duplication ceiling with expanded suite * test(reverse): allow signal admission mutants * fix(reverse): bound retained cleanup failures to process owners * fix(test): replace drifting mutation ranges with owned selectors * fix(reverse): admit persistent commands and retain cleanup ownership * fix(cutter): recover timed-out commands from exact receipts * fix(cutter): reject unsupported bridge request kinds * fix(ci): repair client format and shared verification fixtures * fix(native): preserve debugger cleanup ownership and bridge authentication * test(native): retain diagnostics without perfect coverage requirements * chore(licenses): refresh locked dependency inventory and notices * fix(test): exclude transient command locks from mutation sandboxes * fix(gdb): admit only platforms with safe detach verification * fix(cutter): retain bounded completion receipts until acknowledgement * fix(cutter): acknowledge commands rejected before UI dispatch * test(reverse): fail cancellation checks before fixture teardown * test(gdb): clean up the retained broken-stdin fixture owner * docs(test): clarify focused provider verification scope * fix(rizin): retain close cleanup failures * fix(gdb): wait for inferior stop and bound MI buffering * fix(cutter): retain failed receipt acknowledgements * fix(rizin): return retained session identity on rebind failure * fix(gdb): preserve startup cancellation and lossy status * fix(rizin): retain launch provenance and assemble frames once * fix(cutter): recover receipts across callers and bound discovery * docs: describe debugger evidence and bridge recovery * fix(process): keep cleanup incomplete while writers remain Stopping a debugger no longer reports success when inherited output streams stay open, so BinarySession cannot reopen a binary a child is still changing. Concurrent Cutter cleanup retries share one receipt acknowledgement instead of treating a consumed receipt as failure. * fix(debuggers): recover cleanup with captured process identities * fix(analysis): retire sessions after unrestricted commands * fix(cli): preserve completed evidence after cleanup recovery * fix(cutter): bound frame collection and coalesce cleanup * fix(windows): resolve selected provider paths absolutely * fix(gdb): report decoding loss in failed startup evidence * fix(rizin): scope decoding loss to collected command frames * test(mcp): cover snapshot retirement in reverse tools CI --------- Co-authored-by: Daniel Gonçalves Araujo <47118053+dandgabr@users.noreply.github.com> Co-authored-by: REA Maintainers <noreply@github.com> Co-authored-by: OpenAI Codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { z } from "incur";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { CutterBridgeClient } from "../cutter/CutterBridgeClient.js";
|
||||
import { CutterBridgeService } from "../cutter/CutterBridgeService.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
|
||||
/** Register commands that control an already-running Cutter instance. */
|
||||
export const registerCutterCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): void => {
|
||||
const cutter = new CutterBridgeService(new CutterBridgeClient(environment));
|
||||
|
||||
cli.command(CLI_COMMANDS.listCutterSessions, {
|
||||
description:
|
||||
"List live Cutter instances connected through the REA Python plugin bridge",
|
||||
args: z.object({}),
|
||||
run: () =>
|
||||
logCliCommand(logger, CLI_COMMANDS.listCutterSessions, () =>
|
||||
cutter.listSessions(),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.cutterCommand, {
|
||||
description: "Execute one Rizin command in a selected live Cutter instance",
|
||||
args: z.object({
|
||||
sessionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe("Session ID copied from list-cutter-sessions."),
|
||||
expectedGeneration: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe("Session generation returned by list-cutter-sessions."),
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("One unrestricted Rizin command to run in Cutter.")
|
||||
.refine((value) => !/[\r\n]/u.test(value)),
|
||||
}),
|
||||
options: z.object({
|
||||
json: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Return command-specific JSON output when available."),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.cutterCommand, async () => {
|
||||
const result = await cutter.execute(
|
||||
{
|
||||
session_id: args.sessionId,
|
||||
expected_generation: args.expectedGeneration,
|
||||
command: args.command,
|
||||
json: options.json,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
import { z } from "incur";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { evidenceSchema, type Evidence } from "../domain/evidence.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.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 {
|
||||
GdbSessionManager,
|
||||
gdbOneShotCommandMayInvalidateDetachProof,
|
||||
} from "../gdb/GdbSessionManager.js";
|
||||
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
|
||||
|
||||
const executeWithCleanup = async (
|
||||
service: ReverseEngineeringService,
|
||||
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 { ok: true, value: evidence };
|
||||
}
|
||||
return 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 },
|
||||
);
|
||||
|
||||
/** 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>,
|
||||
) => GdbSessionManager = (providerEnvironment) =>
|
||||
new GdbSessionManager({ environment: providerEnvironment }),
|
||||
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([
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
])
|
||||
.describe("GNU objdump inspection operation to run."),
|
||||
}),
|
||||
options: z.object({
|
||||
followDebugLinks: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Follow local DWARF debug links. External debug-file identity is not bound to Evidence.",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => {
|
||||
const result = await executeWithCleanup(service, () =>
|
||||
service.inspectWithObjdump(
|
||||
{
|
||||
...args,
|
||||
follow_debug_links: options.followDebugLinks,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
|
||||
description: "List BFD targets advertised by the installed GNU objdump",
|
||||
args: z.object({}),
|
||||
run: () =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(
|
||||
logger,
|
||||
CLI_COMMANDS.listObjdumpArchitectures,
|
||||
async () => {
|
||||
const result = await executeWithCleanup(service, () =>
|
||||
service.inspectWithObjdump(
|
||||
{ operation: "architectures" },
|
||||
{ signal },
|
||||
),
|
||||
);
|
||||
return result.ok
|
||||
? result.value
|
||||
: 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."),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
|
||||
const result = await executeWithCleanup(service, () =>
|
||||
service.executeRizinCommand(args, { 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.",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.debugWithGdb, async () => {
|
||||
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;
|
||||
try {
|
||||
const started = await manager.start(signal, {
|
||||
inferiorTty: options.inferiorTty,
|
||||
});
|
||||
if (!started.ok) return projectAnalysisError(started.error);
|
||||
sessionId = started.value.session_id;
|
||||
const executed = await manager.execute(
|
||||
sessionId,
|
||||
args.command,
|
||||
signal,
|
||||
);
|
||||
return executed.ok
|
||||
? executed.value.evidence
|
||||
: projectAnalysisError(executed.error);
|
||||
} finally {
|
||||
let cleanupFailed = false;
|
||||
if (sessionId !== undefined) {
|
||||
try {
|
||||
const closed = await manager.close(sessionId);
|
||||
cleanupFailed = !closed.ok;
|
||||
if (!closed.ok)
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithGdb,
|
||||
cleanup: projectAnalysisError(closed.error),
|
||||
},
|
||||
"GDB cleanup failed after collecting command output",
|
||||
);
|
||||
} catch (cause) {
|
||||
cleanupFailed = true;
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithGdb,
|
||||
cleanup:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown cleanup failure",
|
||||
},
|
||||
"GDB cleanup failed after collecting command output",
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await manager.closeAll();
|
||||
cleanupFailed = false;
|
||||
} catch (cause) {
|
||||
cleanupFailed = true;
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithGdb,
|
||||
cleanup:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown cleanup failure",
|
||||
},
|
||||
"GDB fallback cleanup failed",
|
||||
);
|
||||
}
|
||||
if (cleanupFailed) process.exitCode = 1;
|
||||
}
|
||||
}),
|
||||
),
|
||||
});
|
||||
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 = new RizinDebugSessionManager(environment);
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const started = await manager.start(
|
||||
{
|
||||
...args,
|
||||
inferior_tty: options.inferiorTty,
|
||||
...(options.backend === undefined
|
||||
? {}
|
||||
: { backend: options.backend }),
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!started.ok) return projectAnalysisError(started.error);
|
||||
sessionId = started.value.session_id;
|
||||
const executed = await manager.execute(
|
||||
started.value.session_id,
|
||||
args.command,
|
||||
signal,
|
||||
);
|
||||
return executed.ok
|
||||
? executed.value.evidence
|
||||
: projectAnalysisError(executed.error);
|
||||
} finally {
|
||||
const closed =
|
||||
sessionId === undefined
|
||||
? undefined
|
||||
: await manager.close(sessionId);
|
||||
if (closed !== undefined && !closed.ok) {
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithRizin,
|
||||
cleanup: projectAnalysisError(closed.error),
|
||||
},
|
||||
"Rizin cleanup failed after collecting command output",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
await manager.closeAll().catch((cause: unknown) => {
|
||||
process.exitCode = 1;
|
||||
logger.error(
|
||||
{
|
||||
command: CLI_COMMANDS.debugWithRizin,
|
||||
cleanup:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown cleanup failure",
|
||||
},
|
||||
"Rizin fallback cleanup failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user