mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(cli): retain Cutter discovery through shared finalization
This commit is contained in:
1 parent
900f21b982
commit
12f6e0974c
8 files changed
+240
-100
No files matched your search
@@ -6,13 +6,6 @@ import { CutterBridgeService } from "../cutter/CutterBridgeService.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import type { CutterBridgeDiscovery } from "../cutter/CutterBridgeClient.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { cliIntegerArgument } from "./options.js";
|
||||
@@ -34,44 +27,13 @@ export const registerCutterCommands = (
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.listCutterSessions, async () => {
|
||||
const cutter = createCutter();
|
||||
let result: Result<CutterBridgeDiscovery, AnalysisError>;
|
||||
try {
|
||||
result = await cutter.listSessions({ signal });
|
||||
} catch (cause: unknown) {
|
||||
result = err(
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError("cutter", "list_cutter_sessions", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await cutter.close();
|
||||
} catch (cause: unknown) {
|
||||
const cleanup =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError("cutter", "list_cutter_sessions", {
|
||||
cause,
|
||||
});
|
||||
result = err(
|
||||
result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
cleanup,
|
||||
"list_cutter_sessions",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (result.ok && signal.aborted)
|
||||
result = err(
|
||||
new AnalysisCancelledError("list_cutter_sessions", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
return finishAnalysisCommand(
|
||||
() => cutter.listSessions({ signal }),
|
||||
cutter,
|
||||
"cutter",
|
||||
"list_cutter_sessions",
|
||||
signal,
|
||||
);
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { cutterDiscoverySchema } from "../domain/cutterDiscovery.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
@@ -21,15 +22,6 @@ export const cutterCommandInputSchema = z.strictObject({
|
||||
json: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const cutterSessionSchema = z.strictObject({
|
||||
session_id: z.string().uuid(),
|
||||
pid: z.int().positive(),
|
||||
document_generation: z.int().nonnegative(),
|
||||
current_file: z.string().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
export const cutterCommandResultSchema = z.strictObject({
|
||||
command: z.string(),
|
||||
output: jsonValueSchema.nullable(),
|
||||
@@ -43,24 +35,6 @@ export const cutterCommandResultSchema = z.strictObject({
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
export const cutterDiscoverySchema = z.strictObject({
|
||||
sessions: z.array(cutterSessionSchema),
|
||||
bridge_directory: z.string(),
|
||||
discovery_status: z.enum([
|
||||
"sessions_found",
|
||||
"no_live_bridge_found",
|
||||
"bridge_directory_unavailable",
|
||||
"bridge_directory_insecure",
|
||||
"bridge_directory_capacity_exceeded",
|
||||
]),
|
||||
bridge_directory_security: z.enum([
|
||||
"private_verified",
|
||||
"not_private",
|
||||
"unverified_platform_acl",
|
||||
"not_checked",
|
||||
]),
|
||||
});
|
||||
|
||||
/** Live Cutter discovery and arbitrary Rizin command contracts. */
|
||||
export const CUTTER_TOOL_CONTRACTS = [
|
||||
{
|
||||
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
cutterSessionSchema,
|
||||
type CutterBridgeDiscovery,
|
||||
type CutterBridgeSession,
|
||||
} from "../domain/cutterDiscovery.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { cutterBridgeMac } from "./cutterBridgeMac.js";
|
||||
import { CutterBridgeResponseFrames } from "./cutterAuthenticatedResponse.js";
|
||||
@@ -44,15 +49,10 @@ import {
|
||||
} from "../config/bridgeMaxRequestBytes.js";
|
||||
|
||||
const descriptorSchema = z.object({
|
||||
session_id: z.string().uuid(),
|
||||
pid: z.int().positive(),
|
||||
...cutterSessionSchema.shape,
|
||||
host: z.literal("127.0.0.1"),
|
||||
port: z.int().min(1).max(65535),
|
||||
token: z.string().min(20),
|
||||
document_generation: z.int().nonnegative(),
|
||||
current_file: z.string().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
max_request_bytes: z.int().positive().optional(),
|
||||
});
|
||||
|
||||
@@ -257,26 +257,6 @@ interface CutterBridgeClientOptions {
|
||||
readonly windowsPrivateReader?: WindowsCutterDescriptorReader;
|
||||
}
|
||||
|
||||
export type CutterBridgeSession = Omit<
|
||||
z.infer<typeof descriptorSchema>,
|
||||
"token" | "host" | "port" | "max_request_bytes"
|
||||
>;
|
||||
export interface CutterBridgeDiscovery {
|
||||
readonly sessions: readonly CutterBridgeSession[];
|
||||
readonly bridge_directory: string;
|
||||
readonly discovery_status:
|
||||
| "sessions_found"
|
||||
| "no_live_bridge_found"
|
||||
| "bridge_directory_unavailable"
|
||||
| "bridge_directory_insecure"
|
||||
| "bridge_directory_capacity_exceeded";
|
||||
readonly bridge_directory_security:
|
||||
| "private_verified"
|
||||
| "not_private"
|
||||
| "unverified_platform_acl"
|
||||
| "not_checked";
|
||||
}
|
||||
|
||||
/** Discovers and communicates with REA Python bridge plugins in live Cutter processes. */
|
||||
export class CutterBridgeClient {
|
||||
readonly #maxRequestBytes: BridgeMaxRequestBytes;
|
||||
|
||||
@@ -11,9 +11,9 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { cutterCommandInputSchema } from "../contracts/cutterToolContracts.js";
|
||||
import type { CutterBridgeDiscovery } from "../domain/cutterDiscovery.js";
|
||||
import {
|
||||
CutterBridgeClient,
|
||||
type CutterBridgeDiscovery,
|
||||
type CutterBridgeExecution,
|
||||
type CutterDiscoveryOptions,
|
||||
} from "./CutterBridgeClient.js";
|
||||
|
||||
@@ -279,9 +279,11 @@ import type { FileSnapshotPartialObservation } from "./fileSnapshotObservation.j
|
||||
import type { WebModuleResolutionObservation } from "./webModuleTrace.js";
|
||||
import type { WebNetworkCapture } from "./webNetworkCapture.js";
|
||||
import type { FridaDeviceList, FridaProcessList } from "./fridaDiscovery.js";
|
||||
import type { CutterBridgeDiscovery } from "./cutterDiscovery.js";
|
||||
|
||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||
export type AnalysisPartialObservation =
|
||||
| CutterBridgeDiscovery
|
||||
| FridaDeviceList
|
||||
| FridaProcessList
|
||||
| WebModuleResolutionObservation
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Observed Cutter session identity without bridge transport credentials. */
|
||||
export const cutterSessionSchema = z.strictObject({
|
||||
session_id: z.string().uuid(),
|
||||
pid: z.int().positive(),
|
||||
document_generation: z.int().nonnegative(),
|
||||
current_file: z.string().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
/** Discovery facts retained independently of descriptor cleanup. */
|
||||
export const cutterDiscoverySchema = z.strictObject({
|
||||
sessions: z.array(cutterSessionSchema),
|
||||
bridge_directory: z.string(),
|
||||
discovery_status: z.enum([
|
||||
"sessions_found",
|
||||
"no_live_bridge_found",
|
||||
"bridge_directory_unavailable",
|
||||
"bridge_directory_insecure",
|
||||
"bridge_directory_capacity_exceeded",
|
||||
]),
|
||||
bridge_directory_security: z.enum([
|
||||
"private_verified",
|
||||
"not_private",
|
||||
"unverified_platform_acl",
|
||||
"not_checked",
|
||||
]),
|
||||
});
|
||||
|
||||
export type CutterBridgeSession = z.infer<typeof cutterSessionSchema>;
|
||||
|
||||
export type CutterBridgeDiscovery = Readonly<
|
||||
Omit<z.infer<typeof cutterDiscoverySchema>, "sessions">
|
||||
> & {
|
||||
readonly sessions: readonly CutterBridgeSession[];
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { expect, it } from "vitest";
|
||||
|
||||
import { CutterBridgeClient } from "../../../src/cutter/CutterBridgeClient.js";
|
||||
import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js";
|
||||
import { cutterDiscoverySchema } from "../../../src/contracts/cutterToolContracts.js";
|
||||
import { cutterDiscoverySchema } from "../../../src/domain/cutterDiscovery.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const descriptor = async (directory: string, port: number): Promise<string> => {
|
||||
|
||||
@@ -7,8 +7,12 @@ import { afterEach, expect, it, vi } from "vitest";
|
||||
import * as evidenceInputResolver from "../../src/application/EvidenceInputResolver.js";
|
||||
import { WasmArtifactService } from "../../src/application/wasm/WasmArtifactService.js";
|
||||
import { registerAnalysisViewCommands } from "../../src/cli/analysisViewCommands.js";
|
||||
import { registerCutterCommands } from "../../src/cli/cutterCommands.js";
|
||||
import { registerWasmCommands } from "../../src/cli/wasmCommands.js";
|
||||
import { wasmArtifactModule } from "../../src/composition/wasm.js";
|
||||
import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { CutterBridgeService } from "../../src/cutter/CutterBridgeService.js";
|
||||
import { AnalysisOutputError } from "../../src/domain/analysisErrorCore.js";
|
||||
import * as analysisView from "../../src/domain/analysisView/analysisView.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
@@ -58,6 +62,186 @@ afterEach(() => {
|
||||
host.listeners.clear();
|
||||
});
|
||||
|
||||
const cutterDiscoveryFixture = () =>
|
||||
toolContract("list_cutter_sessions").outputSchema.parse({
|
||||
sessions: [
|
||||
{
|
||||
session_id: "00000000-0000-4000-8000-000000000001",
|
||||
pid: 41,
|
||||
document_generation: 7,
|
||||
current_file: "C:\\Caller files\\token=literal%2F.bin",
|
||||
cutter_version: "2.3.4",
|
||||
identity_status: "partial",
|
||||
},
|
||||
{
|
||||
session_id: "00000000-0000-4000-8000-000000000002",
|
||||
pid: 42,
|
||||
document_generation: 0,
|
||||
current_file: null,
|
||||
cutter_version: null,
|
||||
identity_status: "partial",
|
||||
},
|
||||
],
|
||||
bridge_directory: "/caller/Cutter bridge/token=literal%2F/证据",
|
||||
discovery_status: "sessions_found",
|
||||
bridge_directory_security: "unverified_platform_acl",
|
||||
});
|
||||
|
||||
const finalCloseGate = () => {
|
||||
let enter!: () => void;
|
||||
let release!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { enter, release, entered, pending };
|
||||
};
|
||||
|
||||
it.each([
|
||||
"control",
|
||||
"cleanup-failure",
|
||||
"interrupt",
|
||||
"interrupt-cleanup-failure",
|
||||
"queued-interrupt",
|
||||
] as const)(
|
||||
"retains registered Cutter discovery through final close: %s",
|
||||
async (scenario) => {
|
||||
const discoverySchema = toolContract("list_cutter_sessions").outputSchema;
|
||||
const discovery = cutterDiscoveryFixture();
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"cutter",
|
||||
[],
|
||||
{ reason: "controlled discovery close failure" },
|
||||
{ operation: "list_cutter_sessions" },
|
||||
);
|
||||
const failsCleanup =
|
||||
scenario === "cleanup-failure" ||
|
||||
scenario === "interrupt-cleanup-failure";
|
||||
const expectsInterrupt =
|
||||
scenario === "interrupt" ||
|
||||
scenario === "interrupt-cleanup-failure" ||
|
||||
scenario === "queued-interrupt";
|
||||
const gate = finalCloseGate();
|
||||
let closed = false;
|
||||
let interruptHandled = false;
|
||||
let queuedInterrupt: Promise<void> | undefined;
|
||||
const interrupt = () => {
|
||||
const listener = host.listeners.get("SIGINT");
|
||||
interruptHandled = listener !== undefined;
|
||||
listener?.();
|
||||
};
|
||||
const list = vi
|
||||
.spyOn(CutterBridgeService.prototype, "listSessions")
|
||||
.mockResolvedValue(ok(discovery));
|
||||
const close = vi
|
||||
.spyOn(CutterBridgeService.prototype, "close")
|
||||
.mockImplementation(async () => {
|
||||
gate.enter();
|
||||
await gate.pending;
|
||||
if (
|
||||
scenario === "interrupt" ||
|
||||
scenario === "interrupt-cleanup-failure"
|
||||
)
|
||||
interrupt();
|
||||
if (scenario === "queued-interrupt")
|
||||
queuedInterrupt = new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
interrupt();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
closed = true;
|
||||
if (failsCleanup) throw cleanup;
|
||||
});
|
||||
const cli = Cli.create("cutter-discovery-finalization-test", {
|
||||
sync: false,
|
||||
});
|
||||
registerCutterCommands(cli, silentLogger, {});
|
||||
let output = "";
|
||||
let settled = false;
|
||||
let pending: Promise<void> | undefined;
|
||||
const previousExitCode = process.exitCode;
|
||||
let exitCode: typeof process.exitCode;
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
pending = cli
|
||||
.serve(["list-cutter-sessions", "--json"], {
|
||||
stdout: (part) => {
|
||||
expect(closed).toBe(true);
|
||||
output += part;
|
||||
},
|
||||
exit: () => {},
|
||||
})
|
||||
.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.race([gate.entered, pending]);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(settled).toBe(false);
|
||||
expect(output).toBe("");
|
||||
gate.release();
|
||||
await pending;
|
||||
await queuedInterrupt;
|
||||
exitCode = process.exitCode;
|
||||
} finally {
|
||||
gate.release();
|
||||
try {
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
await queuedInterrupt;
|
||||
}
|
||||
} finally {
|
||||
process.exitCode = previousExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
expect(list).toHaveBeenCalledOnce();
|
||||
expect(list).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) });
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(closed).toBe(true);
|
||||
expect(interruptHandled).toBe(expectsInterrupt);
|
||||
expect(host.listeners.size).toBe(0);
|
||||
expect(exitCode).toBe(
|
||||
expectsInterrupt ? 130 : failsCleanup ? 1 : undefined,
|
||||
);
|
||||
const result: unknown = JSON.parse(output);
|
||||
if (scenario === "control") {
|
||||
expect(result).toEqual(discovery);
|
||||
return;
|
||||
}
|
||||
|
||||
const error = analysisErrorProjectionSchema.parse(result);
|
||||
expect(error.code).toBe(failsCleanup ? "cleanup_incomplete" : "cancelled");
|
||||
expect(error.details?.partial_observation).toEqual(discovery);
|
||||
expect(discoverySchema.parse(error.details?.partial_observation)).toEqual(
|
||||
discovery,
|
||||
);
|
||||
if (expectsInterrupt) expect(error.category).toBe("cancelled");
|
||||
if (failsCleanup) expect(error.details?.resources).toEqual([]);
|
||||
if (scenario === "cleanup-failure")
|
||||
expect(error.details?.diagnostics).toEqual({
|
||||
reason: "controlled discovery close failure",
|
||||
});
|
||||
if (scenario === "interrupt-cleanup-failure")
|
||||
expect(error.details?.diagnostics).toMatchObject({
|
||||
primary_error: {
|
||||
code: "cancelled",
|
||||
details: { operation: "list_cutter_sessions" },
|
||||
},
|
||||
cleanup_error: {
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
resources: [],
|
||||
diagnostics: { reason: "controlled discovery close failure" },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"control",
|
||||
"interrupt",
|
||||
|
||||
Reference in new issue
Block a user