fix(plugins): guard runtime facade activation (#59412)

* fix(plugins): guard runtime facade activation

* refactor(plugin-sdk): localize facade load policy

* fix(plugin-sdk): narrow facade activation guards

* fix(browser): keep cleanup helpers outside activation guard

* style(browser): apply formatter follow-ups

* chore(changelog): note plugin activation guard regressions

* fix(discord): keep cleanup thread unbinds outside activation guard

* fix(browser): fallback when trash exits non-zero
This commit is contained in:
Vincent Koc 2026-04-02 14:37:12 +09:00 committed by GitHub
parent ed6012eb5b
commit 52a018680d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 509 additions and 57 deletions

View File

@ -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.

View File

@ -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(" });");

View File

@ -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(

View File

@ -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<BundledSessionKeyModule>({
tryLoadActivatedBundledPluginPublicSurfaceModuleSync<BundledSessionKeyModule>({
dirName,
artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME,
}).resolveSessionConversation;
})?.resolveSessionConversation;
if (typeof resolveSessionConversation !== "function") {
return null;
}

View File

@ -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,
}));

View File

@ -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<void> | 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();
});

View File

@ -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");
});
});

View File

@ -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<BrowserRuntimeModule, "closeTrackedBrowserTabsForSessions">
>({
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"];

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
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,

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "browser",
artifactBasename: "runtime-api.js",
});

View File

@ -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<DiscordThreadBindingsModule, "unbindThreadBindingsBySessionKey">
>({
dirName: "discord",
artifactBasename: "runtime-api.js",
})?.unbindThreadBindingsBySessionKey;
return typeof unbindThreadBindings === "function" ? unbindThreadBindings(...args) : [];
}) as DiscordThreadBindingsModule["unbindThreadBindingsBySessionKey"];

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "discord",
artifactBasename: "runtime-api.js",
});

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
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"];

View File

@ -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);
});
});

View File

@ -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<string, ReturnType<typeof createJiti>>();
const loadedFacadeModules = new Map<string, unknown>();
let cachedBoundaryRawConfig: OpenClawConfig | undefined;
let cachedBoundaryResolvedConfig:
| {
config: OpenClawConfig;
normalizedPluginsConfig: ReturnType<typeof normalizePluginsConfig>;
}
| 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<T>(load: () => T): () => T {
let loaded = false;
let value: T;
@ -220,3 +322,35 @@ export function loadBundledPluginPublicSurfaceModuleSync<T extends object>(param
return sentinel;
}
export function canLoadActivatedBundledPluginPublicSurface(params: {
dirName: string;
artifactBasename: string;
}): boolean {
return resolveBundledPluginPublicSurfaceAccess(params).allowed;
}
export function loadActivatedBundledPluginPublicSurfaceModuleSync<T extends object>(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<T>(params);
}
export function tryLoadActivatedBundledPluginPublicSurfaceModuleSync<T extends object>(params: {
dirName: string;
artifactBasename: string;
}): T | null {
const access = resolveBundledPluginPublicSurfaceAccess(params);
if (!access.allowed) {
return null;
}
return loadBundledPluginPublicSurfaceModuleSync<T>(params);
}

View File

@ -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";

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "image-generation-core",
artifactBasename: "runtime-api.js",
});

View File

@ -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";

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "line",
artifactBasename: "runtime-api.js",
});

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "matrix",
artifactBasename: "runtime-api.js",
});

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "media-understanding-core",
artifactBasename: "runtime-api.js",
});

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "memory-core",
artifactBasename: "runtime-api.js",
});

View File

@ -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";

View File

@ -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";

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "slack",
artifactBasename: "runtime-api.js",
});

View File

@ -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<FacadeModule>({
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "speech-core",
artifactBasename: "runtime-api.js",
});

View File

@ -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<typeof import("../plugin-sdk/facade-runtime.js")>();
return {
...actual,
loadActivatedBundledPluginPublicSurfaceModuleSync,
loadBundledPluginPublicSurfaceModuleSync,
};
});
@ -14,6 +16,7 @@ describe("tts runtime facade", () => {
let ttsModulePromise: Promise<typeof import("./tts.js")> | 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);
});
});