From f8e8dc3e7a6b67a67b912ecfb6f0774572ae13ee Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 17:22:30 +0800 Subject: [PATCH] feat(cutter): name the plugin re-copy recovery for sessions whose plugin source is unknown or modified (#10950) * feat(cutter): name the plugin re-copy recovery for sessions whose plugin source is unknown or modified * test(cutter): assert recovery names the owner's platform plugin path --- docs/reverse-engineering-tools.md | 3 +++ src/cutter/CutterBridgeClient.ts | 8 ++++++-- src/cutter/CutterBridgeSource.ts | 15 +++++++++++++++ src/domain/cutterDiscovery.ts | 5 +++++ .../boundary/providers/CutterBridgeClient.test.ts | 6 ++++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 63a67ab3b8..34fff1f30f 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -275,6 +275,9 @@ REA's packaged `bridge/cutter/rea_bridge.py`, `modified` when it differs, and `unknown` when the plugin predates this field. `cutter_command` refuses a `modified` session before sending anything and names the packaged plugin to copy into Cutter's `plugins/python` directory. An `unknown` session still runs. +For `modified` and `unknown` sessions, discovery also returns +`bridge_source_recovery`, which names the packaged plugin to copy before +restarting Cutter. The client and plugin must support the current v2 handshake; older peers are rejected before dispatch because they cannot preserve cross-caller recovery proof. diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index d3e4beb5e1..eda0c4971b 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -44,7 +44,7 @@ import { err, ok, type Result } from "../domain/result.js"; import { cutterBridgeMac } from "./cutterBridgeMac.js"; import { cutterBridgeSource, - packagedCutterPluginPath, + cutterBridgeSourceRecovery, } from "./CutterBridgeSource.js"; import { CutterBridgeResponseFrames } from "./cutterAuthenticatedResponse.js"; import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js"; @@ -106,7 +106,7 @@ const responseSchema = z.object({ }); /** A session runs a plugin whose bytes differ from the packaged copy. */ const modifiedPluginRefusal = (): AnalysisCapabilityUnavailableError => { - const remediation = `Copy ${packagedCutterPluginPath()} into Cutter's plugins/python directory, restart Cutter, and list sessions again. No command was sent.`; + const remediation = `${cutterBridgeSourceRecovery("modified")} No command was sent.`; return new AnalysisCapabilityUnavailableError( "cutter", "cutter_command", @@ -411,6 +411,7 @@ export class CutterBridgeClient { descriptor.bridge_source_sha256, "list_cutter_sessions", ); + const bridgeSourceRecovery = cutterBridgeSourceRecovery(bridgeSource); try { const status = responseSchema.safeParse( await this.#request( @@ -443,6 +444,9 @@ export class CutterBridgeClient { status.data.cutter_version ?? descriptor.cutter_version, identity_status: "partial" as const, bridge_source: bridgeSource, + ...(bridgeSourceRecovery === undefined + ? {} + : { bridge_source_recovery: bridgeSourceRecovery }), }; } catch (cause) { if (cause instanceof AnalysisResourceConstraintError) throw cause; diff --git a/src/cutter/CutterBridgeSource.ts b/src/cutter/CutterBridgeSource.ts index 62c5c591a6..4c3ad650db 100644 --- a/src/cutter/CutterBridgeSource.ts +++ b/src/cutter/CutterBridgeSource.ts @@ -14,6 +14,21 @@ const PACKAGED_PLUGIN = new URL( export const packagedCutterPluginPath = (plugin = PACKAGED_PLUGIN): string => fileURLToPath(plugin); +/** + * What to do when a session does not run the packaged plugin. A plugin with + * no published digest still runs, but REA cannot tell which source it is. + */ +export const cutterBridgeSourceRecovery = ( + source: CutterBridgeSource, + plugin = PACKAGED_PLUGIN, +): string | undefined => { + if (source === "packaged") return undefined; + const recopy = `Copy ${packagedCutterPluginPath(plugin)} into Cutter's plugins/python directory, restart Cutter, and list sessions again.`; + return source === "unknown" + ? `The running plugin publishes no SHA-256, so its source is unknown. ${recopy}` + : recopy; +}; + const packagedDigests = new Map(); /** diff --git a/src/domain/cutterDiscovery.ts b/src/domain/cutterDiscovery.ts index 9ee40329d1..026b007e0f 100644 --- a/src/domain/cutterDiscovery.ts +++ b/src/domain/cutterDiscovery.ts @@ -20,6 +20,11 @@ export const cutterSessionSchema = z.strictObject({ cutter_version: z.string().nullable(), identity_status: z.literal("partial"), bridge_source: cutterBridgeSourceSchema, + /** + * How to run the packaged plugin, present when `bridge_source` is not + * `packaged`: the session's plugin is unknown or differs from this release. + */ + bridge_source_recovery: z.string().exactOptional(), }); /** Discovery facts retained independently of descriptor cleanup. */ diff --git a/tests/boundary/providers/CutterBridgeClient.test.ts b/tests/boundary/providers/CutterBridgeClient.test.ts index 6e7d3a4ae8..54c739f57a 100644 --- a/tests/boundary/providers/CutterBridgeClient.test.ts +++ b/tests/boundary/providers/CutterBridgeClient.test.ts @@ -23,6 +23,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CutterBridgeClient } from "../../../src/cutter/CutterBridgeClient.js"; import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js"; +import { packagedCutterPluginPath } from "../../../src/cutter/CutterBridgeSource.js"; const listen = async ( server: ReturnType, @@ -348,6 +349,11 @@ describe("CutterBridgeClient: plugin source identity", () => { }).listSessions(); expect(result.sessions).toMatchObject([{ bridge_source: source }]); + const recovery = result.sessions[0]?.bridge_source_recovery; + if (source === "packaged") expect(recovery).toBeUndefined(); + else + expect(recovery).toContain(`${packagedCutterPluginPath()} into Cutter`); + if (source === "unknown") expect(recovery).toContain("source is unknown"); }, );