diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 8828a1854eb..9c5e794924a 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -4,15 +4,20 @@ import { toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import type { InspectedDiscordAccount } from "../../../src/channels/read-only-account-inspect.discord.runtime.js"; -import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js"; +import { inspectDiscordAccount } from "../api.js"; +import type { InspectedDiscordAccount } from "../api.js"; -export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "discord", +function inspectDiscordDirectoryAccount( + params: DirectoryConfigParams, +): InspectedDiscordAccount | null { + return inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedDiscordAccount | null; + }); +} + +export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = inspectDiscordDirectoryAccount(params); if (!account || !("config" in account)) { return []; } @@ -34,11 +39,7 @@ export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfi } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "discord", - cfg: params.cfg, - accountId: params.accountId, - })) as InspectedDiscordAccount | null; + const account = inspectDiscordDirectoryAccount(params); if (!account || !("config" in account)) { return []; } diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index 635222f9c2e..0bc0f49804e 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,3 +1,4 @@ +import { normalizeSlackMessagingTarget } from "openclaw/plugin-sdk/channel-runtime"; import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, @@ -5,16 +6,18 @@ import { toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { normalizeSlackMessagingTarget } from "../../../src/channels/plugins/normalize/slack.js"; -import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js"; -import type { InspectedSlackAccount } from "../../../src/channels/read-only-account-inspect.slack.runtime.js"; +import { inspectSlackAccount } from "../api.js"; +import type { InspectedSlackAccount } from "../api.js"; -export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "slack", +function inspectSlackDirectoryAccount(params: DirectoryConfigParams): InspectedSlackAccount | null { + return inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedSlackAccount | null; + }); +} + +export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = inspectSlackDirectoryAccount(params); if (!account || !("config" in account)) { return []; } @@ -40,11 +43,7 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "slack", - cfg: params.cfg, - accountId: params.accountId, - })) as InspectedSlackAccount | null; + const account = inspectSlackDirectoryAccount(params); if (!account || !("config" in account)) { return []; } diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 4a2e17f5455..55576d9e822 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,7 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; -import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; +import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js"; diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index 10abc88d784..3355b295cca 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -6,15 +6,20 @@ import { toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js"; -import type { InspectedTelegramAccount } from "../../../src/channels/read-only-account-inspect.telegram.runtime.js"; +import { inspectTelegramAccount } from "../api.js"; +import type { InspectedTelegramAccount } from "../api.js"; -export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "telegram", +async function inspectTelegramDirectoryAccount( + params: DirectoryConfigParams, +): Promise { + return inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedTelegramAccount | null; + }); +} + +export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = await inspectTelegramDirectoryAccount(params); if (!account || !("config" in account)) { return []; } @@ -36,11 +41,7 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "telegram", - cfg: params.cfg, - accountId: params.accountId, - })) as InspectedTelegramAccount | null; + const account = await inspectTelegramDirectoryAccount(params); if (!account || !("config" in account)) { return []; } diff --git a/extensions/whatsapp/src/directory-config.ts b/extensions/whatsapp/src/directory-config.ts index ad7b7d257e7..1a5fbbff9b0 100644 --- a/extensions/whatsapp/src/directory-config.ts +++ b/extensions/whatsapp/src/directory-config.ts @@ -3,8 +3,8 @@ import { listDirectoryUserEntriesFromAllowFrom, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; import { resolveWhatsAppAccount } from "./accounts.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConfigParams) { const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts index bfecb31e4a5..d0506cd5883 100644 --- a/extensions/whatsapp/src/normalize.ts +++ b/extensions/whatsapp/src/normalize.ts @@ -1,5 +1,7 @@ export { + isWhatsAppGroupJid, looksLikeWhatsAppTargetId, normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, + normalizeWhatsAppTarget, } from "openclaw/plugin-sdk/channel-runtime"; diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 69626948743..a4ca46a569c 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -252,7 +252,10 @@ function collectCoreSourceFiles(): string[] { fullPath.includes(".test.") || fullPath.includes(".spec.") || fullPath.includes(".fixture.") || - fullPath.includes(".snap") + fullPath.includes(".snap") || + // src/plugin-sdk is the curated bridge layer; validate its contracts with dedicated + // plugin-sdk guardrails instead of the generic "core should not touch extensions" rule. + fullPath.includes(`${resolve(ROOT_DIR, "plugin-sdk")}/`) ) { continue; } diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 5e90b196c09..59832d70f80 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -43,6 +43,7 @@ export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; export * from "../polls.js"; export * from "../utils/message-channel.js"; +export * from "../whatsapp/normalize.js"; export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js"; export * from "./channel-lifecycle.js"; export * from "./directory-runtime.js"; diff --git a/src/plugin-sdk/package-contract-guardrails.test.ts b/src/plugin-sdk/package-contract-guardrails.test.ts new file mode 100644 index 00000000000..046562708cd --- /dev/null +++ b/src/plugin-sdk/package-contract-guardrails.test.ts @@ -0,0 +1,145 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { pluginSdkEntrypoints } from "./entrypoints.js"; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const REPO_ROOT = resolve(ROOT_DIR, ".."); +const REFERENCE_SCAN_ROOTS = ["src", "extensions", "scripts", "test", "docs"] as const; +const PLUGIN_SDK_SUBPATH_PATTERN = /openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)\b/g; + +function collectPluginSdkPackageExports(): string[] { + const packageJson = JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf8")) as { + exports?: Record; + }; + const exports = packageJson.exports ?? {}; + const subpaths: string[] = []; + for (const key of Object.keys(exports)) { + if (key === "./plugin-sdk") { + subpaths.push("index"); + continue; + } + if (!key.startsWith("./plugin-sdk/")) { + continue; + } + subpaths.push(key.slice("./plugin-sdk/".length)); + } + return subpaths.sort(); +} + +function collectPluginSdkSourceNames(): string[] { + const pluginSdkDir = resolve(REPO_ROOT, "src", "plugin-sdk"); + return readdirSync(pluginSdkDir, { withFileTypes: true }) + .filter( + (entry) => entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts"), + ) + .map((entry) => entry.name.slice(0, -".ts".length)) + .sort(); +} + +function collectTextFiles(rootRelativeDir: string): string[] { + const rootDir = resolve(REPO_ROOT, rootRelativeDir); + const files: string[] = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { + continue; + } + stack.push(fullPath); + continue; + } + if (!entry.isFile()) { + continue; + } + if ( + /\.(?:[cm]?ts|[cm]?js|tsx|jsx|md|mdx|json)$/u.test(entry.name) && + !entry.name.endsWith(".snap") + ) { + files.push(fullPath); + } + } + } + return files; +} + +function collectPluginSdkSubpathReferences() { + const references: Array<{ file: string; subpath: string }> = []; + for (const rootRelativeDir of REFERENCE_SCAN_ROOTS) { + for (const fullPath of collectTextFiles(rootRelativeDir)) { + const source = readFileSync(fullPath, "utf8"); + for (const match of source.matchAll(PLUGIN_SDK_SUBPATH_PATTERN)) { + const subpath = match[1]; + if (!subpath) { + continue; + } + references.push({ + file: relative(REPO_ROOT, fullPath).replaceAll("\\", "/"), + subpath, + }); + } + } + } + return references; +} + +describe("plugin-sdk package contract guardrails", () => { + it("keeps package.json exports aligned with built plugin-sdk entrypoints", () => { + expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].sort()); + }); + + it("keeps repo openclaw/plugin-sdk/ references on exported built subpaths", () => { + const entrypoints = new Set(pluginSdkEntrypoints); + const exports = new Set(collectPluginSdkPackageExports()); + const failures: string[] = []; + + for (const reference of collectPluginSdkSubpathReferences()) { + const missingFrom: string[] = []; + if (!entrypoints.has(reference.subpath)) { + missingFrom.push("scripts/lib/plugin-sdk-entrypoints.json"); + } + if (!exports.has(reference.subpath)) { + missingFrom.push("package.json exports"); + } + if (missingFrom.length === 0) { + continue; + } + failures.push( + `${reference.file} references openclaw/plugin-sdk/${reference.subpath}, but ${reference.subpath} is missing from ${missingFrom.join(" and ")}`, + ); + } + + expect(failures).toEqual([]); + }); + + it("does not leave referenced src/plugin-sdk source names stranded outside the public contract", () => { + const exported = new Set(pluginSdkEntrypoints); + const references = collectPluginSdkSubpathReferences(); + const failures: string[] = []; + + for (const sourceName of collectPluginSdkSourceNames()) { + if (exported.has(sourceName) || sourceName === "compat" || sourceName === "index") { + continue; + } + const matchingRefs = references.filter((reference) => reference.subpath === sourceName); + if (matchingRefs.length === 0) { + continue; + } + failures.push( + `src/plugin-sdk/${sourceName}.ts is referenced as openclaw/plugin-sdk/${sourceName} in ${matchingRefs + .map((reference) => reference.file) + .sort() + .join(", ")}, but ${sourceName} is not exported as a public plugin-sdk subpath`, + ); + } + + expect(failures).toEqual([]); + }); +}); diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 1b29d1570c6..b05bdf482f7 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -27,15 +27,25 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/send.js";', ], "extensions/imessage/runtime-api.ts": [ - 'export * from "./src/monitor.js";', - 'export * from "./src/probe.js";', - 'export * from "./src/send.js";', + 'export type { IMessageAccountConfig } from "../../src/config/types.imessage.js";', + 'export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js";', + 'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, getChatChannelMeta } from "../../src/plugin-sdk/channel-plugin-common.js";', + 'export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo } from "../../src/plugin-sdk/channel-config-helpers.js";', + 'export { collectStatusIssuesFromLastError } from "../../src/plugin-sdk/status-helpers.js";', + 'export { resolveChannelMediaMaxBytes } from "../../src/channels/plugins/media-limits.js";', + 'export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "../../src/channels/plugins/normalize/imessage.js";', + 'export { IMessageConfigSchema } from "../../src/config/zod-schema.providers-core.js";', + 'export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy } from "./src/group-policy.js";', + 'export { monitorIMessageProvider } from "./src/monitor.js";', + 'export type { MonitorIMessageOpts } from "./src/monitor.js";', + 'export { probeIMessage } from "./src/probe.js";', + 'export { sendMessageIMessage } from "./src/send.js";', ], "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], "extensions/nextcloud-talk/runtime-api.ts": [ 'export * from "openclaw/plugin-sdk/nextcloud-talk";', ], - "extensions/signal/runtime-api.ts": ['export * from "./src/index.js";'], + "extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'], "extensions/slack/runtime-api.ts": [ 'export * from "./src/action-runtime.js";', 'export * from "./src/directory-live.js";', @@ -44,14 +54,21 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/resolve-users.js";', ], "extensions/telegram/runtime-api.ts": [ - 'export * from "./src/audit.js";', - 'export * from "./src/action-runtime.js";', - 'export * from "./src/channel-actions.js";', - 'export * from "./src/monitor.js";', - 'export * from "./src/probe.js";', - 'export * from "./src/send.js";', - 'export * from "./src/thread-bindings.js";', - 'export * from "./src/token.js";', + 'export type { ChannelPlugin, OpenClawConfig, TelegramActionConfig } from "../../src/plugin-sdk/telegram-core.js";', + 'export type { ChannelMessageActionAdapter } from "../../src/channels/plugins/types.js";', + 'export type { TelegramAccountConfig, TelegramNetworkConfig } from "../../src/config/types.js";', + 'export type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "../../src/plugins/types.js";', + 'export type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeDoctorReport, AcpRuntimeEnsureInput, AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, AcpSessionUpdateTag } from "../../src/acp/runtime/types.js";', + 'export type { AcpRuntimeErrorCode } from "../../src/acp/runtime/errors.js";', + 'export { AcpRuntimeError } from "../../src/acp/runtime/errors.js";', + 'export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../src/routing/session-key.js";', + 'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "../../src/plugin-sdk/telegram-core.js";', + 'export { parseTelegramTopicConversation } from "../../src/acp/conversation-id.js";', + 'export { clearAccountEntryFields } from "../../src/channels/plugins/config-helpers.js";', + 'export { buildTokenChannelStatusSummary } from "../../src/plugin-sdk/status-helpers.js";', + 'export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses } from "../../src/channels/account-snapshot-fields.js";', + 'export { resolveTelegramPollVisibility } from "../../src/poll-params.js";', + 'export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js";', ], "extensions/whatsapp/runtime-api.ts": [ 'export * from "./src/active-listener.js";',