From 4133e3bb1dc9b9438c6f780861a87d3e1865a185 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 6 Apr 2026 12:12:28 +0100 Subject: [PATCH] fix(runtime): narrow bundled runtime startup surfaces --- extensions/mattermost/channel-plugin-api.ts | 32 ++++++- .../mattermost/channel-plugin-runtime.ts | 4 + extensions/mattermost/src/channel.runtime.ts | 9 ++ extensions/mattermost/src/channel.ts | 89 +++++++++++++------ extensions/zalo/runtime-api.test.ts | 43 +++------ extensions/zalo/runtime-api.ts | 5 +- src/secrets/target-registry-data.ts | 33 +++++++ 7 files changed, 153 insertions(+), 62 deletions(-) create mode 100644 extensions/mattermost/channel-plugin-runtime.ts create mode 100644 extensions/mattermost/src/channel.runtime.ts diff --git a/extensions/mattermost/channel-plugin-api.ts b/extensions/mattermost/channel-plugin-api.ts index e4907886d023..054f73c143e6 100644 --- a/extensions/mattermost/channel-plugin-api.ts +++ b/extensions/mattermost/channel-plugin-api.ts @@ -1,3 +1,33 @@ +import { loadBundledEntryExportSync } from "openclaw/plugin-sdk/channel-entry-contract"; + +type ChannelPluginModule = typeof import("./channel-plugin-runtime.js"); + +function createLazyObjectValue(load: () => T): T { + return new Proxy({} as T, { + get(_target, property, receiver) { + return Reflect.get(load(), property, receiver); + }, + has(_target, property) { + return property in load(); + }, + ownKeys() { + return Reflect.ownKeys(load()); + }, + getOwnPropertyDescriptor(_target, property) { + const descriptor = Object.getOwnPropertyDescriptor(load(), property); + return descriptor ? { ...descriptor, configurable: true } : undefined; + }, + }); +} + +function loadChannelPluginModule(): ChannelPluginModule { + return loadBundledEntryExportSync(import.meta.url, { + specifier: "./channel-plugin-runtime.js", + }); +} + // Keep bundled channel entry imports narrow so bootstrap/discovery paths do // not drag the broader Mattermost helper surfaces into lightweight plugin loads. -export { mattermostPlugin } from "./src/channel.js"; +export const mattermostPlugin: ChannelPluginModule["mattermostPlugin"] = createLazyObjectValue( + () => loadChannelPluginModule().mattermostPlugin as object, +) as ChannelPluginModule["mattermostPlugin"]; diff --git a/extensions/mattermost/channel-plugin-runtime.ts b/extensions/mattermost/channel-plugin-runtime.ts new file mode 100644 index 000000000000..5b773450e307 --- /dev/null +++ b/extensions/mattermost/channel-plugin-runtime.ts @@ -0,0 +1,4 @@ +// Private runtime-bearing plugin export for the bundled Mattermost entry. +// Keep the actual channel plugin value off the lighter channel-plugin-api seam +// so bootstrap can lazy-load it without tripping bundle init cycles. +export { mattermostPlugin } from "./src/channel.js"; diff --git a/extensions/mattermost/src/channel.runtime.ts b/extensions/mattermost/src/channel.runtime.ts new file mode 100644 index 000000000000..766657330800 --- /dev/null +++ b/extensions/mattermost/src/channel.runtime.ts @@ -0,0 +1,9 @@ +export { + listMattermostDirectoryGroups, + listMattermostDirectoryPeers, +} from "./mattermost/directory.js"; +export { monitorMattermostProvider } from "./mattermost/monitor.js"; +export { probeMattermost } from "./mattermost/probe.js"; +export { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js"; +export { sendMessageMattermost } from "./mattermost/send.js"; +export { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 0a05966a9608..72f96b12241f 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -16,6 +16,7 @@ import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy"; import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { createComputedAccountStatusAdapter, @@ -40,24 +41,43 @@ import { resolveMattermostReplyToMode, type ResolvedMattermostAccount, } from "./mattermost/accounts.js"; -import { - listMattermostDirectoryGroups, - listMattermostDirectoryPeers, -} from "./mattermost/directory.js"; -import { monitorMattermostProvider } from "./mattermost/monitor.js"; -import { probeMattermost } from "./mattermost/probe.js"; -import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js"; -import { sendMessageMattermost } from "./mattermost/send.js"; -import { collectMattermostSlashCallbackPaths } from "./mattermost/slash-commands.js"; -import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; +import type { MattermostSlashCommandConfig } from "./mattermost/slash-commands.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; -import { getMattermostRuntime } from "./runtime.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { resolveMattermostOutboundSessionRoute } from "./session-route.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; import type { MattermostConfig } from "./types.js"; +const loadMattermostChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); + +const DEFAULT_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command"; + +function collectMattermostSlashCallbackPaths( + raw?: Partial, +): string[] { + const callbackPath = (() => { + const trimmed = raw?.callbackPath?.trim(); + if (!trimmed) { + return DEFAULT_SLASH_CALLBACK_PATH; + } + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + })(); + const callbackUrl = raw?.callbackUrl?.trim(); + const paths = new Set([callbackPath]); + if (callbackUrl) { + try { + const pathname = new URL(callbackUrl).pathname; + if (pathname) { + paths.add(pathname); + } + } catch { + // Keep the normalized callback path when the configured URL is invalid. + } + } + return [...paths]; +} + const mattermostSecurityAdapter = createRestrictSendersChannelSecurity({ channelKey: "mattermost", resolveDmPolicy: (account) => account.config.dmPolicy, @@ -135,7 +155,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { const { postId, emojiName, remove } = parseMattermostReactActionParams(params); if (remove) { - const result = await removeMattermostReaction({ + const result = await ( + await loadMattermostChannelRuntime() + ).removeMattermostReaction({ cfg, postId, emojiName, @@ -152,7 +174,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { }; } - const result = await addMattermostReaction({ + const result = await ( + await loadMattermostChannelRuntime() + ).addMattermostReaction({ cfg, postId, emojiName, @@ -192,7 +216,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { const mediaUrl = typeof params.media === "string" ? params.media.trim() || undefined : undefined; - const result = await sendMessageMattermost(to, message, { + const result = await ( + await loadMattermostChannelRuntime() + ).sendMessageMattermost(to, message, { accountId: resolvedAccountId, replyToId, buttons: Array.isArray(params.buttons) ? params.buttons : undefined, @@ -342,10 +368,14 @@ export const mattermostPlugin: ChannelPlugin = create collectRuntimeConfigAssignments, }, directory: createChannelDirectoryAdapter({ - listGroups: async (params) => listMattermostDirectoryGroups(params), - listGroupsLive: async (params) => listMattermostDirectoryGroups(params), - listPeers: async (params) => listMattermostDirectoryPeers(params), - listPeersLive: async (params) => listMattermostDirectoryPeers(params), + listGroups: async (params) => + (await loadMattermostChannelRuntime()).listMattermostDirectoryGroups(params), + listGroupsLive: async (params) => + (await loadMattermostChannelRuntime()).listMattermostDirectoryGroups(params), + listPeers: async (params) => + (await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params), + listPeersLive: async (params) => + (await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params), }), messaging: { defaultMarkdownTableMode: "off", @@ -355,7 +385,9 @@ export const mattermostPlugin: ChannelPlugin = create looksLikeId: looksLikeMattermostTargetId, hint: "", resolveTarget: async ({ cfg, accountId, input }) => { - const resolved = await resolveMattermostOpaqueTarget({ + const resolved = await ( + await loadMattermostChannelRuntime() + ).resolveMattermostOpaqueTarget({ input, cfg, accountId, @@ -389,12 +421,9 @@ export const mattermostPlugin: ChannelPlugin = create if (!token || !baseUrl) { return { ok: false, error: "bot token or baseUrl missing" }; } - return await probeMattermost( - baseUrl, - token, - timeoutMs, - isPrivateNetworkOptInEnabled(account.config), - ); + return await ( + await loadMattermostChannelRuntime() + ).probeMattermost(baseUrl, token, timeoutMs, isPrivateNetworkOptInEnabled(account.config)); }, resolveAccountSnapshot: ({ account, runtime }) => ({ accountId: account.accountId, @@ -450,7 +479,7 @@ export const mattermostPlugin: ChannelPlugin = create botTokenSource: account.botTokenSource, }); ctx.log?.info(`[${account.accountId}] starting channel`); - return monitorMattermostProvider({ + return (await loadMattermostChannelRuntime()).monitorMattermostProvider({ botToken: account.botToken ?? undefined, baseUrl: account.baseUrl ?? undefined, accountId: account.accountId, @@ -511,7 +540,9 @@ export const mattermostPlugin: ChannelPlugin = create attachedResults: { channel: "mattermost", sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => - await sendMessageMattermost(to, text, { + await ( + await loadMattermostChannelRuntime() + ).sendMessageMattermost(to, text, { cfg, accountId: accountId ?? undefined, replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), @@ -526,7 +557,9 @@ export const mattermostPlugin: ChannelPlugin = create replyToId, threadId, }) => - await sendMessageMattermost(to, text, { + await ( + await loadMattermostChannelRuntime() + ).sendMessageMattermost(to, text, { cfg, accountId: accountId ?? undefined, mediaUrl, diff --git a/extensions/zalo/runtime-api.test.ts b/extensions/zalo/runtime-api.test.ts index 30d042e477cf..813b229a3a1f 100644 --- a/extensions/zalo/runtime-api.test.ts +++ b/extensions/zalo/runtime-api.test.ts @@ -1,36 +1,19 @@ -import { execFile } from "node:child_process"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; - -const execFileAsync = promisify(execFile); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); -const zaloRuntimeImportEnv = { - HOME: process.env.HOME, - NODE_OPTIONS: process.env.NODE_OPTIONS, - NODE_PATH: process.env.NODE_PATH, - PATH: process.env.PATH, - TERM: process.env.TERM, -} satisfies NodeJS.ProcessEnv; +import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/plugins/jiti-runtime-api.js"; describe("zalo runtime api", () => { - it("exports the channel plugin without reentering setup surfaces", async () => { - const { stdout } = await execFileAsync( - process.execPath, - [ - "--import", - "tsx", - "-e", - 'const runtimeApi = await import("./extensions/zalo/runtime-api.ts"); process.stdout.write(runtimeApi.zaloPlugin.id);', - ], - { - cwd: repoRoot, - env: zaloRuntimeImportEnv, - timeout: 40_000, - }, - ); + it("loads the narrow runtime api without reentering setup surfaces", () => { + const runtimeApiPath = path.join(process.cwd(), "extensions", "zalo", "runtime-api.ts"); - expect(stdout).toBe("zalo"); - }, 45_000); + expect( + loadRuntimeApiExportTypesViaJiti({ + modulePath: runtimeApiPath, + exportNames: ["setZaloRuntime"], + realPluginSdkSpecifiers: ["openclaw/plugin-sdk/runtime-store"], + }), + ).toEqual({ + setZaloRuntime: "function", + }); + }); }); diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index b6dda1888f15..5eebd3b2a0f7 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -1,5 +1,4 @@ // Private runtime barrel for the bundled Zalo extension. -// Keep this barrel thin and free of local plugin self-imports so the bundled -// entry loader can resolve the channel plugin without re-entering this module. -export { zaloPlugin } from "./src/channel.js"; +// Keep this barrel thin and free of channel plugin exports so direct runtime +// imports do not re-enter the full channel/setup surface. export * from "./src/runtime-api.js"; diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 051659bee654..7ebab7b6ffae 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -1,4 +1,6 @@ import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; +import { loadBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-runtime.js"; +import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js"; import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret @@ -6,7 +8,38 @@ const SIBLING_REF_SHAPE = "sibling_ref"; // pragma: allowlist secret function listChannelSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] { const entries: SecretTargetRegistryEntry[] = []; + const handledChannelIds = new Set(); + + for (const metadata of listBundledPluginMetadata({ + includeChannelConfigs: false, + includeSyntheticChannelConfigs: false, + })) { + const channelIds = metadata.manifest.channels ?? []; + if (channelIds.length === 0) { + continue; + } + if (!metadata.publicSurfaceArtifacts?.includes("contract-api.js")) { + continue; + } + try { + const contractApi = loadBundledPluginPublicSurfaceModuleSync<{ + secretTargetRegistryEntries?: readonly SecretTargetRegistryEntry[]; + }>({ + dirName: metadata.dirName, + artifactBasename: "contract-api.js", + }); + entries.push(...(contractApi.secretTargetRegistryEntries ?? [])); + channelIds.forEach((channelId) => handledChannelIds.add(channelId)); + } catch { + // Fall back to the full bootstrap plugin surface for channels that do not + // expose a usable secret contract artifact. + } + } + for (const plugin of iterateBootstrapChannelPlugins()) { + if (handledChannelIds.has(plugin.id)) { + continue; + } entries.push(...(plugin.secrets?.secretTargetRegistryEntries ?? [])); } return entries;