diff --git a/CHANGELOG.md b/CHANGELOG.md index 3086c367d80..8fc2667dd01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Discord/OpenAI voice: accept leading fuzzy wake-name transcripts such as "Monty" or "Moti" for a Molty agent while keeping ambient speech gated. - Media understanding: convert HEIC and HEIF images to JPEG before image description providers run so iPhone photos work in direct and configured image-description flows. (#86037) - Discord/OpenAI voice: rotate Realtime sessions at provider max duration without logging the expected session-expiry event as an error. +- Agents/media: derive bundled plugin local-media trust from plugin tool metadata instead of importing the full plugin registry on subscription paths. (#84409) Thanks @samzong. - Memory/local embeddings: run local GGUF embeddings in an isolated worker sidecar and degrade to configured fallback or keyword search on worker failure so native embedding crashes do not take down the Gateway. (#85348) Thanks @osolmaz. - Gateway: clear the runtime config snapshot before `SIGUSR1` in-process restarts so config changes survive the next gateway loop. (#86388) Thanks @XuZehan-iCenter. - Models: show OAuth delegation markers as configured `models.json` auth while keeping runtime route usability checks strict. (#86378) Thanks @rohitjavvadi. diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ffb19b2a153..9c566f341ca 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -124,6 +124,7 @@ import { } from "../../pi-embedded-helpers.js"; import { countActiveToolExecutions } from "../../pi-embedded-subscribe.handlers.tools.js"; import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js"; +import { isCoreToolResultMediaTrustedName } from "../../pi-embedded-subscribe.tools.js"; import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js"; import { applyPiAutoCompactionGuard, @@ -448,6 +449,35 @@ export { resolveEmbeddedAgentStreamFn, }; +function collectTrustedPluginLocalMediaToolNames(params: { + tools: Array<{ name?: string }>; +}): Set { + const trusted = new Set(); + for (const tool of params.tools) { + const toolName = tool.name?.trim(); + if (!toolName) { + continue; + } + const meta = getPluginToolMeta(tool as Parameters[0]); + if (meta?.trustedLocalMedia === true) { + trusted.add(toolName); + } + } + return trusted; +} + +function collectTrustedLocalMediaToolNames(params: { + coreBuiltinToolNames: ReadonlySet; + trustedPluginToolNames: ReadonlySet; +}): Set { + return new Set([ + ...[...params.coreBuiltinToolNames].filter((toolName) => + isCoreToolResultMediaTrustedName(toolName), + ), + ...params.trustedPluginToolNames, + ]); +} + const MAX_BTW_SNAPSHOT_MESSAGES = 100; const TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES = [ TOOL_SEARCH_CODE_MODE_TOOL_NAME, @@ -1651,6 +1681,11 @@ export async function runEmbeddedAttempt( modelApi: params.model.api, model: params.model, }; + const pluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ + config: params.config, + env: process.env, + workspaceDir: effectiveWorkspace, + }); const tools = normalizeAgentRuntimeTools({ runtimePlan: params.runtimePlan, tools: toolsEnabled ? toolsRaw : [], @@ -1721,6 +1756,9 @@ export async function runEmbeddedAttempt( senderE164: params.senderE164, warn: (message) => log.warn(message), }); + const trustedPluginLocalMediaToolNames = collectTrustedPluginLocalMediaToolNames({ + tools: toolsEnabled ? [...toolsRaw, ...filteredBundledTools] : [], + }); const normalizedBundledTools = filteredBundledTools.length > 0 ? normalizeAgentRuntimeTools({ @@ -2202,11 +2240,7 @@ export async function runEmbeddedAttempt( cwd: effectiveWorkspace, agentDir, cfg: params.config, - pluginMetadataSnapshot: getCurrentPluginMetadataSnapshot({ - config: params.config, - env: process.env, - workspaceDir: effectiveWorkspace, - }), + pluginMetadataSnapshot, contextTokenBudget: params.contextTokenBudget, }); const piAutoCompactionGuardArgs = { @@ -2285,10 +2319,8 @@ export async function runEmbeddedAttempt( cfg: params.config, agentId: sessionAgentId, }); - // Exact raw names of every tool registered for this run, including - // bundled/plugin tools. Used as the raw-name set for the trusted local - // MEDIA: passthrough gate: a normalized alias is not sufficient — the - // emitted tool name must match an exact registration of this run. + // Exact raw names of every tool registered for this run. This remains + // available for diagnostics; local MEDIA: trust is narrower below. const builtinToolNames = new Set( uncompactedEffectiveTools.flatMap((tool) => { const name = (tool.name ?? "").trim(); @@ -2304,6 +2336,10 @@ export async function runEmbeddedAttempt( isPluginTool: (tool) => Boolean(getPluginToolMeta(tool as Parameters[0])), }); + const trustedLocalMediaToolNames = collectTrustedLocalMediaToolNames({ + coreBuiltinToolNames, + trustedPluginToolNames: trustedPluginLocalMediaToolNames, + }); const clientToolNameConflicts = findClientToolNameConflicts({ tools: clientTools ?? [], existingToolNames: [...coreBuiltinToolNames, ...PI_RESERVED_TOOL_NAMES], @@ -3303,6 +3339,7 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, agentId: sessionAgentId, builtinToolNames, + trustedLocalMediaToolNames, internalEvents: params.internalEvents, }), ); diff --git a/src/agents/pi-embedded-subscribe.e2e-harness.ts b/src/agents/pi-embedded-subscribe.e2e-harness.ts index 2359928de38..0b522f6754c 100644 --- a/src/agents/pi-embedded-subscribe.e2e-harness.ts +++ b/src/agents/pi-embedded-subscribe.e2e-harness.ts @@ -45,6 +45,8 @@ export function createSubscribedSessionHarness( const mergedSession = Object.assign(session, sessionExtras ?? {}); const subscription = subscribeEmbeddedPiSession({ ...subscribeParams, + trustedLocalMediaToolNames: + subscribeParams.trustedLocalMediaToolNames ?? subscribeParams.builtinToolNames, session: mergedSession, }); return { emit, session: mergedSession, subscription }; diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts index 4a621d06b41..bb64ab237f8 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts @@ -11,6 +11,7 @@ function createMockContext(overrides?: { onToolResult?: ReturnType; toolResultFormat?: "markdown" | "plain"; builtinToolNames?: ReadonlySet; + trustedLocalMediaToolNames?: ReadonlySet; }): EmbeddedPiSubscribeContext { const onToolResult = overrides?.onToolResult ?? vi.fn(); return { @@ -44,6 +45,8 @@ function createMockContext(overrides?: { }, log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, builtinToolNames: overrides?.builtinToolNames, + trustedLocalMediaToolNames: + overrides?.trustedLocalMediaToolNames ?? overrides?.builtinToolNames, shouldEmitToolResult: vi.fn(() => false), shouldEmitToolOutput: vi.fn(() => overrides?.shouldEmitToolOutput ?? false), emitToolSummary: vi.fn(), @@ -465,6 +468,37 @@ describe("handleToolExecutionEnd media emission", () => { expect(ctx.state.pendingToolMediaUrls).toStrictEqual([]); }); + it("does not queue trusted bundled plugin media already emitted in plain verbose output", async () => { + const ctx = createMockContext({ + shouldEmitToolOutput: true, + toolResultFormat: "plain", + trustedLocalMediaToolNames: new Set(["meeting_notes"]), + }); + + await handleToolExecutionEnd(ctx, { + type: "tool_execution_end", + toolName: "meeting_notes", + toolCallId: "tc-1", + isError: false, + result: { + content: [ + { + type: "text", + text: "Meeting audio attached.\nMEDIA:/tmp/meeting.wav", + }, + ], + details: { + media: { + mediaUrls: ["/tmp/meeting.wav"], + }, + }, + }, + }); + + expect(ctx.emitToolOutput).toHaveBeenCalledTimes(1); + expect(ctx.state.pendingToolMediaUrls).toStrictEqual([]); + }); + it("queues structured media once for markdown verbose output", async () => { const ctx = await handleVerboseGeneratedImage("markdown"); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 6a0e38ba90b..11c8a9a118c 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -537,13 +537,14 @@ async function collectEmittedToolOutputMediaUrls( toolName: string, outputText: string, result: unknown, + trustedLocalMediaToolNames?: ReadonlySet, ): Promise { const { splitMediaFromOutput } = await loadMediaParse(); const mediaUrls = splitMediaFromOutput(outputText).mediaUrls ?? []; if (mediaUrls.length === 0) { return []; } - return filterToolResultMediaUrls(toolName, mediaUrls, result); + return filterToolResultMediaUrls(toolName, mediaUrls, result, trustedLocalMediaToolNames); } function readExecApprovalPendingDetails(result: unknown): { @@ -712,7 +713,12 @@ async function emitToolResultOutput(params: { const outputText = extractToolResultText(sanitizedResult); const mediaReply = isToolError ? undefined : extractToolResultMediaArtifact(result); const mediaUrls = mediaReply - ? filterToolResultMediaUrls(rawToolName, mediaReply.mediaUrls, result, ctx.builtinToolNames) + ? filterToolResultMediaUrls( + rawToolName, + mediaReply.mediaUrls, + result, + ctx.trustedLocalMediaToolNames, + ) : []; const shouldEmitOutput = !shouldSuppressStructuredMediaToolOutput({ @@ -730,6 +736,7 @@ async function emitToolResultOutput(params: { rawToolName, outputText, result, + ctx.trustedLocalMediaToolNames, ); } } diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 44a69412b9c..7a5f6be2676 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -132,6 +132,7 @@ export type EmbeddedPiSubscribeContext = { blockChunker: EmbeddedBlockChunker | null; hookRunner?: HookRunner; builtinToolNames?: ReadonlySet; + trustedLocalMediaToolNames?: ReadonlySet; noteLastAssistant: (msg: AgentMessage) => void; shouldEmitToolResult: () => boolean; @@ -244,6 +245,7 @@ export type ToolHandlerContext = { log: EmbeddedSubscribeLogger; hookRunner?: HookRunner; builtinToolNames?: ReadonlySet; + trustedLocalMediaToolNames?: ReadonlySet; flushBlockReplyBuffer: () => void | Promise; shouldEmitToolResult: () => boolean; shouldEmitToolOutput: () => boolean; diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 43ebfb67ca1..5805cf6f797 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -53,6 +53,7 @@ describe("subscribeEmbeddedPiSession", () => { subscribeEmbeddedPiSession({ session, ...options, + trustedLocalMediaToolNames: options.trustedLocalMediaToolNames ?? options.builtinToolNames, }); return { emit }; } diff --git a/src/agents/pi-embedded-subscribe.tools.media.test.ts b/src/agents/pi-embedded-subscribe.tools.media.test.ts index 4b57f57991f..88688bc7498 100644 --- a/src/agents/pi-embedded-subscribe.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.media.test.ts @@ -340,8 +340,14 @@ describe("extractToolResultMediaPaths", () => { expect(isToolResultMediaTrusted("video_generate")).toBe(true); }); - it("trusts bundled plugin tool local MEDIA paths", () => { - expect(isToolResultMediaTrusted("music_generate")).toBe(true); + it("does not trust bundled plugin tool names without run-local metadata", () => { + expect(isToolResultMediaTrusted("meeting_notes")).toBe(false); + }); + + it("trusts bundled plugin tool names carried by run-local metadata", () => { + expect(isToolResultMediaTrusted("meeting_notes", undefined, new Set(["meeting_notes"]))).toBe( + true, + ); }); it("blocks trusted-media aliases that are not exact registered built-ins", () => { @@ -382,18 +388,15 @@ describe("extractToolResultMediaPaths", () => { ).toEqual(["/tmp/reply.opus"]); }); - it("keeps local media for bundled plugin tool names registered in this run", () => { - // music_generate is a bundled-plugin trusted tool; when the runner - // registers it for this run, its raw name must be allowed through the - // exact-name gate just like a core built-in. + it("keeps local media for bundled plugin tool names trusted in this run", () => { expect( filterToolResultMediaUrls( - "music_generate", - ["/tmp/song.mp3"], + "meeting_notes", + ["/tmp/meeting.wav"], undefined, - new Set(["music_generate"]), + new Set(["meeting_notes"]), ), - ).toEqual(["/tmp/song.mp3"]); + ).toEqual(["/tmp/meeting.wav"]); }); it("strips local media for plugin-name collisions when the plugin is not registered", () => { diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 1fdec99d5c4..45e3b3fd52a 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -2,7 +2,6 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index. import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js"; import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js"; import { splitMediaFromOutput } from "../media/parse.js"; -import { pluginRegistrationContractRegistry } from "../plugins/contracts/registry.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -277,8 +276,8 @@ export function extractToolResultText(result: unknown): string | undefined { return texts.join("\n"); } -// Core tool names that are allowed to emit local MEDIA: paths. -// Plugin/MCP tools are intentionally excluded to prevent untrusted file reads. +// Core tool names that are allowed to emit local MEDIA: paths. Plugin tools +// must be explicitly passed as trusted run-local names by the caller. const TRUSTED_TOOL_RESULT_MEDIA = new Set([ "agents_list", "apply_patch", @@ -310,11 +309,15 @@ const TRUSTED_TOOL_RESULT_MEDIA = new Set([ "x_search", "write", ]); -const TRUSTED_BUNDLED_PLUGIN_MEDIA_TOOLS = new Set( - pluginRegistrationContractRegistry.flatMap((entry) => entry.toolNames), -); const HTTP_URL_RE = /^https?:\/\//i; +export function isCoreToolResultMediaTrustedName(toolName?: string): boolean { + if (!toolName) { + return false; + } + return TRUSTED_TOOL_RESULT_MEDIA.has(normalizeToolName(toolName)); +} + function readToolResultDetails(result: unknown): Record | undefined { if (!result || typeof result !== "object") { return undefined; @@ -338,20 +341,29 @@ function isExternalToolResult(result: unknown): boolean { return typeof details.mcpServer === "string" || typeof details.mcpTool === "string"; } -export function isToolResultMediaTrusted(toolName?: string, result?: unknown): boolean { +export function isToolResultMediaTrusted( + toolName?: string, + result?: unknown, + trustedLocalMediaToolNames?: ReadonlySet, +): boolean { if (!toolName || isExternalToolResult(result)) { return false; } - const normalized = normalizeToolName(toolName); - return ( - TRUSTED_TOOL_RESULT_MEDIA.has(normalized) || TRUSTED_BUNDLED_PLUGIN_MEDIA_TOOLS.has(normalized) - ); + const registeredName = toolName.trim(); + if (registeredName && trustedLocalMediaToolNames?.has(registeredName) === true) { + return true; + } + return isCoreToolResultMediaTrustedName(toolName); } -function isTrustedOwnedTtsLocalMedia(toolName: string | undefined, result: unknown): boolean { +function isTrustedOwnedTtsLocalMedia( + toolName: string | undefined, + result: unknown, + trustedLocalMediaToolNames?: ReadonlySet, +): boolean { if ( !toolName || - !isToolResultMediaTrusted(toolName, result) || + !isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames) || normalizeToolName(toolName) !== "tts" ) { return false; @@ -367,25 +379,29 @@ export function filterToolResultMediaUrls( toolName: string | undefined, mediaUrls: string[], result?: unknown, - builtinToolNames?: ReadonlySet, + trustedLocalMediaToolNames?: ReadonlySet, ): string[] { if (mediaUrls.length === 0) { return mediaUrls; } - const trustedOwnedTtsLocalMedia = isTrustedOwnedTtsLocalMedia(toolName, result); - if (isToolResultMediaTrusted(toolName, result)) { - // When the current run provides its exact registered tool names (core - // built-ins plus bundled/trusted plugin tools), require the raw emitted - // tool name to match one of them before allowing local MEDIA: paths. + const trustedOwnedTtsLocalMedia = isTrustedOwnedTtsLocalMedia( + toolName, + result, + trustedLocalMediaToolNames, + ); + if (isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames)) { + // When the current run provides its exact trusted local-media tool names, + // require the raw emitted tool name to match one of them before allowing + // local MEDIA: paths. // This blocks normalized aliases and case-variant collisions such as // "Bash" -> "bash" or "Web_Search" -> "web_search" from inheriting a // registered tool's media trust. TTS-generated local files carry a // separate trusted-media flag from the owned tool result, so they can - // survive runs whose exact built-in set omitted the raw tts name. - if (builtinToolNames !== undefined) { + // survive runs whose exact trusted set omitted the raw tts name. + if (trustedLocalMediaToolNames !== undefined) { if (!trustedOwnedTtsLocalMedia) { const registeredName = toolName?.trim(); - if (!registeredName || !builtinToolNames.has(registeredName)) { + if (!registeredName || !trustedLocalMediaToolNames.has(registeredName)) { return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim())); } } diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 9a716512c8f..7f744b29450 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -565,7 +565,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar toolName, mediaUrls ?? [], result, - params.builtinToolNames, + params.trustedLocalMediaToolNames, ); if ( params.sourceReplyDeliveryMode === "message_tool_only" && @@ -1010,6 +1010,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar blockChunker, hookRunner: params.hookRunner, builtinToolNames: params.builtinToolNames, + trustedLocalMediaToolNames: params.trustedLocalMediaToolNames, noteLastAssistant, shouldEmitToolResult, shouldEmitToolOutput, diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index c0392f79542..b1cc67f350e 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -70,10 +70,14 @@ export type SubscribeEmbeddedPiSessionParams = { /** Agent identity for hook context — resolved from session config in attempt.ts. */ agentId?: string; /** - * Exact raw names of non-plugin OpenClaw tools registered for this run. - * When provided, MEDIA: passthrough requires an exact match instead of only - * a normalized-name collision with a trusted built-in. + * Exact raw names of OpenClaw tools registered for this run. */ builtinToolNames?: ReadonlySet; + /** + * Exact raw names allowed to emit local MEDIA: paths for this run. + * Includes core trusted tools plus bundled plugin tools proven from the + * startup metadata snapshot. + */ + trustedLocalMediaToolNames?: ReadonlySet; internalEvents?: AgentInternalEvent[]; }; diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 46a05958808..303458d4e1b 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -1364,8 +1364,11 @@ describe("resolvePluginTools optional tools", () => { expectResolvedToolNames(first, ["other_tool", "optional_tool"]); expectResolvedToolNames(second, ["other_tool", "optional_tool"]); expect(getPluginToolMeta(first[0])?.optional).toBe(false); + expect(getPluginToolMeta(first[0])?.trustedLocalMedia).toBe(true); expect(getPluginToolMeta(first[1])?.optional).toBe(true); + expect(getPluginToolMeta(first[1])?.trustedLocalMedia).toBe(true); expect(getPluginToolMeta(second[1])?.optional).toBe(true); + expect(getPluginToolMeta(second[1])?.trustedLocalMedia).toBe(true); expect(factory).toHaveBeenCalledTimes(1); }); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 2e414bfb491..76b6237f4bb 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -38,6 +38,7 @@ export { export type PluginToolMeta = { pluginId: string; optional: boolean; + trustedLocalMedia?: boolean; }; type PluginToolFactoryTimingResult = "array" | "error" | "null" | "single"; @@ -139,6 +140,16 @@ function isPluginToolOptional(params: { ); } +function isTrustedManifestLocalMediaTool(params: { + manifestPlugin: PluginManifestRecord | undefined; + toolName: string; +}): boolean { + return ( + params.manifestPlugin?.origin === "bundled" && + params.manifestPlugin.contracts?.tools?.includes(params.toolName) === true + ); +} + function isOptionalToolAllowed(params: { toolName: string; pluginId: string; @@ -530,6 +541,7 @@ function cachedDescriptorsCoverToolNames(params: { function createCachedDescriptorPluginTool(params: { descriptor: CachedPluginToolDescriptor; + plugin: PluginManifestRecord; ctx: OpenClawPluginToolContext; loadContext: ReturnType; runtimeOptions: PluginLoadOptions["runtimeOptions"]; @@ -601,6 +613,10 @@ function createCachedDescriptorPluginTool(params: { setPluginToolMeta(tool, { pluginId, optional: params.descriptor.optional, + trustedLocalMedia: isTrustedManifestLocalMediaTool({ + manifestPlugin: params.plugin, + toolName, + }), }); return tool; } @@ -728,6 +744,7 @@ function resolveCachedPluginTools(params: { pluginTools.push( createCachedDescriptorPluginTool({ descriptor: cachedDescriptor, + plugin, ctx: params.ctx, loadContext: params.loadContext, runtimeOptions: params.runtimeOptions, @@ -1195,6 +1212,10 @@ export function resolvePluginTools(params: { pluginToolMeta.set(tool, { pluginId: entry.pluginId, optional, + trustedLocalMedia: isTrustedManifestLocalMediaTool({ + manifestPlugin, + toolName: tool.name, + }), }); if (manifestPlugin) { const capturedDescriptors = capturedDescriptorsByPluginId.get(entry.pluginId) ?? [];