From 8cb85ff85f29430d5c5ca88f0998ee1d68949022 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 00:15:29 +0100 Subject: [PATCH] refactor: harden plugin metadata and bundled channel entry seams --- extensions/bluebubbles/src/monitor.ts | 2 +- extensions/discord/channel-plugin-api.ts | 4 + extensions/discord/index.test.ts | 15 ++ extensions/discord/index.ts | 6 +- extensions/discord/setup-entry.ts | 2 +- extensions/discord/subagent-hooks-api.ts | 7 + .../src/monitor-transport-runtime-api.ts | 2 +- extensions/mattermost/runtime-api.ts | 2 +- .../mattermost/src/mattermost/interactions.ts | 2 +- .../mattermost/src/mattermost/slash-http.ts | 2 +- extensions/synology-chat/src/security.ts | 2 +- extensions/telegram/src/webhook.ts | 2 +- extensions/voice-call/src/providers/twilio.ts | 2 +- extensions/voice-call/src/webhook-security.ts | 2 +- extensions/whatsapp/channel-plugin-api.ts | 4 + extensions/whatsapp/index.test.ts | 15 ++ extensions/whatsapp/index.ts | 2 +- extensions/whatsapp/package.json | 6 +- extensions/whatsapp/setup-entry.ts | 2 +- extensions/zalo/runtime-api.test.ts | 33 +++- extensions/zalo/runtime-api.ts | 92 +-------- extensions/zalo/src/monitor.webhook.ts | 2 +- extensions/zalo/src/runtime-api.ts | 6 +- extensions/zalo/src/runtime-support.ts | 90 +++++++++ extensions/zalouser/runtime-api.ts | 2 +- src/channels/config-presence.ts | 16 +- src/channels/plugins/bootstrap-registry.ts | 18 +- src/channels/plugins/legacy-config.ts | 8 +- src/channels/plugins/persisted-auth-state.ts | 178 ++++++++++++++++++ src/commands/doctor-state-integrity.ts | 7 +- .../shared/channel-legacy-config-migrate.ts | 4 +- src/config/channel-configured.ts | 3 +- src/infra/outbound/message-action-spec.ts | 14 +- src/infra/state-migrations.ts | 15 +- src/plugins/manifest.ts | 4 + .../runtime/metadata-registry-loader.test.ts | 81 ++++++++ .../runtime/metadata-registry-loader.ts | 3 + src/plugins/status.test.ts | 88 ++++++--- src/plugins/status.ts | 31 +-- .../runtime-config-collectors-channels.ts | 4 +- src/secrets/target-registry-data.ts | 10 +- src/secrets/unsupported-surface-policy.ts | 12 +- src/sessions/session-chat-type.ts | 4 +- 43 files changed, 607 insertions(+), 199 deletions(-) create mode 100644 extensions/discord/channel-plugin-api.ts create mode 100644 extensions/discord/index.test.ts create mode 100644 extensions/discord/subagent-hooks-api.ts create mode 100644 extensions/whatsapp/channel-plugin-api.ts create mode 100644 extensions/whatsapp/index.test.ts create mode 100644 extensions/zalo/src/runtime-support.ts create mode 100644 src/channels/plugins/persisted-auth-state.ts create mode 100644 src/plugins/runtime/metadata-registry-loader.test.ts diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 61105098df8..195881abd40 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js"; import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; diff --git a/extensions/discord/channel-plugin-api.ts b/extensions/discord/channel-plugin-api.ts new file mode 100644 index 00000000000..bb271efb17e --- /dev/null +++ b/extensions/discord/channel-plugin-api.ts @@ -0,0 +1,4 @@ +// Keep bundled channel entry imports narrow so bootstrap/discovery paths do +// not drag the broad Discord API barrel into lightweight plugin loads. +export { discordPlugin } from "./src/channel.js"; +export { discordSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/discord/index.test.ts b/extensions/discord/index.test.ts new file mode 100644 index 00000000000..f39de0e0784 --- /dev/null +++ b/extensions/discord/index.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import entry from "./index.js"; +import setupEntry from "./setup-entry.js"; + +describe("discord bundled entries", () => { + it("loads the channel plugin without importing the broad api barrel", () => { + const plugin = entry.loadChannelPlugin(); + expect(plugin.id).toBe("discord"); + }); + + it("loads the setup plugin without importing the broad api barrel", () => { + const plugin = setupEntry.loadSetupPlugin(); + expect(plugin.id).toBe("discord"); + }); +}); diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index a0bf0d5b2c0..aa5429e4b84 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,11 +1,11 @@ import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract"; -type DiscordSubagentHooksModule = typeof import("./api.js"); +type DiscordSubagentHooksModule = typeof import("./subagent-hooks-api.js"); let discordSubagentHooksPromise: Promise | null = null; function loadDiscordSubagentHooksModule() { - discordSubagentHooksPromise ??= import("./api.js"); + discordSubagentHooksPromise ??= import("./subagent-hooks-api.js"); return discordSubagentHooksPromise; } @@ -15,7 +15,7 @@ export default defineBundledChannelEntry({ description: "Discord channel plugin", importMetaUrl: import.meta.url, plugin: { - specifier: "./api.js", + specifier: "./channel-plugin-api.js", exportName: "discordPlugin", }, runtime: { diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index 138c98531b3..59209126655 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, plugin: { - specifier: "./api.js", + specifier: "./channel-plugin-api.js", exportName: "discordSetupPlugin", }, }); diff --git a/extensions/discord/subagent-hooks-api.ts b/extensions/discord/subagent-hooks-api.ts new file mode 100644 index 00000000000..5bc81f666c6 --- /dev/null +++ b/extensions/discord/subagent-hooks-api.ts @@ -0,0 +1,7 @@ +// Subagent hooks live behind a dedicated barrel so the bundled entry can lazy +// load only the handlers it needs. +export { + handleDiscordSubagentDeliveryTarget, + handleDiscordSubagentEnded, + handleDiscordSubagentSpawning, +} from "./src/subagent-hooks.js"; diff --git a/extensions/feishu/src/monitor-transport-runtime-api.ts b/extensions/feishu/src/monitor-transport-runtime-api.ts index 33788de8155..973f1f951b9 100644 --- a/extensions/feishu/src/monitor-transport-runtime-api.ts +++ b/extensions/feishu/src/monitor-transport-runtime-api.ts @@ -1,5 +1,5 @@ export type { RuntimeEnv } from "../runtime-api.js"; -export { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +export { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; export { applyBasicWebhookRequestGuards, isRequestBodyLimitError, diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index 6e04c855055..d232769bd1c 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -61,7 +61,7 @@ export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-ac export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; -export { rawDataToString } from "openclaw/plugin-sdk/browser-support"; +export { rawDataToString } from "openclaw/plugin-sdk/browser-node-runtime"; export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; export { DEFAULT_GROUP_HISTORY_LIMIT, diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index 1ede1174be3..ac688ec479c 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -1,6 +1,6 @@ import { createHmac } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import { getMattermostRuntime } from "../runtime.js"; import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js"; import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "./runtime-api.js"; diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index b3c9349692b..dda20fd242c 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -6,7 +6,7 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; -import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; diff --git a/extensions/synology-chat/src/security.ts b/extensions/synology-chat/src/security.ts index 4b65ea8c273..9d6e46787f3 100644 --- a/extensions/synology-chat/src/security.ts +++ b/extensions/synology-chat/src/security.ts @@ -2,7 +2,7 @@ * Security module: token validation, rate limiting, input sanitization, user allowlist. */ -import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import { createFixedWindowRateLimiter, type FixedWindowRateLimiter, diff --git a/extensions/telegram/src/webhook.ts b/extensions/telegram/src/webhook.ts index 034821714eb..ee6283f2690 100644 --- a/extensions/telegram/src/webhook.ts +++ b/extensions/telegram/src/webhook.ts @@ -2,7 +2,7 @@ import { createServer } from "node:http"; import type { IncomingMessage } from "node:http"; import net from "node:net"; import * as grammy from "grammy"; -import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDiagnosticsEnabled } from "openclaw/plugin-sdk/diagnostic-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index b7e9064ff07..9e7b87e852c 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import type { TwilioConfig, WebhookSecurityConfig } from "../config.js"; import { getHeader } from "../http-headers.js"; import type { MediaStreamHandler } from "../media-stream.js"; diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 02b92d6a975..023d393fea4 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import { getHeader } from "./http-headers.js"; import type { WebhookContext } from "./types.js"; diff --git a/extensions/whatsapp/channel-plugin-api.ts b/extensions/whatsapp/channel-plugin-api.ts new file mode 100644 index 00000000000..e5e34a75e41 --- /dev/null +++ b/extensions/whatsapp/channel-plugin-api.ts @@ -0,0 +1,4 @@ +// Keep bundled channel bootstrap loads narrow so lightweight config-presence +// probes do not import the broad WhatsApp API barrel. +export { whatsappPlugin } from "./src/channel.js"; +export { whatsappSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/whatsapp/index.test.ts b/extensions/whatsapp/index.test.ts new file mode 100644 index 00000000000..1f861347d6a --- /dev/null +++ b/extensions/whatsapp/index.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import entry from "./index.js"; +import setupEntry from "./setup-entry.js"; + +describe("whatsapp bundled entries", () => { + it("loads the channel plugin without importing the broad api barrel", () => { + const plugin = entry.loadChannelPlugin(); + expect(plugin.id).toBe("whatsapp"); + }); + + it("loads the setup plugin without importing the broad api barrel", () => { + const plugin = setupEntry.loadSetupPlugin(); + expect(plugin.id).toBe("whatsapp"); + }); +}); diff --git a/extensions/whatsapp/index.ts b/extensions/whatsapp/index.ts index 360861c94c8..ecb23b6ff8e 100644 --- a/extensions/whatsapp/index.ts +++ b/extensions/whatsapp/index.ts @@ -6,7 +6,7 @@ export default defineBundledChannelEntry({ description: "WhatsApp channel plugin", importMetaUrl: import.meta.url, plugin: { - specifier: "./api.js", + specifier: "./channel-plugin-api.js", exportName: "whatsappPlugin", }, runtime: { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index b30d18434b8..ecd84c0a26b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -31,7 +31,11 @@ "docsPath": "/channels/whatsapp", "docsLabel": "whatsapp", "blurb": "works with your own number; recommend a separate phone + eSIM.", - "systemImage": "message" + "systemImage": "message", + "persistedAuthState": { + "specifier": "./auth-presence", + "exportName": "hasAnyWhatsAppAuth" + } }, "install": { "npmSpec": "@openclaw/whatsapp", diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index b6bfcff52ed..f17f12f3486 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, plugin: { - specifier: "./api.js", + specifier: "./channel-plugin-api.js", exportName: "whatsappSetupPlugin", }, }); diff --git a/extensions/zalo/runtime-api.test.ts b/extensions/zalo/runtime-api.test.ts index 3bc8548fa4c..30d042e477c 100644 --- a/extensions/zalo/runtime-api.test.ts +++ b/extensions/zalo/runtime-api.test.ts @@ -1,9 +1,36 @@ +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; + describe("zalo runtime api", () => { it("exports the channel plugin without reentering setup surfaces", async () => { - const runtimeApi = await import("./runtime-api.js"); + 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, + }, + ); - expect(runtimeApi.zaloPlugin.id).toBe("zalo"); - }); + expect(stdout).toBe("zalo"); + }, 45_000); }); diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index 0182da617c7..b6dda1888f1 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -2,94 +2,4 @@ // 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"; -export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-runtime"; -export type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; -export type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; -export type { - BaseProbeResult, - ChannelAccountSnapshot, - ChannelMessageActionAdapter, - ChannelMessageActionName, - ChannelStatusIssue, -} from "openclaw/plugin-sdk/channel-contract"; -export type { SecretInput } from "openclaw/plugin-sdk/secret-input"; -export type { SenderGroupAccessDecision } from "openclaw/plugin-sdk/group-access"; -export type { ChannelPlugin, PluginRuntime, WizardPrompter } from "openclaw/plugin-sdk/core"; -export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; -export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload"; -export { - DEFAULT_ACCOUNT_ID, - buildChannelConfigSchema, - createDedupeCache, - formatPairingApproveHint, - jsonResult, - normalizeAccountId, - readStringParam, - resolveClientIp, -} from "openclaw/plugin-sdk/core"; -export { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - buildSingleChannelSecretPromptState, - mergeAllowFromEntries, - migrateBaseNameToDefaultAccount, - promptSingleChannelSecretInput, - runSingleChannelSecretStep, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/setup"; -export { - buildSecretInputSchema, - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/secret-input"; -export { - buildTokenChannelStatusSummary, - PAIRING_APPROVED_MESSAGE, -} from "openclaw/plugin-sdk/channel-status"; -export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers"; -export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; -export { - formatAllowFromLowercase, - isNormalizedSenderAllowed, -} from "openclaw/plugin-sdk/allow-from"; -export { addWildcardAllowFrom } from "openclaw/plugin-sdk/setup"; -export { evaluateSenderGroupAccess } from "openclaw/plugin-sdk/group-access"; -export { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; -export { - warnMissingProviderGroupPolicyFallbackOnce, - resolveDefaultGroupPolicy, -} from "openclaw/plugin-sdk/config-runtime"; -export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; -export { - deliverTextOrMediaReply, - isNumericTargetId, - sendPayloadWithChunkedTextAndMedia, -} from "openclaw/plugin-sdk/reply-payload"; -export { - resolveDirectDmAuthorizationOutcome, - resolveSenderCommandAuthorizationWithRuntime, -} from "openclaw/plugin-sdk/command-auth"; -export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; -export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime"; -export { - applyBasicWebhookRequestGuards, - createFixedWindowRateLimiter, - createWebhookAnomalyTracker, - readJsonWebhookBodyOrReject, - registerWebhookTarget, - registerWebhookTargetWithPluginRoute, - resolveWebhookPath, - resolveWebhookTargetWithAuthOrRejectSync, - WEBHOOK_ANOMALY_COUNTER_DEFAULTS, - WEBHOOK_RATE_LIMIT_DEFAULTS, - withResolvedWebhookRequestPipeline, -} from "openclaw/plugin-sdk/webhook-ingress"; -export type { - RegisterWebhookPluginRouteOptions, - RegisterWebhookTargetOptions, -} from "openclaw/plugin-sdk/webhook-ingress"; -export { setZaloRuntime } from "./src/runtime.js"; +export * from "./src/runtime-api.js"; diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts index d95522586e1..1b602c6a55f 100644 --- a/extensions/zalo/src/monitor.webhook.ts +++ b/extensions/zalo/src/monitor.webhook.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import type { ResolvedZaloAccount } from "./accounts.js"; import type { ZaloFetch, ZaloUpdate } from "./api.js"; import type { ZaloRuntimeEnv } from "./monitor.js"; diff --git a/extensions/zalo/src/runtime-api.ts b/extensions/zalo/src/runtime-api.ts index ece735819df..3513e9690ec 100644 --- a/extensions/zalo/src/runtime-api.ts +++ b/extensions/zalo/src/runtime-api.ts @@ -1 +1,5 @@ -export * from "../runtime-api.js"; +// Internal runtime barrel. Keep this independent from the public top-level +// runtime barrel so local imports do not loop back through the plugin export +// surface during entry loading. +export * from "./runtime-support.js"; +export { setZaloRuntime } from "./runtime.js"; diff --git a/extensions/zalo/src/runtime-support.ts b/extensions/zalo/src/runtime-support.ts new file mode 100644 index 00000000000..41e4aae940d --- /dev/null +++ b/extensions/zalo/src/runtime-support.ts @@ -0,0 +1,90 @@ +export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +export type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +export type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; +export type { + BaseProbeResult, + ChannelAccountSnapshot, + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; +export type { SecretInput } from "openclaw/plugin-sdk/secret-input"; +export type { SenderGroupAccessDecision } from "openclaw/plugin-sdk/group-access"; +export type { ChannelPlugin, PluginRuntime, WizardPrompter } from "openclaw/plugin-sdk/core"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + createDedupeCache, + formatPairingApproveHint, + jsonResult, + normalizeAccountId, + readStringParam, + resolveClientIp, +} from "openclaw/plugin-sdk/core"; +export { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + buildSingleChannelSecretPromptState, + mergeAllowFromEntries, + migrateBaseNameToDefaultAccount, + promptSingleChannelSecretInput, + runSingleChannelSecretStep, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "openclaw/plugin-sdk/setup"; +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; +export { + buildTokenChannelStatusSummary, + PAIRING_APPROVED_MESSAGE, +} from "openclaw/plugin-sdk/channel-status"; +export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +export { + formatAllowFromLowercase, + isNormalizedSenderAllowed, +} from "openclaw/plugin-sdk/allow-from"; +export { addWildcardAllowFrom } from "openclaw/plugin-sdk/setup"; +export { evaluateSenderGroupAccess } from "openclaw/plugin-sdk/group-access"; +export { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +export { + warnMissingProviderGroupPolicyFallbackOnce, + resolveDefaultGroupPolicy, +} from "openclaw/plugin-sdk/config-runtime"; +export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; +export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; +export { + deliverTextOrMediaReply, + isNumericTargetId, + sendPayloadWithChunkedTextAndMedia, +} from "openclaw/plugin-sdk/reply-payload"; +export { + resolveDirectDmAuthorizationOutcome, + resolveSenderCommandAuthorizationWithRuntime, +} from "openclaw/plugin-sdk/command-auth"; +export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; +export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime"; +export { + applyBasicWebhookRequestGuards, + createFixedWindowRateLimiter, + createWebhookAnomalyTracker, + readJsonWebhookBodyOrReject, + registerWebhookTarget, + registerWebhookTargetWithPluginRoute, + resolveWebhookPath, + resolveWebhookTargetWithAuthOrRejectSync, + WEBHOOK_ANOMALY_COUNTER_DEFAULTS, + WEBHOOK_RATE_LIMIT_DEFAULTS, + withResolvedWebhookRequestPipeline, +} from "openclaw/plugin-sdk/webhook-ingress"; +export type { + RegisterWebhookPluginRouteOptions, + RegisterWebhookTargetOptions, +} from "openclaw/plugin-sdk/webhook-ingress"; diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index 63759649519..a0bd7942848 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -58,4 +58,4 @@ export { sendPayloadWithChunkedTextAndMedia, type OutboundReplyPayload, } from "openclaw/plugin-sdk/reply-payload"; -export { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/browser-support"; +export { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/browser-security-runtime"; diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 5a337783250..bc36c1ddc61 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import os from "node:os"; +import { + hasBundledChannelPersistedAuthState, + listBundledChannelIdsWithPersistedAuthState, +} from "../channels/plugins/persisted-auth-state.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; -import { getBootstrapChannelPlugin } from "./plugins/bootstrap-registry.js"; import { listBundledChannelPluginIds } from "./plugins/bundled-ids.js"; const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); @@ -39,6 +42,8 @@ function hasPersistedChannelState(env: NodeJS.ProcessEnv): boolean { return fs.existsSync(resolveStateDir(env, os.homedir)); } +const PERSISTED_AUTH_STATE_CHANNEL_IDS = listBundledChannelIdsWithPersistedAuthState(); + export function listPotentialConfiguredChannelIds( cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env, @@ -71,9 +76,8 @@ export function listPotentialConfiguredChannelIds( } if (options.includePersistedAuthState !== false && hasPersistedChannelState(env)) { - for (const channelId of channelIds) { - const plugin = getBootstrapChannelPlugin(channelId); - if (plugin?.config?.hasPersistedAuthState?.({ cfg, env })) { + for (const channelId of PERSISTED_AUTH_STATE_CHANNEL_IDS) { + if (hasBundledChannelPersistedAuthState({ channelId, cfg, env })) { configuredChannelIds.add(channelId); } } @@ -100,8 +104,8 @@ function hasEnvConfiguredChannel( if (options.includePersistedAuthState === false || !hasPersistedChannelState(env)) { return false; } - return channelIds.some((channelId) => - Boolean(getBootstrapChannelPlugin(channelId)?.config?.hasPersistedAuthState?.({ cfg, env })), + return PERSISTED_AUTH_STATE_CHANNEL_IDS.some((channelId) => + hasBundledChannelPersistedAuthState({ channelId, cfg, env }), ); } diff --git a/src/channels/plugins/bootstrap-registry.ts b/src/channels/plugins/bootstrap-registry.ts index 5d7cd51f49b..676d693f361 100644 --- a/src/channels/plugins/bootstrap-registry.ts +++ b/src/channels/plugins/bootstrap-registry.ts @@ -61,11 +61,21 @@ function getBootstrapPlugins(): CachedBootstrapPlugins { return cachedBootstrapPlugins; } -export function listBootstrapChannelPlugins(): readonly ChannelPlugin[] { - return getBootstrapPlugins().sortedIds.flatMap((id) => { +export function listBootstrapChannelPluginIds(): readonly string[] { + return getBootstrapPlugins().sortedIds; +} + +export function* iterateBootstrapChannelPlugins(): IterableIterator { + for (const id of listBootstrapChannelPluginIds()) { const plugin = getBootstrapChannelPlugin(id); - return plugin ? [plugin] : []; - }); + if (plugin) { + yield plugin; + } + } +} + +export function listBootstrapChannelPlugins(): readonly ChannelPlugin[] { + return [...iterateBootstrapChannelPlugins()]; } export function getBootstrapChannelPlugin(id: ChannelId): ChannelPlugin | undefined { diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts index 3d13762a560..bdbb850ecee 100644 --- a/src/channels/plugins/legacy-config.ts +++ b/src/channels/plugins/legacy-config.ts @@ -1,6 +1,10 @@ import type { LegacyConfigRule } from "../../config/legacy.shared.js"; -import { listBootstrapChannelPlugins } from "./bootstrap-registry.js"; +import { iterateBootstrapChannelPlugins } from "./bootstrap-registry.js"; export function collectChannelLegacyConfigRules(): LegacyConfigRule[] { - return listBootstrapChannelPlugins().flatMap((plugin) => plugin.doctor?.legacyConfigRules ?? []); + const rules: LegacyConfigRule[] = []; + for (const plugin of iterateBootstrapChannelPlugins()) { + rules.push(...(plugin.doctor?.legacyConfigRules ?? [])); + } + return rules; } diff --git a/src/channels/plugins/persisted-auth-state.ts b/src/channels/plugins/persisted-auth-state.ts new file mode 100644 index 00000000000..50f991df3da --- /dev/null +++ b/src/channels/plugins/persisted-auth-state.ts @@ -0,0 +1,178 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { createJiti } from "jiti"; +import type { OpenClawConfig } from "../../config/config.js"; +import { openBoundaryFileSync } from "../../infra/boundary-file-read.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + listChannelCatalogEntries, + type PluginChannelCatalogEntry, +} from "../../plugins/channel-catalog-registry.js"; +import { + buildPluginLoaderAliasMap, + buildPluginLoaderJitiOptions, + shouldPreferNativeJiti, +} from "../../plugins/sdk-alias.js"; + +type PersistedAuthStateChecker = (params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}) => boolean; + +type PersistedAuthStateMetadata = { + specifier?: string; + exportName?: string; +}; + +const log = createSubsystemLogger("channels"); +const nodeRequire = createRequire(import.meta.url); +const persistedAuthStateCatalog = listChannelCatalogEntries({ origin: "bundled" }).filter((entry) => + Boolean(resolvePersistedAuthStateMetadata(entry)), +); +const persistedAuthStateEntriesById = new Map( + persistedAuthStateCatalog.map((entry) => [entry.pluginId, entry] as const), +); +const persistedAuthStateCheckerCache = new Map(); + +function resolvePersistedAuthStateMetadata( + entry: PluginChannelCatalogEntry, +): PersistedAuthStateMetadata | null { + const metadata = entry.channel.persistedAuthState; + if (!metadata || typeof metadata !== "object") { + return null; + } + const specifier = typeof metadata.specifier === "string" ? metadata.specifier.trim() : ""; + const exportName = typeof metadata.exportName === "string" ? metadata.exportName.trim() : ""; + if (!specifier || !exportName) { + return null; + } + return { specifier, exportName }; +} + +function createModuleLoader() { + const jitiLoaders = new Map>(); + + return (modulePath: string) => { + const tryNative = + shouldPreferNativeJiti(modulePath) || modulePath.includes(`${path.sep}dist${path.sep}`); + const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url); + const cacheKey = JSON.stringify({ + tryNative, + aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)), + }); + const cached = jitiLoaders.get(cacheKey); + if (cached) { + return cached; + } + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + tryNative, + }); + jitiLoaders.set(cacheKey, loader); + return loader; + }; +} + +const loadModule = createModuleLoader(); + +function resolveModuleCandidates(entry: PluginChannelCatalogEntry, specifier: string): string[] { + const normalizedSpecifier = specifier.replace(/\\/g, "/"); + const resolvedPath = path.resolve(entry.rootDir, normalizedSpecifier); + const ext = path.extname(resolvedPath); + if (ext) { + return [resolvedPath]; + } + return [ + resolvedPath, + `${resolvedPath}.ts`, + `${resolvedPath}.js`, + `${resolvedPath}.mjs`, + `${resolvedPath}.cjs`, + ]; +} + +function resolveExistingModulePath(entry: PluginChannelCatalogEntry, specifier: string): string { + for (const candidate of resolveModuleCandidates(entry, specifier)) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return path.resolve(entry.rootDir, specifier); +} + +function loadPersistedAuthStateModule(modulePath: string, rootDir: string): unknown { + const opened = openBoundaryFileSync({ + absolutePath: modulePath, + rootPath: rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: false, + skipLexicalRootCheck: true, + }); + if (!opened.ok) { + throw new Error("plugin persisted-auth module escapes plugin root or fails alias checks"); + } + const safePath = opened.path; + fs.closeSync(opened.fd); + if ( + process.platform === "win32" && + [".js", ".mjs", ".cjs"].includes(path.extname(safePath).toLowerCase()) + ) { + try { + return nodeRequire(safePath); + } catch { + // Fall back to Jiti when native require cannot load the target. + } + } + return loadModule(safePath)(safePath); +} + +function resolvePersistedAuthStateChecker( + entry: PluginChannelCatalogEntry, +): PersistedAuthStateChecker | null { + const cached = persistedAuthStateCheckerCache.get(entry.pluginId); + if (cached !== undefined) { + return cached; + } + + const metadata = resolvePersistedAuthStateMetadata(entry); + if (!metadata) { + persistedAuthStateCheckerCache.set(entry.pluginId, null); + return null; + } + + try { + const moduleExport = loadPersistedAuthStateModule( + resolveExistingModulePath(entry, metadata.specifier!), + entry.rootDir, + ) as Record; + const checker = moduleExport[metadata.exportName!] as PersistedAuthStateChecker | undefined; + if (typeof checker !== "function") { + throw new Error(`missing persisted auth export ${metadata.exportName}`); + } + persistedAuthStateCheckerCache.set(entry.pluginId, checker); + return checker; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + log.warn(`[channels] failed to load persisted auth checker for ${entry.pluginId}: ${detail}`); + persistedAuthStateCheckerCache.set(entry.pluginId, null); + return null; + } +} + +export function listBundledChannelIdsWithPersistedAuthState(): string[] { + return persistedAuthStateCatalog.map((entry) => entry.pluginId); +} + +export function hasBundledChannelPersistedAuthState(params: { + channelId: string; + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): boolean { + const entry = persistedAuthStateEntriesById.get(params.channelId); + if (!entry) { + return false; + } + const checker = resolvePersistedAuthStateChecker(entry); + return checker ? Boolean(checker({ cfg: params.cfg, env: params.env })) : false; +} diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index e37c46dda31..e78de3fb4dc 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; +import { listBundledChannelPluginIds } from "../channels/plugins/bundled-ids.js"; +import { hasBundledChannelPersistedAuthState } from "../channels/plugins/persisted-auth-state.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; @@ -463,8 +464,8 @@ function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boo if (!isRecord(channels)) { return false; } - for (const plugin of listBootstrapChannelPlugins()) { - if (plugin.config.hasPersistedAuthState?.({ cfg, env })) { + for (const channelId of listBundledChannelPluginIds()) { + if (hasBundledChannelPersistedAuthState({ channelId, cfg, env })) { return true; } } diff --git a/src/commands/doctor/shared/channel-legacy-config-migrate.ts b/src/commands/doctor/shared/channel-legacy-config-migrate.ts index f3483ebc3f5..ec0e3c92b72 100644 --- a/src/commands/doctor/shared/channel-legacy-config-migrate.ts +++ b/src/commands/doctor/shared/channel-legacy-config-migrate.ts @@ -1,4 +1,4 @@ -import { listBootstrapChannelPlugins } from "../../../channels/plugins/bootstrap-registry.js"; +import { iterateBootstrapChannelPlugins } from "../../../channels/plugins/bootstrap-registry.js"; import type { OpenClawConfig } from "../../../config/types.js"; export function applyChannelDoctorCompatibilityMigrations(cfg: Record): { @@ -7,7 +7,7 @@ export function applyChannelDoctorCompatibilityMigrations(cfg: Record; const changes: string[] = []; - for (const plugin of listBootstrapChannelPlugins()) { + for (const plugin of iterateBootstrapChannelPlugins()) { const mutation = plugin.doctor?.normalizeCompatibilityConfig?.({ cfg: nextCfg }); if (!mutation || mutation.changes.length === 0) { continue; diff --git a/src/config/channel-configured.ts b/src/config/channel-configured.ts index 6c3d58a6fb6..216816b4411 100644 --- a/src/config/channel-configured.ts +++ b/src/config/channel-configured.ts @@ -1,5 +1,6 @@ import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js"; +import { hasBundledChannelPersistedAuthState } from "../channels/plugins/persisted-auth-state.js"; import { isRecord } from "../utils.js"; import type { OpenClawConfig } from "./config.js"; @@ -27,7 +28,7 @@ export function isChannelConfigured( if (pluginConfigured) { return true; } - const pluginPersistedAuthState = plugin?.config?.hasPersistedAuthState?.({ cfg, env }); + const pluginPersistedAuthState = hasBundledChannelPersistedAuthState({ channelId, cfg, env }); if (pluginPersistedAuthState) { return true; } diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 57d97b1c0e2..8370372c395 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -1,4 +1,4 @@ -import { listBootstrapChannelPlugins } from "../../channels/plugins/bootstrap-registry.js"; +import { getBootstrapChannelPlugin } from "../../channels/plugins/bootstrap-registry.js"; import type { ChannelMessageActionName } from "../../channels/plugins/types.js"; export type MessageActionTargetMode = "to" | "channelId" | "none"; @@ -91,14 +91,10 @@ function listActionTargetAliasSpecs( if (!normalizedChannel) { return specs; } - for (const plugin of listBootstrapChannelPlugins()) { - if (plugin.id !== normalizedChannel) { - continue; - } - const channelSpec = plugin.actions?.messageActionTargetAliases?.[action]; - if (channelSpec) { - specs.push(channelSpec); - } + const plugin = getBootstrapChannelPlugin(normalizedChannel); + const channelSpec = plugin?.actions?.messageActionTargetAliases?.[action]; + if (channelSpec) { + specs.push(channelSpec); } return specs; } diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 7d1b3b5ae2f..9d462ab7e18 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; +import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; import { listBundledChannelPlugins } from "../channels/plugins/bundled.js"; import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -79,11 +79,14 @@ type LegacySessionSurface = { }; function getLegacySessionSurfaces(): LegacySessionSurface[] { - return listBootstrapChannelPlugins() - .map((plugin) => plugin.messaging) - .filter( - (surface): surface is LegacySessionSurface => Boolean(surface) && typeof surface === "object", - ); + const surfaces: LegacySessionSurface[] = []; + for (const plugin of iterateBootstrapChannelPlugins()) { + const surface = plugin.messaging; + if (surface && typeof surface === "object") { + surfaces.push(surface); + } + } + return surfaces; } function isSurfaceGroupKey(key: string): boolean { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 086fa3f7373..a0477b1fede 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -438,6 +438,10 @@ export type PluginPackageChannel = { quickstartAllowFrom?: boolean; forceAccountBinding?: boolean; preferSessionLookupForAnnounceTarget?: boolean; + persistedAuthState?: { + specifier?: string; + exportName?: string; + }; }; export type PluginPackageInstall = { diff --git a/src/plugins/runtime/metadata-registry-loader.test.ts b/src/plugins/runtime/metadata-registry-loader.test.ts new file mode 100644 index 00000000000..d4e3a0a2a28 --- /dev/null +++ b/src/plugins/runtime/metadata-registry-loader.test.ts @@ -0,0 +1,81 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const loadConfigMock = vi.fn(); +const applyPluginAutoEnableMock = vi.fn(); +const loadOpenClawPluginsMock = vi.fn(); + +let loadPluginMetadataRegistrySnapshot: typeof import("./metadata-registry-loader.js").loadPluginMetadataRegistrySnapshot; + +vi.mock("../../config/config.js", () => ({ + loadConfig: () => loadConfigMock(), +})); + +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: (...args: unknown[]) => applyPluginAutoEnableMock(...args), +})); + +vi.mock("../loader.js", () => ({ + loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: () => "/resolved-workspace", + resolveDefaultAgentId: () => "default", +})); + +describe("loadPluginMetadataRegistrySnapshot", () => { + beforeAll(async () => { + ({ loadPluginMetadataRegistrySnapshot } = await import("./metadata-registry-loader.js")); + }); + + beforeEach(() => { + loadConfigMock.mockReset(); + applyPluginAutoEnableMock.mockReset(); + loadOpenClawPluginsMock.mockReset(); + loadConfigMock.mockReturnValue({ plugins: {} }); + applyPluginAutoEnableMock.mockImplementation((params: { config: unknown }) => ({ + config: params.config, + changes: [], + autoEnabledReasons: {}, + })); + loadOpenClawPluginsMock.mockReturnValue({ plugins: [], diagnostics: [] }); + }); + + it("defaults to a non-activating validate snapshot", () => { + loadPluginMetadataRegistrySnapshot({ + config: { plugins: {} }, + activationSourceConfig: { plugins: { allow: ["demo"] } }, + env: { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv, + workspaceDir: "/workspace", + onlyPluginIds: ["demo"], + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: { plugins: {} }, + activationSourceConfig: { plugins: { allow: ["demo"] } }, + workspaceDir: "/workspace", + env: { HOME: "/tmp/openclaw-home" }, + onlyPluginIds: ["demo"], + cache: false, + activate: false, + mode: "validate", + loadModules: undefined, + }), + ); + }); + + it("forwards explicit manifest-only requests", () => { + loadPluginMetadataRegistrySnapshot({ + config: { plugins: {} }, + loadModules: false, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + loadModules: false, + mode: "validate", + }), + ); + }); +}); diff --git a/src/plugins/runtime/metadata-registry-loader.ts b/src/plugins/runtime/metadata-registry-loader.ts index 3eee609298c..ae186427bb0 100644 --- a/src/plugins/runtime/metadata-registry-loader.ts +++ b/src/plugins/runtime/metadata-registry-loader.ts @@ -15,6 +15,7 @@ export function loadPluginMetadataRegistrySnapshot(options?: { env?: NodeJS.ProcessEnv; workspaceDir?: string; onlyPluginIds?: string[]; + loadModules?: boolean; }): PluginRegistry { const env = options?.env ?? process.env; const baseConfig = options?.config ?? loadConfig(); @@ -35,11 +36,13 @@ export function loadPluginMetadataRegistrySnapshot(options?: { activationSourceConfig: options?.activationSourceConfig ?? baseConfig, autoEnabledReasons: autoEnabled.autoEnabledReasons, workspaceDir, + env, logger, throwOnLoadError: true, cache: false, activate: false, mode: "validate", + loadModules: options?.loadModules, ...(options?.onlyPluginIds?.length ? { onlyPluginIds: options.onlyPluginIds } : {}), }); } diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 1de9e061887..ed18b4c67a9 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -11,6 +11,7 @@ import { const loadConfigMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); +const loadPluginMetadataRegistrySnapshotMock = vi.fn(); const applyPluginAutoEnableMock = vi.fn(); const resolveBundledProviderCompatPluginIdsMock = vi.fn(); const withBundledPluginAllowlistCompatMock = vi.fn(); @@ -38,6 +39,11 @@ vi.mock("./loader.js", () => ({ loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), })); +vi.mock("./runtime/metadata-registry-loader.js", () => ({ + loadPluginMetadataRegistrySnapshot: (...args: unknown[]) => + loadPluginMetadataRegistrySnapshotMock(...args), +})); + vi.mock("./providers.js", () => ({ resolveBundledProviderCompatPluginIds: (...args: unknown[]) => resolveBundledProviderCompatPluginIdsMock(...args), @@ -69,12 +75,12 @@ vi.mock("../agents/workspace.js", () => ({ })); function setPluginLoadResult(overrides: Partial>) { - loadOpenClawPluginsMock.mockReturnValue( - createPluginLoadResult({ - plugins: [], - ...overrides, - }), - ); + const result = createPluginLoadResult({ + plugins: [], + ...overrides, + }); + loadOpenClawPluginsMock.mockReturnValue(result); + loadPluginMetadataRegistrySnapshotMock.mockReturnValue(result); } function setSinglePluginLoadResult( @@ -122,20 +128,31 @@ function expectPluginLoaderCall(params: { ); } -function expectAutoEnabledStatusLoad(params: { - rawConfig: unknown; - autoEnabledConfig: unknown; - autoEnabledReasons?: Record; +function expectMetadataSnapshotLoaderCall(params: { + config?: unknown; + activationSourceConfig?: unknown; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + loadModules?: boolean; }) { + expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ + ...(params.config !== undefined ? { config: params.config } : {}), + ...(params.activationSourceConfig !== undefined + ? { activationSourceConfig: params.activationSourceConfig } + : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.env ? { env: params.env } : {}), + ...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}), + }), + ); +} + +function expectAutoEnabledStatusLoad(params: { rawConfig: unknown }) { expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({ config: params.rawConfig, env: process.env, }); - expectPluginLoaderCall({ - config: params.autoEnabledConfig, - activationSourceConfig: params.rawConfig, - autoEnabledReasons: params.autoEnabledReasons ?? {}, - }); } function createCompatChainFixture() { @@ -159,6 +176,7 @@ function expectBundledCompatChainApplied(params: { pluginIds: string[]; compatConfig: unknown; enabledConfig: unknown; + loadModules: boolean; }) { expect(withBundledPluginAllowlistCompatMock).toHaveBeenCalledWith({ config: params.config, @@ -168,7 +186,11 @@ function expectBundledCompatChainApplied(params: { config: params.compatConfig, pluginIds: params.pluginIds, }); - expectPluginLoaderCall({ config: params.enabledConfig }); + if (params.loadModules) { + expectPluginLoaderCall({ config: params.enabledConfig, loadModules: true }); + return; + } + expectMetadataSnapshotLoaderCall({ config: params.enabledConfig, loadModules: false }); } function createAutoEnabledStatusConfig( @@ -258,6 +280,7 @@ describe("plugin status reports", () => { beforeEach(() => { loadConfigMock.mockReset(); loadOpenClawPluginsMock.mockReset(); + loadPluginMetadataRegistrySnapshotMock.mockReset(); applyPluginAutoEnableMock.mockReset(); resolveBundledProviderCompatPluginIdsMock.mockReset(); withBundledPluginAllowlistCompatMock.mockReset(); @@ -291,7 +314,7 @@ describe("plugin status reports", () => { env, }); - expectPluginLoaderCall({ + expectMetadataSnapshotLoaderCall({ config: {}, workspaceDir: "/workspace", env, @@ -299,16 +322,15 @@ describe("plugin status reports", () => { }); }); - it("uses a non-activating snapshot load for snapshot reports", () => { + it("uses a metadata snapshot load for snapshot reports", () => { buildPluginSnapshotReport({ config: {}, workspaceDir: "/workspace" }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith( expect.objectContaining({ - activate: false, - cache: false, loadModules: false, }), ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("loads plugin status from the auto-enabled config snapshot", () => { @@ -330,12 +352,12 @@ describe("plugin status reports", () => { expectAutoEnabledStatusLoad({ rawConfig, - autoEnabledConfig, - autoEnabledReasons: { - demo: ["demo configured"], - }, }); - expectPluginLoaderCall({ loadModules: false }); + expectMetadataSnapshotLoaderCall({ + config: autoEnabledConfig, + activationSourceConfig: rawConfig, + loadModules: false, + }); }); it("uses the auto-enabled config snapshot for inspect policy summaries", () => { @@ -415,12 +437,15 @@ describe("plugin status reports", () => { expectAutoEnabledStatusLoad({ rawConfig, - autoEnabledConfig, + }); + expectPluginLoaderCall({ + config: autoEnabledConfig, + activationSourceConfig: rawConfig, autoEnabledReasons: { demo: ["demo configured"], }, + loadModules: true, }); - expectPluginLoaderCall({ loadModules: true }); }); it("applies the full bundled provider compat chain before loading plugins", () => { @@ -437,6 +462,7 @@ describe("plugin status reports", () => { pluginIds, compatConfig, enabledConfig, + loadModules: false, }); }); @@ -474,10 +500,14 @@ describe("plugin status reports", () => { expectAutoEnabledStatusLoad({ rawConfig, - autoEnabledConfig, + }); + expectPluginLoaderCall({ + config: autoEnabledConfig, + activationSourceConfig: rawConfig, autoEnabledReasons: { demo: ["demo configured"], }, + loadModules: true, }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 62453d20c25..f6543be5da0 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -18,6 +18,7 @@ import { createPluginLoaderLogger } from "./logger.js"; import { resolveBundledProviderCompatPluginIds } from "./providers.js"; import type { PluginRegistry } from "./registry.js"; import { listImportedRuntimePluginIds } from "./runtime.js"; +import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js"; import type { PluginDiagnostic, PluginHookName } from "./types.js"; export type PluginStatusReport = PluginRegistry & { @@ -189,17 +190,25 @@ function buildPluginReport( pluginIds: bundledProviderIds, }); - const registry = loadOpenClawPlugins({ - config: runtimeCompatConfig, - activationSourceConfig: rawConfig, - autoEnabledReasons: autoEnabled.autoEnabledReasons, - workspaceDir, - env: params?.env, - logger: createPluginLoaderLogger(log), - activate: false, - cache: false, - loadModules, - }); + const registry = loadModules + ? loadOpenClawPlugins({ + config: runtimeCompatConfig, + activationSourceConfig: rawConfig, + autoEnabledReasons: autoEnabled.autoEnabledReasons, + workspaceDir, + env: params?.env, + logger: createPluginLoaderLogger(log), + activate: false, + cache: false, + loadModules, + }) + : loadPluginMetadataRegistrySnapshot({ + config: runtimeCompatConfig, + activationSourceConfig: rawConfig, + workspaceDir, + env: params?.env, + loadModules: false, + }); const importedPluginIds = new Set([ ...(loadModules ? registry.plugins diff --git a/src/secrets/runtime-config-collectors-channels.ts b/src/secrets/runtime-config-collectors-channels.ts index 63e483dc431..1209e13fcab 100644 --- a/src/secrets/runtime-config-collectors-channels.ts +++ b/src/secrets/runtime-config-collectors-channels.ts @@ -1,4 +1,4 @@ -import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; +import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; import type { OpenClawConfig } from "../config/config.js"; import { type ResolverContext, type SecretDefaults } from "./runtime-shared.js"; @@ -7,7 +7,7 @@ export function collectChannelConfigAssignments(params: { defaults: SecretDefaults | undefined; context: ResolverContext; }): void { - for (const plugin of listBootstrapChannelPlugins()) { + for (const plugin of iterateBootstrapChannelPlugins()) { plugin.secrets?.collectRuntimeConfigAssignments?.(params); } } diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 01904d2bdf7..051659bee65 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -1,13 +1,15 @@ -import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; +import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret const SIBLING_REF_SHAPE = "sibling_ref"; // pragma: allowlist secret function listChannelSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] { - return listBootstrapChannelPlugins().flatMap( - (plugin) => plugin.secrets?.secretTargetRegistryEntries ?? [], - ); + const entries: SecretTargetRegistryEntry[] = []; + for (const plugin of iterateBootstrapChannelPlugins()) { + entries.push(...(plugin.secrets?.secretTargetRegistryEntries ?? [])); + } + return entries; } const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ diff --git a/src/secrets/unsupported-surface-policy.ts b/src/secrets/unsupported-surface-policy.ts index 95f96d508ca..b0540debc49 100644 --- a/src/secrets/unsupported-surface-policy.ts +++ b/src/secrets/unsupported-surface-policy.ts @@ -1,4 +1,4 @@ -import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; +import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; import { isRecord } from "../utils.js"; const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [ @@ -10,9 +10,11 @@ const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [ ] as const; function collectChannelUnsupportedSecretRefSurfacePatterns(): string[] { - return listBootstrapChannelPlugins().flatMap( - (plugin) => plugin.secrets?.unsupportedSecretRefSurfacePatterns ?? [], - ); + const patterns: string[] = []; + for (const plugin of iterateBootstrapChannelPlugins()) { + patterns.push(...(plugin.secrets?.unsupportedSecretRefSurfacePatterns ?? [])); + } + return patterns; } let cachedUnsupportedSecretRefSurfacePatterns: string[] | null = null; @@ -74,7 +76,7 @@ export function collectUnsupportedSecretRefConfigCandidates( } if (isRecord(raw.channels)) { - for (const plugin of listBootstrapChannelPlugins()) { + for (const plugin of iterateBootstrapChannelPlugins()) { const channelCandidates = plugin.secrets?.collectUnsupportedSecretRefConfigCandidates?.(raw); if (!channelCandidates?.length) { continue; diff --git a/src/sessions/session-chat-type.ts b/src/sessions/session-chat-type.ts index d6dbb5ac48f..01f533ba38e 100644 --- a/src/sessions/session-chat-type.ts +++ b/src/sessions/session-chat-type.ts @@ -1,4 +1,4 @@ -import { listBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; +import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js"; import { parseAgentSessionKey } from "./session-key-utils.js"; export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown"; @@ -44,7 +44,7 @@ export function deriveSessionChatType(sessionKey: string | undefined | null): Se if (builtInLegacy) { return builtInLegacy; } - for (const plugin of listBootstrapChannelPlugins()) { + for (const plugin of iterateBootstrapChannelPlugins()) { const derived = plugin.messaging?.deriveLegacySessionChatType?.(scoped); if (derived) { return derived;