From a4850b1b8f2c2cfc5a945af52930732a112d5230 Mon Sep 17 00:00:00 2001 From: Igal Tabachnik Date: Wed, 4 Mar 2026 02:58:48 +0200 Subject: [PATCH] fix(plugins): lazily initialize runtime and split plugin-sdk startup imports (#28620) Merged via squash. Prepared head SHA: 8bd7d6c13b070f86bd4d5c45286c1ceb1a3f9f80 Co-authored-by: hmemcpy <601206+hmemcpy@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + extensions/device-pair/index.ts | 4 +- extensions/memory-core/index.ts | 4 +- extensions/phone-control/index.ts | 2 +- extensions/talk-voice/index.ts | 2 +- extensions/telegram/index.ts | 4 +- extensions/telegram/src/channel.ts | 2 +- extensions/telegram/src/runtime.ts | 2 +- package.json | 8 ++++ scripts/write-plugin-sdk-entry-dts.ts | 2 +- src/plugin-sdk/core.ts | 26 +++++++++++ src/plugin-sdk/telegram.ts | 53 +++++++++++++++++++++++ src/plugins/loader.test.ts | 31 ++++++++++++++ src/plugins/loader.ts | 62 +++++++++++++++++++++++---- tsconfig.plugin-sdk.dts.json | 2 + tsdown.config.ts | 32 ++++++++++++++ vitest.config.ts | 8 ++++ 17 files changed, 226 insertions(+), 19 deletions(-) create mode 100644 src/plugin-sdk/core.ts create mode 100644 src/plugin-sdk/telegram.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b8b6fbd6f..a0ce842f8be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/security default response headers: add `Permissions-Policy: camera=(), microphone=(), geolocation=()` to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan. +- Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy. - Security/auth labels: remove token and API-key snippets from user-facing auth status labels so `/status` and `/models` do not expose credential fragments. (#33262) thanks @cu1ch3n. - Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown `gateway.nodes.denyCommands` entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot. - Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan. diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index b3321b37a5d..c9772a422f2 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,12 +1,12 @@ import os from "node:os"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { approveDevicePairing, listDevicePairing, resolveGatewayBindUrl, runPluginCommandWithTimeout, resolveTailnetHostWithRunner, -} from "openclaw/plugin-sdk"; +} from "openclaw/plugin-sdk/core"; import qrcode from "qrcode-terminal"; import { armPairNotifyOnce, diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index c71e046ef52..05f6aa069fe 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; const memoryCorePlugin = { id: "memory-core", diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index c101b3bd7ba..f2f9acac892 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { OpenClawPluginApi, OpenClawPluginService } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi, OpenClawPluginService } from "openclaw/plugin-sdk/core"; type ArmGroup = "camera" | "screen" | "writes" | "all"; diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index f838c2fa27a..328e69a8f87 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; type ElevenLabsVoice = { voice_id: string; diff --git a/extensions/telegram/index.ts b/extensions/telegram/index.ts index a2492fca87d..d47ae46b6ce 100644 --- a/extensions/telegram/index.ts +++ b/extensions/telegram/index.ts @@ -1,5 +1,5 @@ -import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { telegramPlugin } from "./src/channel.js"; import { setTelegramRuntime } from "./src/runtime.js"; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 2869f168a12..3564a9719ab 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -31,7 +31,7 @@ import { type OpenClawConfig, type ResolvedTelegramAccount, type TelegramProbe, -} from "openclaw/plugin-sdk"; +} from "openclaw/plugin-sdk/telegram"; import { getTelegramRuntime } from "./runtime.js"; const meta = getChatChannelMeta("telegram"); diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index f765d4ed02e..491f7f7d956 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; let runtime: PluginRuntime | null = null; diff --git a/package.json b/package.json index 0bdbf1da2d7..2b58d97c305 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,14 @@ "types": "./dist/plugin-sdk/index.d.ts", "default": "./dist/plugin-sdk/index.js" }, + "./plugin-sdk/core": { + "types": "./dist/plugin-sdk/core.d.ts", + "default": "./dist/plugin-sdk/core.js" + }, + "./plugin-sdk/telegram": { + "types": "./dist/plugin-sdk/telegram.d.ts", + "default": "./dist/plugin-sdk/telegram.js" + }, "./plugin-sdk/account-id": { "types": "./dist/plugin-sdk/account-id.d.ts", "default": "./dist/plugin-sdk/account-id.js" diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index 674f89ed13a..58cea44ab21 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -6,7 +6,7 @@ import path from "node:path"; // // Our package export map points subpath `types` at `dist/plugin-sdk/.d.ts`, so we // generate stable entry d.ts files that re-export the real declarations. -const entrypoints = ["index", "account-id"] as const; +const entrypoints = ["index", "core", "telegram", "account-id"] as const; for (const entry of entrypoints) { const out = path.join(process.cwd(), `dist/plugin-sdk/${entry}.d.ts`); fs.mkdirSync(path.dirname(out), { recursive: true }); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts new file mode 100644 index 00000000000..97960f925a0 --- /dev/null +++ b/src/plugin-sdk/core.ts @@ -0,0 +1,26 @@ +export type { OpenClawPluginApi, OpenClawPluginService } from "../plugins/types.js"; +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; + +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; + +export { + approveDevicePairing, + listDevicePairing, + rejectDevicePairing, +} from "../infra/device-pairing.js"; + +export { + runPluginCommandWithTimeout, + type PluginCommandRunOptions, + type PluginCommandRunResult, +} from "./run-command.js"; + +export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; +export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; + +export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; +export type { + TailscaleStatusCommandResult, + TailscaleStatusCommandRunner, +} from "../shared/tailscale-status.js"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts new file mode 100644 index 00000000000..aae6a429080 --- /dev/null +++ b/src/plugin-sdk/telegram.ts @@ -0,0 +1,53 @@ +export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { ResolvedTelegramAccount } from "../telegram/accounts.js"; +export type { TelegramProbe } from "../telegram/probe.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; + +export { getChatChannelMeta } from "../channels/registry.js"; + +export { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "../telegram/accounts.js"; +export { + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, +} from "../channels/plugins/directory-config.js"; +export { + looksLikeTelegramTargetId, + normalizeTelegramMessagingTarget, +} from "../channels/plugins/normalize/telegram.js"; +export { + parseTelegramReplyToMessageId, + parseTelegramThreadId, +} from "../telegram/outbound-params.js"; +export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js"; + +export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +} from "../config/runtime-group-policy.js"; +export { + resolveTelegramGroupRequireMention, + resolveTelegramGroupToolPolicy, +} from "../channels/plugins/group-mentions.js"; +export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js"; +export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; + +export { buildTokenChannelStatusSummary } from "./status-helpers.js"; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d9b31fe8a4b..1a002447711 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -974,6 +974,37 @@ describe("loadOpenClawPlugins", () => { ); }); + it("preserves runtime reflection semantics when runtime is lazily initialized", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "runtime-introspection", + filename: "runtime-introspection.cjs", + body: `module.exports = { id: "runtime-introspection", register(api) { + const runtime = api.runtime ?? {}; + const keys = Object.keys(runtime); + if (!keys.includes("channel")) { + throw new Error("runtime channel key missing"); + } + if (!("channel" in runtime)) { + throw new Error("runtime channel missing from has check"); + } + if (!Object.getOwnPropertyDescriptor(runtime, "channel")) { + throw new Error("runtime channel descriptor missing"); + } +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["runtime-introspection"], + }, + }); + + const record = registry.plugins.find((entry) => entry.id === "runtime-introspection"); + expect(record?.status).toBe("loaded"); + }); + it("prefers dist plugin-sdk alias when loader runs from dist", () => { const { root, distFile } = createPluginSdkAliasFixture(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c0ac9751a3d..6bbdaacd5e0 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -22,6 +22,7 @@ import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { setActivePluginRegistry } from "./runtime.js"; import { createPluginRuntime } from "./runtime/index.js"; +import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; import type { OpenClawPluginDefinition, @@ -91,6 +92,14 @@ const resolvePluginSdkAccountIdAlias = (): string | null => { return resolvePluginSdkAliasFile({ srcFile: "account-id.ts", distFile: "account-id.js" }); }; +const resolvePluginSdkCoreAlias = (): string | null => { + return resolvePluginSdkAliasFile({ srcFile: "core.ts", distFile: "core.js" }); +}; + +const resolvePluginSdkTelegramAlias = (): string | null => { + return resolvePluginSdkAliasFile({ srcFile: "telegram.ts", distFile: "telegram.js" }); +}; + export const __testing = { resolvePluginSdkAliasFile, }; @@ -393,7 +402,39 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi // Clear previously registered plugin commands before reloading clearPluginCommands(); - const runtime = createPluginRuntime(); + // Lazily initialize the runtime so startup paths that discover/skip plugins do + // not eagerly load every channel runtime dependency. + let resolvedRuntime: PluginRuntime | null = null; + const resolveRuntime = (): PluginRuntime => { + resolvedRuntime ??= createPluginRuntime(); + return resolvedRuntime; + }; + const runtime = new Proxy({} as PluginRuntime, { + get(_target, prop, receiver) { + return Reflect.get(resolveRuntime(), prop, receiver); + }, + set(_target, prop, value, receiver) { + return Reflect.set(resolveRuntime(), prop, value, receiver); + }, + has(_target, prop) { + return Reflect.has(resolveRuntime(), prop); + }, + ownKeys() { + return Reflect.ownKeys(resolveRuntime() as object); + }, + getOwnPropertyDescriptor(_target, prop) { + return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop); + }, + defineProperty(_target, prop, attributes) { + return Reflect.defineProperty(resolveRuntime() as object, prop, attributes); + }, + deleteProperty(_target, prop) { + return Reflect.deleteProperty(resolveRuntime() as object, prop); + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(resolveRuntime() as object); + }, + }); const { registry, createApi } = createPluginRegistry({ logger, runtime, @@ -435,17 +476,22 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } const pluginSdkAlias = resolvePluginSdkAlias(); const pluginSdkAccountIdAlias = resolvePluginSdkAccountIdAlias(); + const pluginSdkCoreAlias = resolvePluginSdkCoreAlias(); + const pluginSdkTelegramAlias = resolvePluginSdkTelegramAlias(); + const aliasMap = { + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...(pluginSdkCoreAlias ? { "openclaw/plugin-sdk/core": pluginSdkCoreAlias } : {}), + ...(pluginSdkTelegramAlias ? { "openclaw/plugin-sdk/telegram": pluginSdkTelegramAlias } : {}), + ...(pluginSdkAccountIdAlias + ? { "openclaw/plugin-sdk/account-id": pluginSdkAccountIdAlias } + : {}), + }; jitiLoader = createJiti(import.meta.url, { interopDefault: true, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - ...(pluginSdkAlias || pluginSdkAccountIdAlias + ...(Object.keys(aliasMap).length > 0 ? { - alias: { - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...(pluginSdkAccountIdAlias - ? { "openclaw/plugin-sdk/account-id": pluginSdkAccountIdAlias } - : {}), - }, + alias: aliasMap, } : {}), }); diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index ba48a3d1eeb..4deee810315 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -12,6 +12,8 @@ }, "include": [ "src/plugin-sdk/index.ts", + "src/plugin-sdk/core.ts", + "src/plugin-sdk/telegram.ts", "src/plugin-sdk/account-id.ts", "src/plugin-sdk/keyed-async-queue.ts", "src/types/**/*.d.ts" diff --git a/tsdown.config.ts b/tsdown.config.ts index b4c9d97b48d..819396b2feb 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -30,6 +30,24 @@ export default defineConfig([ fixedExtension: false, platform: "node", }, + { + // Keep sync lazy-runtime channel modules as concrete dist files. + entry: { + "channels/plugins/agent-tools/whatsapp-login": + "src/channels/plugins/agent-tools/whatsapp-login.ts", + "channels/plugins/actions/discord": "src/channels/plugins/actions/discord.ts", + "channels/plugins/actions/signal": "src/channels/plugins/actions/signal.ts", + "channels/plugins/actions/telegram": "src/channels/plugins/actions/telegram.ts", + "telegram/audit": "src/telegram/audit.ts", + "telegram/token": "src/telegram/token.ts", + "line/accounts": "src/line/accounts.ts", + "line/send": "src/line/send.ts", + "line/template-messages": "src/line/template-messages.ts", + }, + env, + fixedExtension: false, + platform: "node", + }, { entry: "src/plugin-sdk/index.ts", outDir: "dist/plugin-sdk", @@ -37,6 +55,20 @@ export default defineConfig([ fixedExtension: false, platform: "node", }, + { + entry: "src/plugin-sdk/core.ts", + outDir: "dist/plugin-sdk", + env, + fixedExtension: false, + platform: "node", + }, + { + entry: "src/plugin-sdk/telegram.ts", + outDir: "dist/plugin-sdk", + env, + fixedExtension: false, + platform: "node", + }, { entry: "src/plugin-sdk/account-id.ts", outDir: "dist/plugin-sdk", diff --git a/vitest.config.ts b/vitest.config.ts index 51eda12f55b..e95927ae22f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,14 @@ export default defineConfig({ find: "openclaw/plugin-sdk/account-id", replacement: path.join(repoRoot, "src", "plugin-sdk", "account-id.ts"), }, + { + find: "openclaw/plugin-sdk/core", + replacement: path.join(repoRoot, "src", "plugin-sdk", "core.ts"), + }, + { + find: "openclaw/plugin-sdk/telegram", + replacement: path.join(repoRoot, "src", "plugin-sdk", "telegram.ts"), + }, { find: "openclaw/plugin-sdk/keyed-async-queue", replacement: path.join(repoRoot, "src", "plugin-sdk", "keyed-async-queue.ts"),