From 12f6e0974cc0db0b3a9b86246accc76556837222 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:34:14 +0800 Subject: [PATCH] fix(cli): retain Cutter discovery through shared finalization --- src/cli/cutterCommands.ts | 52 +---- src/contracts/cutterToolContracts.ts | 28 +-- src/cutter/CutterBridgeClient.ts | 32 +-- src/cutter/CutterBridgeService.ts | 2 +- src/domain/analysisErrorBase.ts | 2 + src/domain/cutterDiscovery.ts | 38 ++++ .../providers/CutterBridgeCapacity.test.ts | 2 +- .../commandFinalizationCancellation.test.ts | 184 ++++++++++++++++++ 8 files changed, 240 insertions(+), 100 deletions(-) create mode 100644 src/domain/cutterDiscovery.ts diff --git a/src/cli/cutterCommands.ts b/src/cli/cutterCommands.ts index b500308847..68b818122e 100644 --- a/src/cli/cutterCommands.ts +++ b/src/cli/cutterCommands.ts @@ -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; - 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, + ); }), ), }); diff --git a/src/contracts/cutterToolContracts.ts b/src/contracts/cutterToolContracts.ts index 6d84d22dbe..fe22e9a95f 100644 --- a/src/contracts/cutterToolContracts.ts +++ b/src/contracts/cutterToolContracts.ts @@ -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 = [ { diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index 8f090d997d..f100bd7418 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -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, - "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; diff --git a/src/cutter/CutterBridgeService.ts b/src/cutter/CutterBridgeService.ts index f02e2a58d0..8df1e6f9a2 100644 --- a/src/cutter/CutterBridgeService.ts +++ b/src/cutter/CutterBridgeService.ts @@ -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"; diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index d754ae2008..bd94b43365 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -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 diff --git a/src/domain/cutterDiscovery.ts b/src/domain/cutterDiscovery.ts new file mode 100644 index 0000000000..272466de35 --- /dev/null +++ b/src/domain/cutterDiscovery.ts @@ -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; + +export type CutterBridgeDiscovery = Readonly< + Omit, "sessions"> +> & { + readonly sessions: readonly CutterBridgeSession[]; +}; diff --git a/tests/boundary/providers/CutterBridgeCapacity.test.ts b/tests/boundary/providers/CutterBridgeCapacity.test.ts index 135444d580..698198a252 100644 --- a/tests/boundary/providers/CutterBridgeCapacity.test.ts +++ b/tests/boundary/providers/CutterBridgeCapacity.test.ts @@ -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 => { diff --git a/tests/process-global/commandFinalizationCancellation.test.ts b/tests/process-global/commandFinalizationCancellation.test.ts index 2d9a180429..3a927cb201 100644 --- a/tests/process-global/commandFinalizationCancellation.test.ts +++ b/tests/process-global/commandFinalizationCancellation.test.ts @@ -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((resolve) => { + enter = resolve; + }); + const pending = new Promise((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 | 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((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 | 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",