diff --git a/CHANGELOG.md b/CHANGELOG.md index 108a7264867..324bc6ce349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. (#59100) Thanks @jadewon. - Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after `2026.3.31`. (#59092) Thanks @openperf. - WhatsApp/media: add HTML, XML, and CSS to the MIME map and fall back gracefully for unknown media types instead of dropping the attachment. (#51562) Thanks @bobbyt74. +- Plugins/runtime: keep LINE reply directives and browser-backed cleanup/reset flows working even when those plugins are disabled while tightening bundled plugin activation guards. (#59412) Thanks @vincentkoc. - WhatsApp/presence: send `unavailable` presence on connect in self-chat mode so personal-phone users stop losing all push notifications while the gateway is running. (#59410) Thanks @mcaxtr. - Providers/OpenAI-compatible routing: centralize native-vs-proxy request policy so hidden attribution and related OpenAI-family defaults only apply on verified native endpoints across stream, websocket, and shared audio HTTP paths. (#59433) Thanks @vincentkoc. - Exec approvals/doctor: report host policy sources from the real approvals file path and ignore malformed host override values when attributing effective policy conflicts. (#59367) Thanks @gumadeiras. diff --git a/scripts/lib/plugin-sdk-facades.mjs b/scripts/lib/plugin-sdk-facades.mjs index 7a1de370452..a2a2a28fc1b 100644 --- a/scripts/lib/plugin-sdk-facades.mjs +++ b/scripts/lib/plugin-sdk-facades.mjs @@ -49,6 +49,8 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "discord-runtime-surface", source: pluginSource("discord", "runtime-api.js"), + // Runtime entrypoints should be blocked until the owning plugin is active. + loadPolicy: "activated", exports: [ "addRoleDiscord", "auditDiscordChannelPermissions", @@ -159,6 +161,10 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "discord-thread-bindings", source: pluginSource("discord", "runtime-api.js"), + loadPolicy: "activated", + directExports: { + unbindThreadBindingsBySessionKey: "./discord-maintenance.js", + }, exports: [ "autoBindSpawnedDiscordSubagent", "createThreadBindingManager", @@ -199,6 +205,7 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "browser", source: pluginSource("browser", "runtime-api.js"), + loadPolicy: "activated", exports: [ "browserHandlers", "createBrowserPluginService", @@ -210,12 +217,15 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "browser-runtime", source: pluginSource("browser", "runtime-api.js"), + loadPolicy: "activated", directExports: { DEFAULT_AI_SNAPSHOT_MAX_CHARS: "./browser-config.js", DEFAULT_BROWSER_EVALUATE_ENABLED: "./browser-config.js", DEFAULT_OPENCLAW_BROWSER_COLOR: "./browser-config.js", DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME: "./browser-config.js", DEFAULT_UPLOAD_DIR: "./browser-config.js", + closeTrackedBrowserTabsForSessions: "./browser-maintenance.js", + movePathToTrash: "./browser-maintenance.js", redactCdpUrl: "./browser-config.js", resolveBrowserConfig: "./browser-config.js", resolveBrowserControlAuth: "./browser-config.js", @@ -441,6 +451,7 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "image-generation-runtime", source: pluginSource("image-generation-core", "runtime-api.js"), + loadPolicy: "activated", exports: [ "generateImage", "listRuntimeImageGenerationProviders", @@ -487,6 +498,7 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "media-understanding-runtime", source: pluginSource("media-understanding-core", "runtime-api.js"), + loadPolicy: "activated", exports: [ "describeImageFile", "describeImageFileWithModel", @@ -501,6 +513,7 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "memory-core-engine-runtime", source: pluginSource("memory-core", "runtime-api.js"), + loadPolicy: "activated", exports: [ "BuiltinMemoryEmbeddingProviderDoctorMetadata", "getBuiltinMemoryEmbeddingProviderDoctorMetadata", @@ -530,6 +543,7 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "line-runtime", source: pluginSource("line", "runtime-api.js"), + loadPolicy: "activated", runtimeApiPreExportsPath: runtimeApiSourcePath("line"), typeExports: [ "Action", @@ -558,6 +572,8 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "line-surface", source: pluginSource("line", "runtime-api.js"), + // This surface is also used by passive reply normalization helpers. + // Keep it loadable without requiring the LINE plugin to be activated. exports: [ "CardAction", "createActionCard", @@ -611,6 +627,7 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "matrix-runtime-surface", source: pluginSource("matrix", "runtime-api.js"), + loadPolicy: "activated", exports: ["resolveMatrixAccountStringValues", "setMatrixRuntime"], }, { @@ -850,6 +867,7 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "speech-runtime", source: pluginSource("speech-core", "runtime-api.js"), + loadPolicy: "activated", exports: [ "_test", "buildTtsSystemPromptHint", @@ -931,6 +949,7 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ { subpath: "slack-runtime-surface", source: pluginSource("slack", "runtime-api.js"), + loadPolicy: "activated", exports: [ "handleSlackAction", "listSlackDirectoryGroupsLive", @@ -1217,6 +1236,16 @@ export const GENERATED_PLUGIN_SDK_FACADES_BY_SUBPATH = Object.fromEntries( GENERATED_PLUGIN_SDK_FACADES.map((entry) => [entry.subpath, entry]), ); +function resolveFacadeLoadPolicy(entry, sourcePath) { + // Keep loader policy next to the facade entry itself so additions stay local + // and mixed-source facades can opt into per-source behavior later if needed. + const sourcePolicy = entry.sourceLoadPolicy?.[sourcePath]; + if (sourcePolicy) { + return sourcePolicy; + } + return entry.loadPolicy ?? "plain"; +} + export const GENERATED_PLUGIN_SDK_FACADES_LABEL = "plugin-sdk-facades"; export const GENERATED_PLUGIN_SDK_FACADES_SCRIPT = "scripts/generate-plugin-sdk-facades.mjs"; export const GENERATED_PLUGIN_SDK_FACADE_TYPES_OUTPUT = @@ -1515,25 +1544,40 @@ export function buildPluginSdkFacadeModule(entry, params = {}) { } } if (valueExports.length) { - const runtimeImports = ["loadBundledPluginPublicSurfaceModuleSync"]; + const runtimeImports = new Set(); if (needsLazyArrayHelper) { - runtimeImports.unshift("createLazyFacadeArrayValue"); + runtimeImports.add("createLazyFacadeArrayValue"); } if (needsLazyObjectHelper) { - runtimeImports.unshift("createLazyFacadeObjectValue"); + runtimeImports.add("createLazyFacadeObjectValue"); } - lines.push(`import { ${runtimeImports.join(", ")} } from "./facade-runtime.js";`); + for (const sourcePath of listFacadeEntrySourcePaths(entry)) { + const loadPolicy = resolveFacadeLoadPolicy(entry, sourcePath); + runtimeImports.add( + loadPolicy === "activated" + ? "loadActivatedBundledPluginPublicSurfaceModuleSync" + : "loadBundledPluginPublicSurfaceModuleSync", + ); + } + lines.push( + `import { ${[...runtimeImports].toSorted((left, right) => left.localeCompare(right)).join(", ")} } from "./facade-runtime.js";`, + ); for (const [sourceIndex, sourcePath] of listFacadeEntrySourcePaths(entry).entries()) { if (!valueExportsBySource.has(sourcePath)) { continue; } const { dirName: sourceDirName, artifactBasename: sourceArtifactBasename } = normalizeFacadeSourceParts(sourcePath); + const loadPolicy = resolveFacadeLoadPolicy(entry, sourcePath); + const loaderName = + loadPolicy === "activated" + ? "loadActivatedBundledPluginPublicSurfaceModuleSync" + : "loadBundledPluginPublicSurfaceModuleSync"; const loaderSuffix = sourceIndex === 0 ? "" : String(sourceIndex + 1); const moduleTypeName = sourceIndex === 0 ? "FacadeModule" : `FacadeModule${sourceIndex + 1}`; lines.push(""); lines.push(`function loadFacadeModule${loaderSuffix}(): ${moduleTypeName} {`); - lines.push(` return loadBundledPluginPublicSurfaceModuleSync<${moduleTypeName}>({`); + lines.push(` return ${loaderName}<${moduleTypeName}>({`); lines.push(` dirName: ${JSON.stringify(sourceDirName)},`); lines.push(` artifactBasename: ${JSON.stringify(sourceArtifactBasename)},`); lines.push(" });"); diff --git a/src/channels/plugins/session-conversation.test.ts b/src/channels/plugins/session-conversation.test.ts index 6693b36a4e1..ea0ed5ae688 100644 --- a/src/channels/plugins/session-conversation.test.ts +++ b/src/channels/plugins/session-conversation.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; @@ -14,6 +15,10 @@ describe("session conversation routing", () => { setActivePluginRegistry(createSessionConversationTestRegistry()); }); + afterEach(() => { + clearRuntimeConfigSnapshot(); + }); + it("keeps generic :thread: parsing in core", () => { expect( resolveSessionConversationRef("agent:main:slack:channel:general:thread:1699999999.0001"), @@ -51,6 +56,13 @@ describe("session conversation routing", () => { it("keeps bundled Telegram topic parsing available before registry bootstrap", () => { resetPluginRuntimeStateForTest(); + setRuntimeConfigSnapshot({ + channels: { + telegram: { + enabled: true, + }, + }, + }); expect(resolveSessionConversationRef("agent:main:telegram:group:-100123:topic:77")).toEqual({ channel: "telegram", @@ -66,6 +78,15 @@ describe("session conversation routing", () => { it("keeps bundled Feishu parent fallbacks available before registry bootstrap", () => { resetPluginRuntimeStateForTest(); + setRuntimeConfigSnapshot({ + plugins: { + entries: { + feishu: { + enabled: true, + }, + }, + }, + }); expect( resolveSessionConversationRef( @@ -84,6 +105,21 @@ describe("session conversation routing", () => { }); }); + it("does not load bundled session-key fallbacks for inactive channel plugins", () => { + resetPluginRuntimeStateForTest(); + + expect(resolveSessionConversationRef("agent:main:telegram:group:-100123:topic:77")).toEqual({ + channel: "telegram", + kind: "group", + rawId: "-100123:topic:77", + id: "-100123:topic:77", + threadId: undefined, + baseSessionKey: "agent:main:telegram:group:-100123:topic:77", + baseConversationId: "-100123:topic:77", + parentConversationCandidates: [], + }); + }); + it("lets Feishu own parent fallback candidates", () => { expect( resolveSessionConversationRef( diff --git a/src/channels/plugins/session-conversation.ts b/src/channels/plugins/session-conversation.ts index d5034ba5bb8..2d283ed3d06 100644 --- a/src/channels/plugins/session-conversation.ts +++ b/src/channels/plugins/session-conversation.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from "node:url"; -import { loadBundledPluginPublicSurfaceModuleSync } from "../../plugin-sdk/facade-runtime.js"; +import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "../../plugin-sdk/facade-runtime.js"; import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js"; import { resolveBundledPluginPublicSurfacePath } from "../../plugins/bundled-plugin-metadata.js"; import { @@ -150,12 +150,11 @@ function resolveBundledSessionConversationFallback(params: { ) { return null; } - const resolveSessionConversation = - loadBundledPluginPublicSurfaceModuleSync({ + tryLoadActivatedBundledPluginPublicSurfaceModuleSync({ dirName, artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME, - }).resolveSessionConversation; + })?.resolveSessionConversation; if (typeof resolveSessionConversation !== "function") { return null; } diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index d62d1d4a9cf..fb597ce38ff 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -71,7 +71,7 @@ vi.mock("../../config/sessions/paths.js", () => ({ resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent, })); -vi.mock("../../browser/trash.js", () => ({ +vi.mock("../../plugin-sdk/browser-runtime.js", () => ({ movePathToTrash: mocks.movePathToTrash, })); diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index e21a1d03da4..6e75ea3dc3e 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -34,6 +34,7 @@ describe("plugin activation boundary", () => { DEFAULT_OPENCLAW_BROWSER_COLOR: typeof import("./plugin-sdk/browser-runtime.js").DEFAULT_OPENCLAW_BROWSER_COLOR; DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME: typeof import("./plugin-sdk/browser-runtime.js").DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME; DEFAULT_UPLOAD_DIR: typeof import("./plugin-sdk/browser-runtime.js").DEFAULT_UPLOAD_DIR; + closeTrackedBrowserTabsForSessions: typeof import("./plugin-sdk/browser-runtime.js").closeTrackedBrowserTabsForSessions; redactCdpUrl: typeof import("./plugin-sdk/browser-runtime.js").redactCdpUrl; resolveBrowserConfig: typeof import("./plugin-sdk/browser-runtime.js").resolveBrowserConfig; resolveBrowserControlAuth: typeof import("./plugin-sdk/browser-runtime.js").resolveBrowserControlAuth; @@ -41,6 +42,11 @@ describe("plugin activation boundary", () => { }> | undefined; let browserAmbientImportsPromise: Promise | undefined; + let discordMaintenancePromise: + | Promise<{ + unbindThreadBindingsBySessionKey: typeof import("./plugin-sdk/discord-thread-bindings.js").unbindThreadBindingsBySessionKey; + }> + | undefined; function importAmbientModules() { ambientImportsPromise ??= Promise.all([ @@ -77,6 +83,7 @@ describe("plugin activation boundary", () => { DEFAULT_OPENCLAW_BROWSER_COLOR: module.DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME: module.DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, DEFAULT_UPLOAD_DIR: module.DEFAULT_UPLOAD_DIR, + closeTrackedBrowserTabsForSessions: module.closeTrackedBrowserTabsForSessions, redactCdpUrl: module.redactCdpUrl, resolveBrowserConfig: module.resolveBrowserConfig, resolveBrowserControlAuth: module.resolveBrowserControlAuth, @@ -96,6 +103,15 @@ describe("plugin activation boundary", () => { return browserAmbientImportsPromise; } + function importDiscordMaintenance() { + discordMaintenancePromise ??= import("./plugin-sdk/discord-thread-bindings.js").then( + (module) => ({ + unbindThreadBindingsBySessionKey: module.unbindThreadBindingsBySessionKey, + }), + ); + return discordMaintenancePromise; + } + it("does not load bundled provider plugins on ambient command imports", async () => { await importAmbientModules(); @@ -143,9 +159,30 @@ describe("plugin activation boundary", () => { cdpHost: "127.0.0.1", }), ); - expect(browser.redactCdpUrl("wss://user:secret@example.com/devtools/browser/123")).not.toContain( - "secret", - ); + expect( + browser.redactCdpUrl("wss://user:secret@example.com/devtools/browser/123"), + ).not.toContain("secret"); + expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); + }); + + it("keeps browser cleanup helpers cold when browser is disabled", async () => { + const browser = await importBrowserHelpers(); + + await expect(browser.closeTrackedBrowserTabsForSessions({ sessionKeys: [] })).resolves.toBe(0); + expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); + }); + + it("keeps discord cleanup helpers cold when discord is disabled", async () => { + const discord = await importDiscordMaintenance(); + + expect( + discord.unbindThreadBindingsBySessionKey({ + targetSessionKey: "agent:main:test", + targetKind: "acp", + reason: "session-reset", + sendFarewell: true, + }), + ).toEqual([]); expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); }); diff --git a/src/plugin-sdk/browser-maintenance.test.ts b/src/plugin-sdk/browser-maintenance.test.ts new file mode 100644 index 00000000000..4fab072b8b6 --- /dev/null +++ b/src/plugin-sdk/browser-maintenance.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runCommandWithTimeout = vi.hoisted(() => vi.fn()); +const mkdir = vi.hoisted(() => vi.fn()); +const access = vi.hoisted(() => vi.fn()); +const rename = vi.hoisted(() => vi.fn()); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout, +})); + +vi.mock("node:fs/promises", () => { + const mocked = { mkdir, access, rename }; + return { ...mocked, default: mocked }; +}); + +vi.mock("node:os", () => ({ + default: { + homedir: () => "/home/test", + }, + homedir: () => "/home/test", +})); + +describe("browser maintenance", () => { + beforeEach(() => { + vi.restoreAllMocks(); + runCommandWithTimeout.mockReset(); + mkdir.mockReset(); + access.mockReset(); + rename.mockReset(); + vi.spyOn(Date, "now").mockReturnValue(123); + }); + + it("returns the target path when trash exits successfully", async () => { + const { movePathToTrash } = await import("./browser-maintenance.js"); + runCommandWithTimeout.mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }); + + await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo"); + expect(mkdir).not.toHaveBeenCalled(); + expect(rename).not.toHaveBeenCalled(); + }); + + it("falls back to rename when trash exits non-zero", async () => { + const { movePathToTrash } = await import("./browser-maintenance.js"); + runCommandWithTimeout.mockResolvedValue({ + stdout: "", + stderr: "permission denied", + code: 1, + signal: null, + killed: false, + termination: "exit", + }); + access.mockRejectedValue(new Error("missing")); + + await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/home/test/.Trash/demo-123"); + expect(mkdir).toHaveBeenCalledWith("/home/test/.Trash", { recursive: true }); + expect(rename).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123"); + }); +}); diff --git a/src/plugin-sdk/browser-maintenance.ts b/src/plugin-sdk/browser-maintenance.ts new file mode 100644 index 00000000000..4fcb07873d5 --- /dev/null +++ b/src/plugin-sdk/browser-maintenance.ts @@ -0,0 +1,54 @@ +import { randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +type BrowserRuntimeModule = PluginSdkFacadeTypeMap["browser-runtime"]["module"]; + +function createTrashCollisionSuffix(): string { + return randomBytes(6).toString("hex"); +} + +export const closeTrackedBrowserTabsForSessions: BrowserRuntimeModule["closeTrackedBrowserTabsForSessions"] = + (async (...args) => { + // Session reset always attempts browser cleanup, even when browser is disabled. + // Keep that path a no-op unless the browser runtime is actually active. + const closeTrackedTabs = tryLoadActivatedBundledPluginPublicSurfaceModuleSync< + Pick + >({ + dirName: "browser", + artifactBasename: "runtime-api.js", + })?.closeTrackedBrowserTabsForSessions; + if (typeof closeTrackedTabs !== "function") { + return 0; + } + return await closeTrackedTabs(...args); + }) as BrowserRuntimeModule["closeTrackedBrowserTabsForSessions"]; + +export const movePathToTrash: BrowserRuntimeModule["movePathToTrash"] = (async (...args) => { + const [targetPath] = args; + try { + const result = await runCommandWithTimeout(["trash", targetPath], { timeoutMs: 10_000 }); + if (result.code !== 0) { + throw new Error(`trash exited with code ${result.code ?? "unknown"}`); + } + return targetPath; + } catch { + const trashDir = path.join(os.homedir(), ".Trash"); + await fs.mkdir(trashDir, { recursive: true }); + const base = path.basename(targetPath); + const timestamp = Date.now(); + let destination = path.join(trashDir, `${base}-${timestamp}`); + try { + await fs.access(destination); + destination = path.join(trashDir, `${base}-${timestamp}-${createTrashCollisionSuffix()}`); + } catch { + // The initial destination is free to use. + } + await fs.rename(targetPath, destination); + return destination; + } +}) as BrowserRuntimeModule["movePathToTrash"]; diff --git a/src/plugin-sdk/browser-runtime.ts b/src/plugin-sdk/browser-runtime.ts index b594c3529c0..25a475bd17e 100644 --- a/src/plugin-sdk/browser-runtime.ts +++ b/src/plugin-sdk/browser-runtime.ts @@ -13,13 +13,14 @@ export { resolveBrowserControlAuth, resolveProfile, } from "./browser-config.js"; +export { closeTrackedBrowserTabsForSessions, movePathToTrash } from "./browser-maintenance.js"; import { createLazyFacadeObjectValue, - loadBundledPluginPublicSurfaceModuleSync, + loadActivatedBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "browser", artifactBasename: "runtime-api.js", }); @@ -71,11 +72,6 @@ export const browserTabAction: FacadeModule["browserTabAction"] = ((...args) => loadFacadeModule()["browserTabAction"](...args)) as FacadeModule["browserTabAction"]; export const browserTabs: FacadeModule["browserTabs"] = ((...args) => loadFacadeModule()["browserTabs"](...args)) as FacadeModule["browserTabs"]; -export const closeTrackedBrowserTabsForSessions: FacadeModule["closeTrackedBrowserTabsForSessions"] = - ((...args) => - loadFacadeModule()["closeTrackedBrowserTabsForSessions"]( - ...args, - )) as FacadeModule["closeTrackedBrowserTabsForSessions"]; export const createBrowserControlContext: FacadeModule["createBrowserControlContext"] = (( ...args ) => @@ -139,8 +135,6 @@ export const isPersistentBrowserProfileMutation: FacadeModule["isPersistentBrows loadFacadeModule()["isPersistentBrowserProfileMutation"]( ...args, )) as FacadeModule["isPersistentBrowserProfileMutation"]; -export const movePathToTrash: FacadeModule["movePathToTrash"] = ((...args) => - loadFacadeModule()["movePathToTrash"](...args)) as FacadeModule["movePathToTrash"]; export const normalizeBrowserFormField: FacadeModule["normalizeBrowserFormField"] = ((...args) => loadFacadeModule()["normalizeBrowserFormField"]( ...args, diff --git a/src/plugin-sdk/browser.ts b/src/plugin-sdk/browser.ts index 8728af181ba..c86e7b73e38 100644 --- a/src/plugin-sdk/browser.ts +++ b/src/plugin-sdk/browser.ts @@ -4,11 +4,11 @@ type FacadeEntry = PluginSdkFacadeTypeMap["browser"]; type FacadeModule = FacadeEntry["module"]; import { createLazyFacadeObjectValue, - loadBundledPluginPublicSurfaceModuleSync, + loadActivatedBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "browser", artifactBasename: "runtime-api.js", }); diff --git a/src/plugin-sdk/discord-maintenance.ts b/src/plugin-sdk/discord-maintenance.ts new file mode 100644 index 00000000000..61b19029fdc --- /dev/null +++ b/src/plugin-sdk/discord-maintenance.ts @@ -0,0 +1,17 @@ +import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js"; +import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +type DiscordThreadBindingsModule = PluginSdkFacadeTypeMap["discord-thread-bindings"]["module"]; + +export const unbindThreadBindingsBySessionKey: DiscordThreadBindingsModule["unbindThreadBindingsBySessionKey"] = + ((...args) => { + // Session cleanup always attempts Discord thread unbinds, even when Discord is disabled. + // Keep that path a no-op unless the Discord runtime is actually active. + const unbindThreadBindings = tryLoadActivatedBundledPluginPublicSurfaceModuleSync< + Pick + >({ + dirName: "discord", + artifactBasename: "runtime-api.js", + })?.unbindThreadBindingsBySessionKey; + return typeof unbindThreadBindings === "function" ? unbindThreadBindings(...args) : []; + }) as DiscordThreadBindingsModule["unbindThreadBindingsBySessionKey"]; diff --git a/src/plugin-sdk/discord-runtime-surface.ts b/src/plugin-sdk/discord-runtime-surface.ts index 1cbb29c1e71..12d273020b9 100644 --- a/src/plugin-sdk/discord-runtime-surface.ts +++ b/src/plugin-sdk/discord-runtime-surface.ts @@ -4,11 +4,11 @@ type FacadeEntry = PluginSdkFacadeTypeMap["discord-runtime-surface"]; type FacadeModule = FacadeEntry["module"]; import { createLazyFacadeObjectValue, - loadBundledPluginPublicSurfaceModuleSync, + loadActivatedBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "discord", artifactBasename: "runtime-api.js", }); diff --git a/src/plugin-sdk/discord-thread-bindings.ts b/src/plugin-sdk/discord-thread-bindings.ts index 032cc7bd2d5..5c4fdd16324 100644 --- a/src/plugin-sdk/discord-thread-bindings.ts +++ b/src/plugin-sdk/discord-thread-bindings.ts @@ -2,10 +2,11 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js"; type FacadeEntry = PluginSdkFacadeTypeMap["discord-thread-bindings"]; type FacadeModule = FacadeEntry["module"]; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +export { unbindThreadBindingsBySessionKey } from "./discord-maintenance.js"; +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "discord", artifactBasename: "runtime-api.js", }); @@ -61,12 +62,6 @@ export const setThreadBindingMaxAgeBySessionKey: FacadeModule["setThreadBindingM loadFacadeModule()["setThreadBindingMaxAgeBySessionKey"]( ...args, )) as FacadeModule["setThreadBindingMaxAgeBySessionKey"]; -export const unbindThreadBindingsBySessionKey: FacadeModule["unbindThreadBindingsBySessionKey"] = (( - ...args -) => - loadFacadeModule()["unbindThreadBindingsBySessionKey"]( - ...args, - )) as FacadeModule["unbindThreadBindingsBySessionKey"]; export type ThreadBindingManager = FacadeEntry["types"]["ThreadBindingManager"]; export type ThreadBindingRecord = FacadeEntry["types"]["ThreadBindingRecord"]; export type ThreadBindingTargetKind = FacadeEntry["types"]["ThreadBindingTargetKind"]; diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index 22b40582b94..506b9ad9467 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -2,7 +2,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; +import { + canLoadActivatedBundledPluginPublicSurface, + loadActivatedBundledPluginPublicSurfaceModuleSync, + loadBundledPluginPublicSurfaceModuleSync, + tryLoadActivatedBundledPluginPublicSurfaceModuleSync, +} from "./facade-runtime.js"; const tempDirs: string[] = []; const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -63,6 +69,7 @@ function createCircularPluginDir(prefix: string): string { afterEach(() => { vi.restoreAllMocks(); + clearRuntimeConfigSnapshot(); if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { @@ -140,4 +147,69 @@ describe("plugin-sdk facade runtime", () => { }), ).toThrow("plugin load failure"); }); + + it("blocks runtime-api facade loads for bundled plugins that are not activated", () => { + setRuntimeConfigSnapshot({}); + + expect( + canLoadActivatedBundledPluginPublicSurface({ + dirName: "discord", + artifactBasename: "runtime-api.js", + }), + ).toBe(false); + expect(() => + loadActivatedBundledPluginPublicSurfaceModuleSync({ + dirName: "discord", + artifactBasename: "runtime-api.js", + }), + ).toThrow(/Bundled plugin public surface access blocked/); + expect( + tryLoadActivatedBundledPluginPublicSurfaceModuleSync({ + dirName: "discord", + artifactBasename: "runtime-api.js", + }), + ).toBeNull(); + }); + + it("allows runtime-api facade loads when the bundled plugin is explicitly enabled", () => { + setRuntimeConfigSnapshot({ + plugins: { + entries: { + discord: { + enabled: true, + }, + }, + }, + }); + + expect( + canLoadActivatedBundledPluginPublicSurface({ + dirName: "discord", + artifactBasename: "runtime-api.js", + }), + ).toBe(true); + }); + + it("keeps shared runtime-core facades available without plugin activation", () => { + setRuntimeConfigSnapshot({}); + + expect( + canLoadActivatedBundledPluginPublicSurface({ + dirName: "speech-core", + artifactBasename: "runtime-api.js", + }), + ).toBe(true); + expect( + canLoadActivatedBundledPluginPublicSurface({ + dirName: "image-generation-core", + artifactBasename: "runtime-api.js", + }), + ).toBe(true); + expect( + canLoadActivatedBundledPluginPublicSurface({ + dirName: "media-understanding-core", + artifactBasename: "runtime-api.js", + }), + ).toBe(true); + }); }); diff --git a/src/plugin-sdk/facade-runtime.ts b/src/plugin-sdk/facade-runtime.ts index 1c231ce2a93..77ffc0e08e7 100644 --- a/src/plugin-sdk/facade-runtime.ts +++ b/src/plugin-sdk/facade-runtime.ts @@ -2,9 +2,16 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; import { resolveBundledPluginPublicSurfacePath } from "../plugins/bundled-plugin-metadata.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, +} from "../plugins/manifest-registry.js"; import { buildPluginLoaderAliasMap, buildPluginLoaderJitiOptions, @@ -19,8 +26,21 @@ const OPENCLAW_PACKAGE_ROOT = }) ?? fileURLToPath(new URL("../..", import.meta.url)); const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); const PUBLIC_SURFACE_SOURCE_EXTENSIONS = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"] as const; +const ALWAYS_ALLOWED_RUNTIME_DIR_NAMES = new Set([ + "image-generation-core", + "media-understanding-core", + "speech-core", +]); +const EMPTY_FACADE_BOUNDARY_CONFIG: OpenClawConfig = {}; const jitiLoaders = new Map>(); const loadedFacadeModules = new Map(); +let cachedBoundaryRawConfig: OpenClawConfig | undefined; +let cachedBoundaryResolvedConfig: + | { + config: OpenClawConfig; + normalizedPluginsConfig: ReturnType; + } + | undefined; function resolveSourceFirstPublicSurfacePath(params: { bundledPluginsDir?: string; @@ -106,6 +126,88 @@ function getJiti(modulePath: string) { return loader; } +function readFacadeBoundaryConfigSafely(): OpenClawConfig { + try { + return loadConfig(); + } catch { + return EMPTY_FACADE_BOUNDARY_CONFIG; + } +} + +function getFacadeBoundaryResolvedConfig() { + const rawConfig = readFacadeBoundaryConfigSafely(); + if (cachedBoundaryResolvedConfig && cachedBoundaryRawConfig === rawConfig) { + return cachedBoundaryResolvedConfig; + } + + const config = applyPluginAutoEnable({ + config: rawConfig, + env: process.env, + }).config; + const resolved = { + config, + normalizedPluginsConfig: normalizePluginsConfig(config.plugins), + }; + cachedBoundaryRawConfig = rawConfig; + cachedBoundaryResolvedConfig = resolved; + return resolved; +} + +function resolveBundledPluginManifestRecordByDirName(dirName: string): PluginManifestRecord | null { + const { config } = getFacadeBoundaryResolvedConfig(); + return ( + loadPluginManifestRegistry({ + config, + cache: true, + }).plugins.find( + (plugin) => plugin.origin === "bundled" && path.basename(plugin.rootDir) === dirName, + ) ?? null + ); +} + +function resolveBundledPluginPublicSurfaceAccess(params: { + dirName: string; + artifactBasename: string; +}): { allowed: boolean; pluginId?: string; reason?: string } { + if ( + params.artifactBasename === "runtime-api.js" && + ALWAYS_ALLOWED_RUNTIME_DIR_NAMES.has(params.dirName) + ) { + return { + allowed: true, + pluginId: params.dirName, + }; + } + + const manifestRecord = resolveBundledPluginManifestRecordByDirName(params.dirName); + if (!manifestRecord) { + return { + allowed: false, + reason: `no bundled plugin manifest found for ${params.dirName}`, + }; + } + const { config, normalizedPluginsConfig } = getFacadeBoundaryResolvedConfig(); + const enableState = resolveEffectiveEnableState({ + id: manifestRecord.id, + origin: manifestRecord.origin, + config: normalizedPluginsConfig, + rootConfig: config, + enabledByDefault: manifestRecord.enabledByDefault, + }); + if (enableState.enabled) { + return { + allowed: true, + pluginId: manifestRecord.id, + }; + } + + return { + allowed: false, + pluginId: manifestRecord.id, + reason: enableState.reason ?? "plugin runtime is not activated", + }; +} + function createLazyFacadeValueLoader(load: () => T): () => T { let loaded = false; let value: T; @@ -220,3 +322,35 @@ export function loadBundledPluginPublicSurfaceModuleSync(param return sentinel; } + +export function canLoadActivatedBundledPluginPublicSurface(params: { + dirName: string; + artifactBasename: string; +}): boolean { + return resolveBundledPluginPublicSurfaceAccess(params).allowed; +} + +export function loadActivatedBundledPluginPublicSurfaceModuleSync(params: { + dirName: string; + artifactBasename: string; +}): T { + const access = resolveBundledPluginPublicSurfaceAccess(params); + if (!access.allowed) { + const pluginLabel = access.pluginId ?? params.dirName; + throw new Error( + `Bundled plugin public surface access blocked for "${pluginLabel}" via ${params.dirName}/${params.artifactBasename}: ${access.reason ?? "plugin runtime is not activated"}`, + ); + } + return loadBundledPluginPublicSurfaceModuleSync(params); +} + +export function tryLoadActivatedBundledPluginPublicSurfaceModuleSync(params: { + dirName: string; + artifactBasename: string; +}): T | null { + const access = resolveBundledPluginPublicSurfaceAccess(params); + if (!access.allowed) { + return null; + } + return loadBundledPluginPublicSurfaceModuleSync(params); +} diff --git a/src/plugin-sdk/feishu-conversation.ts b/src/plugin-sdk/feishu-conversation.ts index 78e19ee6da7..3346656035c 100644 --- a/src/plugin-sdk/feishu-conversation.ts +++ b/src/plugin-sdk/feishu-conversation.ts @@ -3,8 +3,8 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type type FacadeEntry = PluginSdkFacadeTypeMap["feishu-conversation"]; type FacadeModule = FacadeEntry["module"]; import { - createLazyFacadeObjectValue, createLazyFacadeArrayValue, + createLazyFacadeObjectValue, loadBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; diff --git a/src/plugin-sdk/image-generation-runtime.ts b/src/plugin-sdk/image-generation-runtime.ts index 13c547de234..0a30f55d15e 100644 --- a/src/plugin-sdk/image-generation-runtime.ts +++ b/src/plugin-sdk/image-generation-runtime.ts @@ -2,10 +2,10 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js"; type FacadeEntry = PluginSdkFacadeTypeMap["image-generation-runtime"]; type FacadeModule = FacadeEntry["module"]; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "image-generation-core", artifactBasename: "runtime-api.js", }); diff --git a/src/plugin-sdk/kilocode.ts b/src/plugin-sdk/kilocode.ts index 872d9ba9665..6343c0c7a14 100644 --- a/src/plugin-sdk/kilocode.ts +++ b/src/plugin-sdk/kilocode.ts @@ -3,8 +3,8 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type type FacadeEntry = PluginSdkFacadeTypeMap["kilocode"]; type FacadeModule = FacadeEntry["module"]; import { - createLazyFacadeObjectValue, createLazyFacadeArrayValue, + createLazyFacadeObjectValue, loadBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; diff --git a/src/plugin-sdk/line-runtime.ts b/src/plugin-sdk/line-runtime.ts index 8fc50a213db..4d2b1c7d8cc 100644 --- a/src/plugin-sdk/line-runtime.ts +++ b/src/plugin-sdk/line-runtime.ts @@ -2,10 +2,10 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js"; type FacadeEntry = PluginSdkFacadeTypeMap["line-runtime"]; type FacadeModule = FacadeEntry["module"]; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "line", artifactBasename: "runtime-api.js", }); diff --git a/src/plugin-sdk/matrix-runtime-surface.ts b/src/plugin-sdk/matrix-runtime-surface.ts index 86125c6a033..ee246cc58bb 100644 --- a/src/plugin-sdk/matrix-runtime-surface.ts +++ b/src/plugin-sdk/matrix-runtime-surface.ts @@ -2,10 +2,10 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js"; type FacadeEntry = PluginSdkFacadeTypeMap["matrix-runtime-surface"]; type FacadeModule = FacadeEntry["module"]; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "matrix", artifactBasename: "runtime-api.js", }); diff --git a/src/plugin-sdk/media-understanding-runtime.ts b/src/plugin-sdk/media-understanding-runtime.ts index 3639a4f861e..c5937672c02 100644 --- a/src/plugin-sdk/media-understanding-runtime.ts +++ b/src/plugin-sdk/media-understanding-runtime.ts @@ -2,10 +2,10 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js"; type FacadeEntry = PluginSdkFacadeTypeMap["media-understanding-runtime"]; type FacadeModule = FacadeEntry["module"]; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "media-understanding-core", artifactBasename: "runtime-api.js", }); diff --git a/src/plugin-sdk/memory-core-engine-runtime.ts b/src/plugin-sdk/memory-core-engine-runtime.ts index ae7ee2f8295..c5415596347 100644 --- a/src/plugin-sdk/memory-core-engine-runtime.ts +++ b/src/plugin-sdk/memory-core-engine-runtime.ts @@ -4,11 +4,11 @@ type FacadeEntry = PluginSdkFacadeTypeMap["memory-core-engine-runtime"]; type FacadeModule = FacadeEntry["module"]; import { createLazyFacadeObjectValue, - loadBundledPluginPublicSurfaceModuleSync, + loadActivatedBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "memory-core", artifactBasename: "runtime-api.js", }); diff --git a/src/plugin-sdk/minimax.ts b/src/plugin-sdk/minimax.ts index a50da86782e..ded25224a5d 100644 --- a/src/plugin-sdk/minimax.ts +++ b/src/plugin-sdk/minimax.ts @@ -3,8 +3,8 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type type FacadeEntry = PluginSdkFacadeTypeMap["minimax"]; type FacadeModule = FacadeEntry["module"]; import { - createLazyFacadeObjectValue, createLazyFacadeArrayValue, + createLazyFacadeObjectValue, loadBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; diff --git a/src/plugin-sdk/modelstudio.ts b/src/plugin-sdk/modelstudio.ts index 7ea37e56339..0aeaa4c8580 100644 --- a/src/plugin-sdk/modelstudio.ts +++ b/src/plugin-sdk/modelstudio.ts @@ -3,8 +3,8 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type type FacadeEntry = PluginSdkFacadeTypeMap["modelstudio"]; type FacadeModule = FacadeEntry["module"]; import { - createLazyFacadeObjectValue, createLazyFacadeArrayValue, + createLazyFacadeObjectValue, loadBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; diff --git a/src/plugin-sdk/slack-runtime-surface.ts b/src/plugin-sdk/slack-runtime-surface.ts index e14b175c0bf..c22c5bdfb10 100644 --- a/src/plugin-sdk/slack-runtime-surface.ts +++ b/src/plugin-sdk/slack-runtime-surface.ts @@ -2,10 +2,10 @@ import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js"; type FacadeEntry = PluginSdkFacadeTypeMap["slack-runtime-surface"]; type FacadeModule = FacadeEntry["module"]; -import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "slack", artifactBasename: "runtime-api.js", }); diff --git a/src/plugin-sdk/speech-runtime.ts b/src/plugin-sdk/speech-runtime.ts index 8b8c1c55e44..5e1089c6421 100644 --- a/src/plugin-sdk/speech-runtime.ts +++ b/src/plugin-sdk/speech-runtime.ts @@ -4,11 +4,11 @@ type FacadeEntry = PluginSdkFacadeTypeMap["speech-runtime"]; type FacadeModule = FacadeEntry["module"]; import { createLazyFacadeObjectValue, - loadBundledPluginPublicSurfaceModuleSync, + loadActivatedBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ + return loadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: "speech-core", artifactBasename: "runtime-api.js", }); diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 96f5a3950d0..1244d3c4e38 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); +const loadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); vi.mock("../plugin-sdk/facade-runtime.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + loadActivatedBundledPluginPublicSurfaceModuleSync, loadBundledPluginPublicSurfaceModuleSync, }; }); @@ -14,6 +16,7 @@ describe("tts runtime facade", () => { let ttsModulePromise: Promise | undefined; beforeEach(() => { + loadActivatedBundledPluginPublicSurfaceModuleSync.mockReset(); loadBundledPluginPublicSurfaceModuleSync.mockReset(); }); @@ -30,15 +33,15 @@ describe("tts runtime facade", () => { it("loads speech-core lazily on first runtime access", async () => { const buildTtsSystemPromptHint = vi.fn().mockReturnValue("hint"); - loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + loadActivatedBundledPluginPublicSurfaceModuleSync.mockReturnValue({ buildTtsSystemPromptHint, }); const tts = await importTtsModule(); - expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); + expect(loadActivatedBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); expect(tts.buildTtsSystemPromptHint({} as never)).toBe("hint"); - expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledTimes(1); + expect(loadActivatedBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledTimes(1); expect(buildTtsSystemPromptHint).toHaveBeenCalledTimes(1); }); });