From 6d9bf6de9383d9f91741b17767fc8d30f9d0e0fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 09:58:12 -0700 Subject: [PATCH] refactor: narrow extension public seams --- docs/tools/plugin.md | 10 +++ extensions/discord/api.ts | 1 - extensions/imessage/api.ts | 1 - extensions/signal/api.ts | 1 - extensions/slack/api.ts | 1 - extensions/telegram/api.ts | 1 - extensions/whatsapp/api.ts | 1 - ....triggers.trigger-handling.test-harness.ts | 2 +- src/channels/plugins/contracts/registry.ts | 4 +- src/gateway/test-helpers.mocks.ts | 2 +- .../channel-import-guardrails.test.ts | 63 +++++++++++++++++++ src/plugin-sdk/discord.ts | 16 ++--- src/plugin-sdk/imessage.ts | 2 +- src/plugin-sdk/signal.ts | 6 +- src/plugin-sdk/slack.ts | 2 +- src/plugin-sdk/telegram.ts | 8 +-- src/plugin-sdk/whatsapp.ts | 23 ++++--- src/plugins/runtime/runtime-telegram.ts | 2 +- src/plugins/runtime/types-channel.ts | 2 +- 19 files changed, 109 insertions(+), 39 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 6d6a61b5e2c..a723978bdc7 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -957,6 +957,16 @@ authoring plugins: - `openclaw/plugin-sdk/compat` remains as a legacy migration surface for older external plugins. Bundled plugins should not use it, and non-test imports emit a one-time deprecation warning outside test environments. +- Bundled extension internals remain private. External plugins should use only + `openclaw/plugin-sdk/*` subpaths. OpenClaw core/test code may use the repo + public seams under `extensions//index.js`, `api.js`, `runtime-api.js`, + `setup-entry.js`, and narrowly scoped files such as `login-qr-api.js`. Never + import `extensions//src/*` from core or from another extension. +- Repo seam split: + `extensions//api.js` is the helper/types barrel, + `extensions//runtime-api.js` is the runtime-only barrel, + `extensions//index.js` is the bundled plugin entry, + and `extensions//setup-entry.js` is the setup plugin entry. - `openclaw/plugin-sdk/telegram` for Telegram channel plugin types and shared channel-facing helpers. Built-in Telegram implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/discord` for Discord channel plugin types and shared channel-facing helpers. Built-in Discord implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/slack` for Slack channel plugin types and shared channel-facing helpers. Built-in Slack implementation internals stay private to the bundled extension. diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index 37235190586..858255c0495 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -1,4 +1,3 @@ -export * from "./runtime-api.js"; export * from "./src/account-inspect.js"; export * from "./src/accounts.js"; export * from "./src/actions/handle-action.guild-admin.js"; diff --git a/extensions/imessage/api.ts b/extensions/imessage/api.ts index ede4a8061ec..a311d13fec5 100644 --- a/extensions/imessage/api.ts +++ b/extensions/imessage/api.ts @@ -1,4 +1,3 @@ -export * from "./runtime-api.js"; export * from "./src/accounts.js"; export * from "./src/target-parsing-helpers.js"; export * from "./src/targets.js"; diff --git a/extensions/signal/api.ts b/extensions/signal/api.ts index f35c45c2b4e..feaaa1c5835 100644 --- a/extensions/signal/api.ts +++ b/extensions/signal/api.ts @@ -1,2 +1 @@ -export * from "./runtime-api.js"; export * from "./src/accounts.js"; diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts index 9264ee7c358..37aaf02b027 100644 --- a/extensions/slack/api.ts +++ b/extensions/slack/api.ts @@ -1,4 +1,3 @@ -export * from "./runtime-api.js"; export * from "./src/account-inspect.js"; export * from "./src/accounts.js"; export * from "./src/actions.js"; diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts index bb8b0907eca..d5960350c39 100644 --- a/extensions/telegram/api.ts +++ b/extensions/telegram/api.ts @@ -1,4 +1,3 @@ -export * from "./runtime-api.js"; export * from "./src/account-inspect.js"; export * from "./src/accounts.js"; export * from "./src/allow-from.js"; diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts index f35c45c2b4e..feaaa1c5835 100644 --- a/extensions/whatsapp/api.ts +++ b/extensions/whatsapp/api.ts @@ -1,2 +1 @@ -export * from "./runtime-api.js"; export * from "./src/accounts.js"; diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 2207023319d..9a831dde795 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -101,7 +101,7 @@ export function getWebSessionMocks(): AnyMocks { return webSessionMocks; } -vi.mock("../../extensions/whatsapp/api.js", () => webSessionMocks); +vi.mock("../../extensions/whatsapp/runtime-api.js", () => webSessionMocks); export const MAIN_SESSION_KEY = "agent:main:main"; diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index a343692622a..d651b6ef012 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -2,10 +2,10 @@ import { expect, vi } from "vitest"; import { __testing as discordThreadBindingTesting, createThreadBindingManager as createDiscordThreadBindingManager, -} from "../../../../extensions/discord/api.js"; +} from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; import { setMatrixRuntime } from "../../../../extensions/matrix/api.js"; -import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/api.js"; +import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { getSessionBindingService, diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 46bd08a8186..bfd2603bc0a 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -574,7 +574,7 @@ vi.mock("../commands/health.js", () => ({ vi.mock("../commands/status.js", () => ({ getStatusSummary: vi.fn().mockResolvedValue({ ok: true }), })); -vi.mock("../../extensions/whatsapp/api.js", () => ({ +vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ sendMessageWhatsApp: (...args: unknown[]) => (hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args), sendPollWhatsApp: (...args: unknown[]) => diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index b7a252987a5..7321adb1264 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -4,6 +4,36 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const ALLOWED_EXTENSION_PUBLIC_SEAMS = new Set([ + "api.js", + "index.js", + "login-qr-api.js", + "runtime-api.js", + "setup-entry.js", +]); +const GUARDED_CHANNEL_EXTENSIONS = new Set([ + "bluebubbles", + "discord", + "feishu", + "googlechat", + "imessage", + "irc", + "line", + "matrix", + "mattermost", + "msteams", + "nextcloud-talk", + "nostr", + "signal", + "slack", + "synology-chat", + "telegram", + "tlon", + "twitch", + "whatsapp", + "zalo", + "zalouser", +]); type GuardedSource = { path: string; @@ -186,6 +216,27 @@ function collectCoreSourceFiles(): string[] { return files; } +function collectExtensionImports(text: string): string[] { + return [...text.matchAll(/["']([^"']*extensions\/[^"']+\.(?:[cm]?[jt]sx?))["']/g)].map( + (match) => match[1] ?? "", + ); +} + +function expectOnlyApprovedExtensionSeams(file: string, imports: string[]): void { + for (const specifier of imports) { + const normalized = specifier.replaceAll("\\", "/"); + const extensionId = normalized.match(/extensions\/([^/]+)\//)?.[1] ?? null; + if (!extensionId || !GUARDED_CHANNEL_EXTENSIONS.has(extensionId)) { + continue; + } + const basename = normalized.split("/").at(-1) ?? ""; + expect( + ALLOWED_EXTENSION_PUBLIC_SEAMS.has(basename), + `${file} should only import approved extension seams, got ${specifier}`, + ).toBe(true); + } +} + describe("channel import guardrails", () => { it("keeps channel helper modules off their own SDK barrels", () => { for (const source of SAME_CHANNEL_SDK_GUARDS) { @@ -236,4 +287,16 @@ describe("channel import guardrails", () => { ); } }); + + it("keeps core extension imports limited to approved public seams", () => { + for (const file of collectCoreSourceFiles()) { + expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); + } + }); + + it("keeps extension-to-extension imports limited to approved public seams", () => { + for (const file of collectExtensionSourceFiles()) { + expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); + } + }); }); diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index d55a8157998..91bde97a5aa 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -13,7 +13,7 @@ export type { ThreadBindingManager, ThreadBindingRecord, ThreadBindingTargetKind, -} from "../../extensions/discord/api.js"; +} from "../../extensions/discord/runtime-api.js"; export type { ChannelConfiguredBindingProvider, ChannelConfiguredBindingConversationRef, @@ -75,20 +75,20 @@ export { normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "../../extensions/discord/api.js"; -export { collectDiscordAuditChannelIds } from "../../extensions/discord/api.js"; +export { collectDiscordAuditChannelIds } from "../../extensions/discord/runtime-api.js"; export { collectDiscordStatusIssues } from "../../extensions/discord/api.js"; export { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../../extensions/discord/api.js"; +} from "../../extensions/discord/runtime-api.js"; export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/api.js"; export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../extensions/discord/api.js"; -export { getGateway } from "../../extensions/discord/api.js"; -export { getPresence } from "../../extensions/discord/api.js"; +} from "../../extensions/discord/runtime-api.js"; +export { getGateway } from "../../extensions/discord/runtime-api.js"; +export { getPresence } from "../../extensions/discord/runtime-api.js"; export { readDiscordComponentSpec } from "../../extensions/discord/api.js"; export { resolveDiscordChannelId } from "../../extensions/discord/api.js"; export { @@ -134,5 +134,5 @@ export { unpinMessageDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../extensions/discord/api.js"; -export { discordMessageActions } from "../../extensions/discord/api.js"; +} from "../../extensions/discord/runtime-api.js"; +export { discordMessageActions } from "../../extensions/discord/runtime-api.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index ec769552348..adad1403eb6 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -42,4 +42,4 @@ export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { collectStatusIssuesFromLastError } from "./status-helpers.js"; -export { sendMessageIMessage } from "../../extensions/imessage/api.js"; +export { sendMessageIMessage } from "../../extensions/imessage/runtime-api.js"; diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index fda5ec6e7b9..f44dfa2f9ff 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -52,6 +52,6 @@ export { listSignalAccountIds, resolveDefaultSignalAccountId, } from "../../extensions/signal/api.js"; -export { resolveSignalReactionLevel } from "../../extensions/signal/api.js"; -export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/api.js"; -export { sendMessageSignal } from "../../extensions/signal/api.js"; +export { resolveSignalReactionLevel } from "../../extensions/signal/runtime-api.js"; +export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/runtime-api.js"; +export { sendMessageSignal } from "../../extensions/signal/runtime-api.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 50c08b51a2b..4b78d14480d 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -60,7 +60,7 @@ export { extractSlackToolSend, listSlackMessageActions } from "../../extensions/ export { buildSlackThreadingToolContext } from "../../extensions/slack/api.js"; export { parseSlackBlocksInput } from "../../extensions/slack/api.js"; export { handleSlackHttpRequest } from "../../extensions/slack/api.js"; -export { sendMessageSlack } from "../../extensions/slack/api.js"; +export { sendMessageSlack } from "../../extensions/slack/runtime-api.js"; export { deleteSlackMessage, downloadSlackFile, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index d4a35210e90..9a94e7c2d1c 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -19,7 +19,7 @@ export type { } from "../channels/plugins/types.adapters.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js"; export type { ResolvedTelegramAccount } from "../../extensions/telegram/api.js"; -export type { TelegramProbe } from "../../extensions/telegram/api.js"; +export type { TelegramProbe } from "../../extensions/telegram/runtime-api.js"; export type { TelegramButtonStyle, TelegramInlineButtons } from "../../extensions/telegram/api.js"; export type { StickerMetadata } from "../../extensions/telegram/api.js"; @@ -96,10 +96,10 @@ export { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../extensions/telegram/api.js"; +} from "../../extensions/telegram/runtime-api.js"; export { getCacheStats, searchStickers } from "../../extensions/telegram/api.js"; -export { resolveTelegramToken } from "../../extensions/telegram/api.js"; -export { telegramMessageActions } from "../../extensions/telegram/api.js"; +export { resolveTelegramToken } from "../../extensions/telegram/runtime-api.js"; +export { telegramMessageActions } from "../../extensions/telegram/runtime-api.js"; export { collectTelegramStatusIssues } from "../../extensions/telegram/api.js"; export { sendTelegramPayloadMessages } from "../../extensions/telegram/api.js"; export { diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index d613872cb33..74ab27dac2f 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,8 +1,11 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; -export type { WebChannelStatus, WebMonitorTuning } from "../../extensions/whatsapp/api.js"; -export type { WebInboundMessage, WebListenerCloseReason } from "../../extensions/whatsapp/api.js"; +export type { WebChannelStatus, WebMonitorTuning } from "../../extensions/whatsapp/runtime-api.js"; +export type { + WebInboundMessage, + WebListenerCloseReason, +} from "../../extensions/whatsapp/runtime-api.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -73,7 +76,7 @@ export { logoutWeb, pickWebChannel, webAuthExists, -} from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { DEFAULT_WEB_MEDIA_BYTES, HEARTBEAT_PROMPT, @@ -81,28 +84,28 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { extractMediaPlaceholder, extractText, monitorWebInbox, -} from "../../extensions/whatsapp/api.js"; -export { loginWeb } from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; +export { loginWeb } from "../../extensions/whatsapp/runtime-api.js"; export { getDefaultLocalRoots, loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg, -} from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp, -} from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; export { createWaSocket, formatError, getStatusCode, waitForWaConnection, -} from "../../extensions/whatsapp/api.js"; -export { createWhatsAppLoginTool } from "../../extensions/whatsapp/api.js"; +} from "../../extensions/whatsapp/runtime-api.js"; +export { createWhatsAppLoginTool } from "../../extensions/whatsapp/runtime-api.js"; diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index 42ce8d995a6..74b4de7e48e 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,4 +1,4 @@ -import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/api.js"; +import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/runtime-api.js"; import { telegramMessageActions } from "../../../extensions/telegram/runtime-api.js"; import { setTelegramThreadBindingIdleTimeoutBySessionKey, diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index d115a3a91e7..ee50b7dd02a 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -141,7 +141,7 @@ export type PluginRuntimeChannel = { }; telegram: { auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership; - collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/api.js").collectTelegramUnmentionedGroupIds; + collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/runtime-api.js").collectTelegramUnmentionedGroupIds; probeTelegram: typeof import("../../../extensions/telegram/runtime-api.js").probeTelegram; resolveTelegramToken: typeof import("../../../extensions/telegram/runtime-api.js").resolveTelegramToken; sendMessageTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram;