From a73d6620b3be24007eb1d1bff2cd831e101a8f7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:17:13 -0700 Subject: [PATCH 01/56] refactor: route remaining channel imports through plugin sdk --- src/agents/pi-embedded-runner/compact.ts | 8 +-- src/agents/pi-embedded-runner/run/attempt.ts | 8 +-- src/agents/pi-embedded-runner/run/images.ts | 2 +- src/agents/tools/discord-actions-guild.ts | 6 +-- src/agents/tools/discord-actions-messaging.ts | 16 +++--- .../tools/discord-actions-moderation.ts | 4 +- src/agents/tools/discord-actions-presence.ts | 2 +- src/agents/tools/discord-actions.ts | 2 +- src/agents/tools/image-tool.ts | 2 +- src/agents/tools/media-tool-shared.ts | 2 +- src/agents/tools/pdf-tool.ts | 2 +- src/agents/tools/slack-actions.ts | 15 +++--- src/agents/tools/telegram-actions.ts | 24 ++++----- src/agents/tools/whatsapp-actions.ts | 2 +- src/agents/tools/whatsapp-target-auth.ts | 2 +- src/channel-web.ts | 20 +++---- src/channels/plugins/actions/discord.ts | 2 +- src/channels/plugins/actions/telegram.ts | 2 +- .../plugins/agent-tools/whatsapp-login.ts | 4 +- ...ad-only-account-inspect.discord.runtime.ts | 6 +-- ...read-only-account-inspect.slack.runtime.ts | 6 +-- ...d-only-account-inspect.telegram.runtime.ts | 6 +-- src/cli/deps.test.ts | 12 ++--- src/cli/deps.ts | 14 ++--- src/gateway/server-http.ts | 2 +- src/plugin-sdk/discord.ts | 54 +++++++++++++++++++ src/plugin-sdk/imessage.ts | 1 + src/plugin-sdk/signal.ts | 1 + src/plugin-sdk/slack.ts | 20 +++++++ src/plugin-sdk/telegram.ts | 24 +++++++++ src/plugin-sdk/web-media.ts | 7 ++- src/plugin-sdk/whatsapp.ts | 47 ++++++++++++++++ 32 files changed, 233 insertions(+), 92 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 908c323c676..41f681f8de5 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -7,9 +7,6 @@ import { estimateTokens, SessionManager, } from "@mariozechner/pi-coding-agent"; -import { resolveSignalReactionLevel } from "../../../extensions/signal/src/reaction-level.js"; -import { resolveTelegramInlineButtonsScope } from "../../../extensions/telegram/src/inline-buttons.js"; -import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/reaction-level.js"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; @@ -22,6 +19,11 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getMemorySearchManager } from "../../memory/index.js"; +import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramReactionLevel, +} from "../../plugin-sdk/telegram.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b02e8a59fb8..2194e87f9e0 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,9 +7,6 @@ import { DefaultResourceLoader, SessionManager, } from "@mariozechner/pi-coding-agent"; -import { resolveSignalReactionLevel } from "../../../../extensions/signal/src/reaction-level.js"; -import { resolveTelegramInlineButtonsScope } from "../../../../extensions/telegram/src/inline-buttons.js"; -import { resolveTelegramReactionLevel } from "../../../../extensions/telegram/src/reaction-level.js"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../../config/config.js"; @@ -19,6 +16,11 @@ import { ensureGlobalUndiciStreamTimeouts, } from "../../../infra/net/undici-global-dispatcher.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; +import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramReactionLevel, +} from "../../../plugin-sdk/telegram.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { PluginHookAgentContext, diff --git a/src/agents/pi-embedded-runner/run/images.ts b/src/agents/pi-embedded-runner/run/images.ts index a1899bb99af..193fad8b94e 100644 --- a/src/agents/pi-embedded-runner/run/images.ts +++ b/src/agents/pi-embedded-runner/run/images.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ImageContent } from "@mariozechner/pi-ai"; -import { loadWebMedia } from "../../../../extensions/whatsapp/src/media.js"; +import { loadWebMedia } from "../../../plugin-sdk/web-media.js"; import { resolveUserPath } from "../../../utils.js"; import type { ImageSanitizationLimits } from "../../image-sanitization.js"; import { diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 6e08c87a276..fa427d87650 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -1,5 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { getPresence } from "../../../extensions/discord/src/monitor/presence-cache.js"; +import type { DiscordActionConfig } from "../../config/config.js"; import { addRoleDiscord, createChannelDiscord, @@ -19,8 +19,8 @@ import { setChannelPermissionDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../../extensions/discord/src/send.js"; -import type { DiscordActionConfig } from "../../config/config.js"; +} from "../../plugin-sdk/discord.js"; +import { getPresence } from "../../plugin-sdk/discord.js"; import { type ActionGate, jsonResult, diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index c38f2d7066f..20fdfcc6a02 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,5 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { readDiscordComponentSpec } from "../../../extensions/discord/src/components.js"; +import type { DiscordActionConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -21,15 +23,9 @@ import { sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, -} from "../../../extensions/discord/src/send.js"; -import type { - DiscordSendComponents, - DiscordSendEmbeds, -} from "../../../extensions/discord/src/send.shared.js"; -import { resolveDiscordChannelId } from "../../../extensions/discord/src/targets.js"; -import type { DiscordActionConfig } from "../../config/config.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +} from "../../plugin-sdk/discord.js"; +import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js"; +import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { assertMediaNotDataUrl } from "../sandbox-paths.js"; diff --git a/src/agents/tools/discord-actions-moderation.ts b/src/agents/tools/discord-actions-moderation.ts index 68db19d1d7f..56d7a80d4c9 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/src/agents/tools/discord-actions-moderation.ts @@ -1,11 +1,11 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { DiscordActionConfig } from "../../config/config.js"; import { banMemberDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, timeoutMemberDiscord, -} from "../../../extensions/discord/src/send.js"; -import type { DiscordActionConfig } from "../../config/config.js"; +} from "../../plugin-sdk/discord.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; import { isDiscordModerationAction, diff --git a/src/agents/tools/discord-actions-presence.ts b/src/agents/tools/discord-actions-presence.ts index 46f476bafec..53c42829bb0 100644 --- a/src/agents/tools/discord-actions-presence.ts +++ b/src/agents/tools/discord-actions-presence.ts @@ -1,7 +1,7 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { getGateway } from "../../../extensions/discord/src/monitor/gateway-registry.js"; import type { DiscordActionConfig } from "../../config/config.js"; +import { getGateway } from "../../plugin-sdk/discord.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; const ACTIVITY_TYPE_MAP: Record = { diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index 9b1c57bb240..b953e56cffd 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { createDiscordActionGate } from "../../../extensions/discord/src/accounts.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { createDiscordActionGate } from "../../plugin-sdk/discord.js"; import { readStringParam } from "./common.js"; import { handleDiscordGuildAction } from "./discord-actions-guild.js"; import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 4a50263cada..402ee0b3eda 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -1,7 +1,7 @@ import { type Context, complete } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; -import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { loadWebMedia } from "../../plugin-sdk/web-media.js"; import { resolveUserPath } from "../../utils.js"; import { isMinimaxVlmModel, isMinimaxVlmProvider, minimaxUnderstandImage } from "../minimax-vlm.js"; import { diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 8ad943a4b91..56f4a92ca97 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -1,6 +1,6 @@ import { type Api, type Model } from "@mariozechner/pi-ai"; -import { getDefaultLocalRoots } from "../../../extensions/whatsapp/src/media.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { getDefaultLocalRoots } from "../../plugin-sdk/web-media.js"; import type { ImageModelConfig } from "./image-tool.helpers.js"; import { getApiKeyForModel, normalizeWorkspaceDir, requireApiKey } from "./tool-runtime.helpers.js"; diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index 8f229dd7b10..c20bec5936a 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -1,8 +1,8 @@ import { type Context, complete } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; -import { loadWebMediaRaw } from "../../../extensions/whatsapp/src/media.js"; import type { OpenClawConfig } from "../../config/config.js"; import { extractPdfContent, type PdfExtractedContent } from "../../media/pdf-extract.js"; +import { loadWebMediaRaw } from "../../plugin-sdk/web-media.js"; import { resolveUserPath } from "../../utils.js"; import { coerceImageModelConfig, diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 5ed58d5960f..e9089cbfdcc 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -1,5 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { resolveSlackAccount } from "../../../extensions/slack/src/accounts.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { deleteSlackMessage, downloadSlackFile, @@ -15,11 +15,14 @@ import { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../../extensions/slack/src/actions.js"; -import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-input.js"; -import { recordSlackThreadParticipation } from "../../../extensions/slack/src/sent-thread-cache.js"; -import { parseSlackTarget, resolveSlackChannelId } from "../../../extensions/slack/src/targets.js"; -import type { OpenClawConfig } from "../../config/config.js"; +} from "../../plugin-sdk/slack.js"; +import { + parseSlackBlocksInput, + parseSlackTarget, + recordSlackThreadParticipation, + resolveSlackAccount, + resolveSlackChannelId, +} from "../../plugin-sdk/slack.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { createActionGate, diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index ccfc9d5ae13..d648b1e5f41 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,17 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { OpenClawConfig } from "../../config/config.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { createTelegramActionGate, resolveTelegramPollActionGateState, -} from "../../../extensions/telegram/src/accounts.js"; -import type { - TelegramButtonStyle, - TelegramInlineButtons, -} from "../../../extensions/telegram/src/button-types.js"; +} from "../../plugin-sdk/telegram.js"; +import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../../extensions/telegram/src/inline-buttons.js"; -import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/reaction-level.js"; +} from "../../plugin-sdk/telegram.js"; import { createForumTopicTelegram, deleteMessageTelegram, @@ -21,11 +19,13 @@ import { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../../extensions/telegram/src/send.js"; -import { getCacheStats, searchStickers } from "../../../extensions/telegram/src/sticker-cache.js"; -import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; +} from "../../plugin-sdk/telegram.js"; +import { + getCacheStats, + resolveTelegramReactionLevel, + resolveTelegramToken, + searchStickers, +} from "../../plugin-sdk/telegram.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { jsonResult, diff --git a/src/agents/tools/whatsapp-actions.ts b/src/agents/tools/whatsapp-actions.ts index 92332d1b3c5..a84dc0a3d5b 100644 --- a/src/agents/tools/whatsapp-actions.ts +++ b/src/agents/tools/whatsapp-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { sendReactionWhatsApp } from "../../../extensions/whatsapp/src/send.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js"; import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js"; diff --git a/src/agents/tools/whatsapp-target-auth.ts b/src/agents/tools/whatsapp-target-auth.ts index 569a930d1a5..edc0052fbab 100644 --- a/src/agents/tools/whatsapp-target-auth.ts +++ b/src/agents/tools/whatsapp-target-auth.ts @@ -1,5 +1,5 @@ -import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; import { ToolAuthorizationError } from "./common.js"; diff --git a/src/channel-web.ts b/src/channel-web.ts index 99e36ef67bc..e6df4bda0d7 100644 --- a/src/channel-web.ts +++ b/src/channel-web.ts @@ -7,19 +7,11 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, - type WebChannelStatus, - type WebMonitorTuning, -} from "../extensions/whatsapp/src/auto-reply.js"; -export { - extractMediaPlaceholder, - extractText, - monitorWebInbox, - type WebInboundMessage, - type WebListenerCloseReason, -} from "../extensions/whatsapp/src/inbound.js"; -export { loginWeb } from "../extensions/whatsapp/src/login.js"; -export { loadWebMedia, optimizeImageToJpeg } from "../extensions/whatsapp/src/media.js"; -export { sendMessageWhatsApp } from "../extensions/whatsapp/src/send.js"; +} from "./plugin-sdk/whatsapp.js"; +export { extractMediaPlaceholder, extractText, monitorWebInbox } from "./plugin-sdk/whatsapp.js"; +export { loginWeb } from "./plugin-sdk/whatsapp.js"; +export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk/whatsapp.js"; +export { sendMessageWhatsApp } from "./plugin-sdk/whatsapp.js"; export { createWaSocket, formatError, @@ -30,4 +22,4 @@ export { WA_WEB_AUTH_DIR, waitForWaConnection, webAuthExists, -} from "../extensions/whatsapp/src/session.js"; +} from "./plugin-sdk/whatsapp.js"; diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts index ccf1b489035..4615a88f3c5 100644 --- a/src/channels/plugins/actions/discord.ts +++ b/src/channels/plugins/actions/discord.ts @@ -1,2 +1,2 @@ // Public entrypoint for the Discord channel action adapter. -export * from "../../../../extensions/discord/src/channel-actions.js"; +export * from "../../../plugin-sdk/discord.js"; diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 7961baf334f..e811e757b94 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -1,2 +1,2 @@ // Public entrypoint for the Telegram channel action adapter. -export * from "../../../../extensions/telegram/src/channel-actions.js"; +export * from "../../../plugin-sdk/telegram.js"; diff --git a/src/channels/plugins/agent-tools/whatsapp-login.ts b/src/channels/plugins/agent-tools/whatsapp-login.ts index 741b40a6fc9..2204225bdda 100644 --- a/src/channels/plugins/agent-tools/whatsapp-login.ts +++ b/src/channels/plugins/agent-tools/whatsapp-login.ts @@ -1,2 +1,2 @@ -// Shim: re-exports from extensions/whatsapp/src/agent-tools-login.ts -export * from "../../../../extensions/whatsapp/src/agent-tools-login.js"; +// Shim: keep legacy import path while the runtime loads the plugin SDK surface. +export * from "../../../plugin-sdk/whatsapp.js"; diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts index aed3283b7a2..9d2ac6ef427 100644 --- a/src/channels/read-only-account-inspect.discord.runtime.ts +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -1,4 +1,2 @@ -export { - inspectDiscordAccount, - type InspectedDiscordAccount, -} from "../../extensions/discord/src/account-inspect.js"; +export { inspectDiscordAccount } from "../plugin-sdk/discord.js"; +export type { InspectedDiscordAccount } from "../plugin-sdk/discord.js"; diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts index 6d0e0a10b29..a7526e2ea95 100644 --- a/src/channels/read-only-account-inspect.slack.runtime.ts +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -1,4 +1,2 @@ -export { - inspectSlackAccount, - type InspectedSlackAccount, -} from "../../extensions/slack/src/account-inspect.js"; +export { inspectSlackAccount } from "../plugin-sdk/slack.js"; +export type { InspectedSlackAccount } from "../plugin-sdk/slack.js"; diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts index 07866b9d450..0ab48f2c241 100644 --- a/src/channels/read-only-account-inspect.telegram.runtime.ts +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -1,4 +1,2 @@ -export { - inspectTelegramAccount, - type InspectedTelegramAccount, -} from "../../extensions/telegram/src/account-inspect.js"; +export { inspectTelegramAccount } from "../plugin-sdk/telegram.js"; +export type { InspectedTelegramAccount } from "../plugin-sdk/telegram.js"; diff --git a/src/cli/deps.test.ts b/src/cli/deps.test.ts index f345e1a24bb..64b66313907 100644 --- a/src/cli/deps.test.ts +++ b/src/cli/deps.test.ts @@ -19,32 +19,32 @@ const sendFns = vi.hoisted(() => ({ imessage: vi.fn(async () => ({ messageId: "i1", chatId: "imessage:1" })), })); -vi.mock("../channels/web/index.js", () => { +vi.mock("../plugin-sdk/whatsapp.js", () => { moduleLoads.whatsapp(); return { sendMessageWhatsApp: sendFns.whatsapp }; }); -vi.mock("../../extensions/telegram/src/send.js", () => { +vi.mock("../plugin-sdk/telegram.js", () => { moduleLoads.telegram(); return { sendMessageTelegram: sendFns.telegram }; }); -vi.mock("../../extensions/discord/src/send.js", () => { +vi.mock("../plugin-sdk/discord.js", () => { moduleLoads.discord(); return { sendMessageDiscord: sendFns.discord }; }); -vi.mock("../../extensions/slack/src/send.js", () => { +vi.mock("../plugin-sdk/slack.js", () => { moduleLoads.slack(); return { sendMessageSlack: sendFns.slack }; }); -vi.mock("../../extensions/signal/src/send.js", () => { +vi.mock("../plugin-sdk/signal.js", () => { moduleLoads.signal(); return { sendMessageSignal: sendFns.signal }; }); -vi.mock("../../extensions/imessage/src/send.js", () => { +vi.mock("../plugin-sdk/imessage.js", () => { moduleLoads.imessage(); return { sendMessageIMessage: sendFns.imessage }; }); diff --git a/src/cli/deps.ts b/src/cli/deps.ts index c9ab341dd18..7ebfbf74f5b 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -35,32 +35,32 @@ export function createDefaultDeps(): CliDeps { return { whatsapp: createLazySender( "whatsapp", - () => import("../channels/web/index.js") as Promise>, + () => import("../plugin-sdk/whatsapp.js") as Promise>, "sendMessageWhatsApp", ), telegram: createLazySender( "telegram", - () => import("../../extensions/telegram/src/send.js") as Promise>, + () => import("../plugin-sdk/telegram.js") as Promise>, "sendMessageTelegram", ), discord: createLazySender( "discord", - () => import("../../extensions/discord/src/send.js") as Promise>, + () => import("../plugin-sdk/discord.js") as Promise>, "sendMessageDiscord", ), slack: createLazySender( "slack", - () => import("../../extensions/slack/src/send.js") as Promise>, + () => import("../plugin-sdk/slack.js") as Promise>, "sendMessageSlack", ), signal: createLazySender( "signal", - () => import("../../extensions/signal/src/send.js") as Promise>, + () => import("../plugin-sdk/signal.js") as Promise>, "sendMessageSignal", ), imessage: createLazySender( "imessage", - () => import("../../extensions/imessage/src/send.js") as Promise>, + () => import("../plugin-sdk/imessage.js") as Promise>, "sendMessageIMessage", ), }; @@ -70,4 +70,4 @@ export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); } -export { logWebSelfId } from "../../extensions/whatsapp/src/auth-store.js"; +export { logWebSelfId } from "../plugin-sdk/whatsapp.js"; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 75af96dd545..0ad655f4990 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -8,12 +8,12 @@ import { import { createServer as createHttpsServer } from "node:https"; import type { TlsOptions } from "node:tls"; import type { WebSocketServer } from "ws"; -import { handleSlackHttpRequest } from "../../extensions/slack/src/http/index.js"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; +import { handleSlackHttpRequest } from "../plugin-sdk/slack.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index d9e40370d23..d89f768071b 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -3,9 +3,14 @@ export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; +export type { + DiscordSendComponents, + DiscordSendEmbeds, +} from "../../extensions/discord/src/send.shared.js"; export * from "./channel-plugin-common.js"; export { + createDiscordActionGate, listDiscordAccountIds, resolveDefaultDiscordAccountId, resolveDiscordAccount, @@ -50,6 +55,55 @@ export { listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey, } from "../../extensions/discord/src/monitor/thread-bindings.js"; +export { getGateway } from "../../extensions/discord/src/monitor/gateway-registry.js"; +export { getPresence } from "../../extensions/discord/src/monitor/presence-cache.js"; +export { readDiscordComponentSpec } from "../../extensions/discord/src/components.js"; +export { resolveDiscordChannelId } from "../../extensions/discord/src/targets.js"; +export { + addRoleDiscord, + banMemberDiscord, + createChannelDiscord, + createScheduledEventDiscord, + createThreadDiscord, + deleteChannelDiscord, + deleteMessageDiscord, + editChannelDiscord, + editMessageDiscord, + fetchChannelInfoDiscord, + fetchChannelPermissionsDiscord, + fetchMemberInfoDiscord, + fetchMessageDiscord, + fetchReactionsDiscord, + fetchRoleInfoDiscord, + fetchVoiceStatusDiscord, + hasAnyGuildPermissionDiscord, + kickMemberDiscord, + listGuildChannelsDiscord, + listGuildEmojisDiscord, + listPinsDiscord, + listScheduledEventsDiscord, + listThreadsDiscord, + moveChannelDiscord, + pinMessageDiscord, + reactMessageDiscord, + readMessagesDiscord, + removeChannelPermissionDiscord, + removeOwnReactionsDiscord, + removeReactionDiscord, + removeRoleDiscord, + searchMessagesDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + sendVoiceMessageDiscord, + setChannelPermissionDiscord, + timeoutMemberDiscord, + unpinMessageDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, +} from "../../extensions/discord/src/send.js"; +export { discordMessageActions } from "../../extensions/discord/src/channel-actions.js"; export type { ThreadBindingManager, ThreadBindingRecord, diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index a3a42f110ee..0fe2e278d40 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -28,6 +28,7 @@ export type { ChatSenderAllowParams, ParsedChatTarget, } from "../../extensions/imessage/src/target-parsing-helpers.js"; +export { sendMessageIMessage } from "../../extensions/imessage/src/send.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index b407e944e47..3683115143e 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -13,6 +13,7 @@ export { removeReactionSignal, sendReactionSignal, } from "../../extensions/signal/src/send-reactions.js"; +export { sendMessageSignal } from "../../extensions/signal/src/send.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 0cbfd236274..8ac3915ec31 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -31,6 +31,26 @@ export { listSlackMessageActions, } from "../../extensions/slack/src/message-actions.js"; export { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; +export { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; +export { handleSlackHttpRequest } from "../../extensions/slack/src/http/index.js"; +export { sendMessageSlack } from "../../extensions/slack/src/send.js"; +export { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, +} from "../../extensions/slack/src/actions.js"; +export { recordSlackThreadParticipation } from "../../extensions/slack/src/sent-thread-cache.js"; export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; export { diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 2db97fb74b5..52d0269f712 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -11,6 +11,10 @@ export type { TelegramAccountConfig, TelegramActionConfig } from "../config/type export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; +export type { + TelegramButtonStyle, + TelegramInlineButtons, +} from "../../extensions/telegram/src/button-types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; @@ -32,8 +36,10 @@ export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js export { getChatChannelMeta } from "../channels/registry.js"; export { + createTelegramActionGate, listTelegramAccountIds, resolveDefaultTelegramAccountId, + resolveTelegramPollActionGateState, resolveTelegramAccount, } from "../../extensions/telegram/src/accounts.js"; export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; @@ -58,6 +64,24 @@ export { normalizeTelegramAllowFromEntry, } from "../../extensions/telegram/src/allow-from.js"; export { fetchTelegramChatId } from "../../extensions/telegram/src/api-fetch.js"; +export { + resolveTelegramInlineButtonsScope, + resolveTelegramTargetChatType, +} from "../../extensions/telegram/src/inline-buttons.js"; +export { resolveTelegramReactionLevel } from "../../extensions/telegram/src/reaction-level.js"; +export { + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageTelegram, + reactMessageTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, +} from "../../extensions/telegram/src/send.js"; +export { getCacheStats, searchStickers } from "../../extensions/telegram/src/sticker-cache.js"; +export { resolveTelegramToken } from "../../extensions/telegram/src/token.js"; +export { telegramMessageActions } from "../../extensions/telegram/src/channel-actions.js"; export { collectTelegramStatusIssues } from "../../extensions/telegram/src/status-issues.js"; export { sendTelegramPayloadMessages } from "../../extensions/telegram/src/outbound-adapter.js"; export { diff --git a/src/plugin-sdk/web-media.ts b/src/plugin-sdk/web-media.ts index 02194b867b2..1c7432ad2b5 100644 --- a/src/plugin-sdk/web-media.ts +++ b/src/plugin-sdk/web-media.ts @@ -1 +1,6 @@ -export { loadWebMedia, type WebMediaResult } from "../../extensions/whatsapp/src/media.js"; +export { + getDefaultLocalRoots, + loadWebMedia, + loadWebMediaRaw, + type WebMediaResult, +} from "../../extensions/whatsapp/src/media.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 759a56b080e..56db52bfc13 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -31,6 +31,53 @@ export { listEnabledWhatsAppAccounts, resolveWhatsAppAccount, } from "../../extensions/whatsapp/src/accounts.js"; +export { + WA_WEB_AUTH_DIR, + logWebSelfId, + logoutWeb, + pickWebChannel, + webAuthExists, +} from "../../extensions/whatsapp/src/auth-store.js"; +export { + DEFAULT_WEB_MEDIA_BYTES, + HEARTBEAT_PROMPT, + HEARTBEAT_TOKEN, + monitorWebChannel, + resolveHeartbeatRecipients, + runWebHeartbeatOnce, +} from "../../extensions/whatsapp/src/auto-reply.js"; +export type { + WebChannelStatus, + WebMonitorTuning, +} from "../../extensions/whatsapp/src/auto-reply.js"; +export { + extractMediaPlaceholder, + extractText, + monitorWebInbox, +} from "../../extensions/whatsapp/src/inbound.js"; +export type { + WebInboundMessage, + WebListenerCloseReason, +} from "../../extensions/whatsapp/src/inbound.js"; +export { loginWeb } from "../../extensions/whatsapp/src/login.js"; +export { + getDefaultLocalRoots, + loadWebMedia, + loadWebMediaRaw, + optimizeImageToJpeg, +} from "../../extensions/whatsapp/src/media.js"; +export { + sendMessageWhatsApp, + sendPollWhatsApp, + sendReactionWhatsApp, +} from "../../extensions/whatsapp/src/send.js"; +export { + createWaSocket, + formatError, + getStatusCode, + waitForWaConnection, +} from "../../extensions/whatsapp/src/session.js"; +export { createWhatsAppLoginTool } from "../../extensions/whatsapp/src/agent-tools-login.js"; export { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; export { collectAllowlistProviderGroupPolicyWarnings, From 7cc5789202a000b6adb396f30c345d8c278959e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:20:15 -0700 Subject: [PATCH 02/56] refactor(plugins): finish provider auth boundary cleanup --- extensions/amazon-bedrock/index.test.ts | 22 + extensions/amazon-bedrock/index.ts | 23 + .../amazon-bedrock/openclaw.plugin.json | 9 + extensions/amazon-bedrock/package.json | 12 + extensions/cloudflare-ai-gateway/index.ts | 5 +- extensions/minimax/index.ts | 20 +- extensions/opencode-go/index.ts | 1 + extensions/opencode/index.ts | 1 + extensions/zai/detect.ts | 21 + extensions/zai/index.ts | 30 +- src/agents/auth-profiles/doctor.ts | 38 +- .../pi-embedded-runner/cache-ttl.test.ts | 20 +- src/agents/pi-embedded-runner/cache-ttl.ts | 13 - src/auto-reply/thinking.test.ts | 9 +- src/auto-reply/thinking.ts | 7 - .../auth-choice.apply.api-key-providers.ts | 497 +---------------- .../auth-choice.apply.api-providers.ts | 296 +---------- .../auth-choice.apply.huggingface.test.ts | 194 ------- src/commands/auth-choice.apply.huggingface.ts | 137 ----- .../auth-choice.apply.minimax.test.ts | 208 -------- src/commands/auth-choice.apply.minimax.ts | 106 ---- src/commands/auth-choice.apply.openrouter.ts | 93 ---- .../auth-choice.apply.plugin-provider.ts | 47 +- src/commands/auth-choice.apply.ts | 4 +- src/commands/auth-choice.test.ts | 53 +- ...oard-non-interactive.provider-auth.test.ts | 160 +++--- .../local/auth-choice.api-key-providers.ts | 500 +----------------- .../local/auth-choice.plugin-providers.ts | 2 + .../local/auth-choice.ts | 113 +--- src/plugins/config-state.ts | 1 + src/plugins/provider-api-key-auth.ts | 92 ++-- src/plugins/provider-auth-choices.ts | 20 + src/plugins/types.ts | 7 + 33 files changed, 483 insertions(+), 2278 deletions(-) create mode 100644 extensions/amazon-bedrock/index.test.ts create mode 100644 extensions/amazon-bedrock/index.ts create mode 100644 extensions/amazon-bedrock/openclaw.plugin.json create mode 100644 extensions/amazon-bedrock/package.json create mode 100644 extensions/zai/detect.ts delete mode 100644 src/commands/auth-choice.apply.huggingface.test.ts delete mode 100644 src/commands/auth-choice.apply.huggingface.ts delete mode 100644 src/commands/auth-choice.apply.minimax.test.ts delete mode 100644 src/commands/auth-choice.apply.minimax.ts delete mode 100644 src/commands/auth-choice.apply.openrouter.ts diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts new file mode 100644 index 00000000000..641173cd6ce --- /dev/null +++ b/extensions/amazon-bedrock/index.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; +import amazonBedrockPlugin from "./index.js"; + +describe("amazon-bedrock provider plugin", () => { + it("marks Claude 4.6 Bedrock models as adaptive by default", () => { + const provider = registerSingleProviderPlugin(amazonBedrockPlugin); + + expect( + provider.resolveDefaultThinkingLevel?.({ + provider: "amazon-bedrock", + modelId: "us.anthropic.claude-opus-4-6-v1", + } as never), + ).toBe("adaptive"); + expect( + provider.resolveDefaultThinkingLevel?.({ + provider: "amazon-bedrock", + modelId: "amazon.nova-micro-v1:0", + } as never), + ).toBeUndefined(); + }); +}); diff --git a/extensions/amazon-bedrock/index.ts b/extensions/amazon-bedrock/index.ts new file mode 100644 index 00000000000..33fa3a08d32 --- /dev/null +++ b/extensions/amazon-bedrock/index.ts @@ -0,0 +1,23 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + +const PROVIDER_ID = "amazon-bedrock"; +const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; + +const amazonBedrockPlugin = { + id: PROVIDER_ID, + name: "Amazon Bedrock Provider", + description: "Bundled Amazon Bedrock provider policy plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Amazon Bedrock", + docsPath: "/providers/models", + auth: [], + resolveDefaultThinkingLevel: ({ modelId }) => + CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined, + }); + }, +}; + +export default amazonBedrockPlugin; diff --git a/extensions/amazon-bedrock/openclaw.plugin.json b/extensions/amazon-bedrock/openclaw.plugin.json new file mode 100644 index 00000000000..9239ea19146 --- /dev/null +++ b/extensions/amazon-bedrock/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "amazon-bedrock", + "providers": ["amazon-bedrock"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/amazon-bedrock/package.json b/extensions/amazon-bedrock/package.json new file mode 100644 index 00000000000..6c1471c92c3 --- /dev/null +++ b/extensions/amazon-bedrock/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/amazon-bedrock-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Amazon Bedrock provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index 4544a932faf..ddc0bd7405a 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -143,7 +143,10 @@ const cloudflareAiGatewayPlugin = { await ensureApiKeyFromOptionEnvOrPrompt({ token: normalizeOptionalSecretInput(ctx.opts?.cloudflareAiGatewayApiKey), tokenProvider: "cloudflare-ai-gateway", - secretInputMode: ctx.secretInputMode, + secretInputMode: + ctx.allowSecretRefPrompt === false + ? (ctx.secretInputMode ?? "plaintext") + : ctx.secretInputMode, config: ctx.config, expectedProviders: [PROVIDER_ID], provider: PROVIDER_ID, diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 6906bb0438d..604e8627d22 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -31,7 +31,11 @@ function getDefaultBaseUrl(region: MiniMaxRegion): string { return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; } -function modelRef(modelId: string): string { +function apiModelRef(modelId: string): string { + return `${API_PROVIDER_ID}/${modelId}`; +} + +function portalModelRef(modelId: string): string { return `${PORTAL_PROVIDER_ID}/${modelId}`; } @@ -109,7 +113,7 @@ function createOAuthHandler(region: MiniMaxRegion) { return buildOauthProviderAuthResult({ providerId: PORTAL_PROVIDER_ID, - defaultModel: modelRef(DEFAULT_MODEL), + defaultModel: portalModelRef(DEFAULT_MODEL), access: result.access, refresh: result.refresh, expires: result.expires, @@ -125,11 +129,11 @@ function createOAuthHandler(region: MiniMaxRegion) { agents: { defaults: { models: { - [modelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" }, - [modelRef("MiniMax-M2.5-highspeed")]: { + [portalModelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" }, + [portalModelRef("MiniMax-M2.5-highspeed")]: { alias: "minimax-m2.5-highspeed", }, - [modelRef("MiniMax-M2.5-Lightning")]: { + [portalModelRef("MiniMax-M2.5-Lightning")]: { alias: "minimax-m2.5-lightning", }, }, @@ -177,7 +181,8 @@ const minimaxPlugin = { promptMessage: "Enter MiniMax API key (sk-api- or sk-cp-)\nhttps://platform.minimax.io/user-center/basic-information/interface-key", profileId: "minimax:global", - defaultModel: modelRef(DEFAULT_MODEL), + allowProfile: false, + defaultModel: apiModelRef(DEFAULT_MODEL), expectedProviders: ["minimax"], applyConfig: (cfg) => applyMinimaxApiConfig(cfg), wizard: { @@ -200,7 +205,8 @@ const minimaxPlugin = { promptMessage: "Enter MiniMax CN API key (sk-api- or sk-cp-)\nhttps://platform.minimaxi.com/user-center/basic-information/interface-key", profileId: "minimax:cn", - defaultModel: modelRef(DEFAULT_MODEL), + allowProfile: false, + defaultModel: apiModelRef(DEFAULT_MODEL), expectedProviders: ["minimax", "minimax-cn"], applyConfig: (cfg) => applyMinimaxApiConfigCn(cfg), wizard: { diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 2027f9ad05d..c0a8cea9b91 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -26,6 +26,7 @@ const opencodeGoPlugin = { flagName: "--opencode-go-api-key", envVar: "OPENCODE_API_KEY", promptMessage: "Enter OpenCode API key", + profileIds: ["opencode:default", "opencode-go:default"], defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF, expectedProviders: ["opencode", "opencode-go"], applyConfig: (cfg) => applyOpencodeGoConfig(cfg), diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 008afe4091c..d00ae301bc5 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -35,6 +35,7 @@ const opencodePlugin = { flagName: "--opencode-zen-api-key", envVar: "OPENCODE_API_KEY", promptMessage: "Enter OpenCode API key", + profileIds: ["opencode:default", "opencode-go:default"], defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, expectedProviders: ["opencode", "opencode-go"], applyConfig: (cfg) => applyOpencodeZenConfig(cfg), diff --git a/extensions/zai/detect.ts b/extensions/zai/detect.ts new file mode 100644 index 00000000000..07f06a9f052 --- /dev/null +++ b/extensions/zai/detect.ts @@ -0,0 +1,21 @@ +import { + detectZaiEndpoint as detectZaiEndpointCore, + type ZaiDetectedEndpoint, + type ZaiEndpointId, +} from "../../src/commands/zai-endpoint-detect.js"; + +type DetectZaiEndpointFn = typeof detectZaiEndpointCore; + +let detectZaiEndpointImpl: DetectZaiEndpointFn = detectZaiEndpointCore; + +export function setDetectZaiEndpointForTesting(fn?: DetectZaiEndpointFn): void { + detectZaiEndpointImpl = fn ?? detectZaiEndpointCore; +} + +export async function detectZaiEndpoint( + ...args: Parameters +): ReturnType { + return await detectZaiEndpointImpl(...args); +} + +export type { ZaiDetectedEndpoint, ZaiEndpointId }; diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index d6a1561167d..16f1c311ea3 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -26,11 +26,11 @@ import { applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF, } from "../../src/commands/onboard-auth.js"; -import { detectZaiEndpoint, type ZaiEndpointId } from "../../src/commands/zai-endpoint-detect.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js"; import { fetchZaiUsage } from "../../src/infra/provider-usage.fetch.js"; import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; +import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; const PROVIDER_ID = "zai"; const GLM5_MODEL_ID = "glm-5"; @@ -97,6 +97,27 @@ function resolveZaiDefaultModel(modelIdOverride?: string): string { return modelIdOverride ? `zai/${modelIdOverride}` : ZAI_DEFAULT_MODEL_REF; } +async function promptForZaiEndpoint(ctx: ProviderAuthContext): Promise { + return await ctx.prompter.select({ + message: "Select Z.AI endpoint", + initialValue: "global", + options: [ + { value: "global", label: "Global", hint: "Z.AI Global (api.z.ai)" }, + { value: "cn", label: "CN", hint: "Z.AI CN (open.bigmodel.cn)" }, + { + value: "coding-global", + label: "Coding-Plan-Global", + hint: "GLM Coding Plan Global (api.z.ai)", + }, + { + value: "coding-cn", + label: "Coding-Plan-CN", + hint: "GLM Coding Plan CN (open.bigmodel.cn)", + }, + ], + }); +} + async function runZaiApiKeyAuth( ctx: ProviderAuthContext, endpoint?: ZaiEndpointId, @@ -116,7 +137,10 @@ async function runZaiApiKeyAuth( tokenProvider: normalizeOptionalSecretInput(ctx.opts?.zaiApiKey) ? PROVIDER_ID : normalizeOptionalSecretInput(ctx.opts?.tokenProvider), - secretInputMode: ctx.secretInputMode, + secretInputMode: + ctx.allowSecretRefPrompt === false + ? (ctx.secretInputMode ?? "plaintext") + : ctx.secretInputMode, config: ctx.config, expectedProviders: [PROVIDER_ID, "z-ai"], provider: PROVIDER_ID, @@ -138,7 +162,7 @@ async function runZaiApiKeyAuth( const detected = await detectZaiEndpoint({ apiKey, ...(endpoint ? { endpoint } : {}) }); const modelIdOverride = detected?.modelId; - const nextEndpoint = detected?.endpoint ?? endpoint; + const nextEndpoint = detected?.endpoint ?? endpoint ?? (await promptForZaiEndpoint(ctx)); return { profiles: [ { diff --git a/src/agents/auth-profiles/doctor.ts b/src/agents/auth-profiles/doctor.ts index 220d20f2294..e2ce1a7efca 100644 --- a/src/agents/auth-profiles/doctor.ts +++ b/src/agents/auth-profiles/doctor.ts @@ -1,8 +1,5 @@ -import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeProviderId } from "../model-selection.js"; -import { listProfilesForProvider } from "./profiles.js"; -import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; import type { AuthProfileStore } from "./types.js"; let providerRuntimePromise: @@ -34,38 +31,5 @@ export async function formatAuthDoctorHint(params: { if (typeof pluginHint === "string" && pluginHint.trim()) { return pluginHint; } - - const providerKey = normalizeProviderId(params.provider); - if (providerKey !== "anthropic") { - return ""; - } - - const legacyProfileId = params.profileId ?? "anthropic:default"; - const suggested = suggestOAuthProfileIdForLegacyDefault({ - cfg: params.cfg, - store: params.store, - provider: providerKey, - legacyProfileId, - }); - if (!suggested || suggested === legacyProfileId) { - return ""; - } - - const storeOauthProfiles = listProfilesForProvider(params.store, providerKey) - .filter((id) => params.store.profiles[id]?.type === "oauth") - .join(", "); - - const cfgMode = params.cfg?.auth?.profiles?.[legacyProfileId]?.mode; - const cfgProvider = params.cfg?.auth?.profiles?.[legacyProfileId]?.provider; - - return [ - "Doctor hint (for GitHub issue):", - `- provider: ${providerKey}`, - `- config: ${legacyProfileId}${ - cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : "" - }`, - `- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`, - `- suggested profile: ${suggested}`, - `Fix: run "${formatCliCommand("openclaw doctor --yes")}"`, - ].join("\n"); + return ""; } diff --git a/src/agents/pi-embedded-runner/cache-ttl.test.ts b/src/agents/pi-embedded-runner/cache-ttl.test.ts index d968b6b79eb..f5ff8be2827 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.test.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.test.ts @@ -3,12 +3,20 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../../plugins/provider-runtime.js", () => ({ resolveProviderCacheTtlEligibility: (params: { context: { provider: string; modelId: string }; - }) => - params.context.provider === "openrouter" - ? ["anthropic/", "moonshot/", "moonshotai/", "zai/"].some((prefix) => - params.context.modelId.startsWith(prefix), - ) - : undefined, + }) => { + if (params.context.provider === "anthropic") { + return true; + } + if (params.context.provider === "moonshot" || params.context.provider === "zai") { + return true; + } + if (params.context.provider === "openrouter") { + return ["anthropic/", "moonshot/", "moonshotai/", "zai/"].some((prefix) => + params.context.modelId.startsWith(prefix), + ); + } + return undefined; + }, })); import { isCacheTtlEligibleProvider } from "./cache-ttl.js"; diff --git a/src/agents/pi-embedded-runner/cache-ttl.ts b/src/agents/pi-embedded-runner/cache-ttl.ts index e5e577d331a..dfea1c714b9 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.ts @@ -10,8 +10,6 @@ export type CacheTtlEntryData = { modelId?: string; }; -const CACHE_TTL_NATIVE_PROVIDERS = new Set(["moonshot", "zai"]); - export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { const normalizedProvider = provider.toLowerCase(); const normalizedModelId = modelId.toLowerCase(); @@ -25,17 +23,6 @@ export function isCacheTtlEligibleProvider(provider: string, modelId: string): b if (pluginEligibility !== undefined) { return pluginEligibility; } - if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) { - return true; - } - // Legacy fallback for tests / plugin-disabled contexts. The Anthropic plugin - // owns this policy in normal runtime. - if (normalizedProvider === "anthropic") { - return true; - } - if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) { - return true; - } return false; } diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 18736f38905..35d1c289271 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -144,7 +144,14 @@ describe("resolveThinkingDefaultForModel", () => { ).toBe("adaptive"); }); - it("treats Bedrock Anthropic aliases as adaptive", () => { + it("uses provider-advertised adaptive defaults for Bedrock aliases", () => { + providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockImplementation( + ({ provider, context }) => + provider === "amazon-bedrock" && context.modelId === "claude-sonnet-4-6" + ? "adaptive" + : undefined, + ); + expect( resolveThinkingDefaultForModel({ provider: "aws-bedrock", model: "claude-sonnet-4-6" }), ).toBe("adaptive"); diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 1652a80bdf6..f43ca7bc34b 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -17,8 +17,6 @@ export type ThinkingCatalogEntry = { reasoning?: boolean; }; -const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; - function normalizeProviderId(provider?: string | null): string { if (!provider) { return ""; @@ -146,7 +144,6 @@ export function resolveThinkingDefaultForModel(params: { catalog?: ThinkingCatalogEntry[]; }): ThinkLevel { const normalizedProvider = normalizeProviderId(params.provider); - const modelLower = params.model.trim().toLowerCase(); const candidate = params.catalog?.find( (entry) => entry.provider === params.provider && entry.id === params.model, ); @@ -161,10 +158,6 @@ export function resolveThinkingDefaultForModel(params: { if (pluginDecision) { return pluginDecision; } - - if (normalizedProvider === "amazon-bedrock" && CLAUDE_46_MODEL_RE.test(modelLower)) { - return "adaptive"; - } if (candidate?.reasoning) { return "low"; } diff --git a/src/commands/auth-choice.apply.api-key-providers.ts b/src/commands/auth-choice.apply.api-key-providers.ts index 078dac8d0a1..3ff35a46365 100644 --- a/src/commands/auth-choice.apply.api-key-providers.ts +++ b/src/commands/auth-choice.apply.api-key-providers.ts @@ -1,81 +1,15 @@ import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js"; -import type { SecretInput } from "../config/types.secrets.js"; import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "./auth-choice.apply-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { ensureModelAllowlistEntry } from "./model-allowlist.js"; -import { applyPrimaryModel } from "./model-picker.js"; -import type { ApiKeyStorageOptions } from "./onboard-auth.credentials.js"; import { applyAuthProfileConfig, - applyKilocodeConfig, - applyKilocodeProviderConfig, - applyKimiCodeConfig, - applyKimiCodeProviderConfig, applyLitellmConfig, applyLitellmProviderConfig, - applyMistralConfig, - applyMistralProviderConfig, - applyModelStudioConfig, - applyModelStudioConfigCn, - applyModelStudioProviderConfig, - applyModelStudioProviderConfigCn, - applyMoonshotConfig, - applyMoonshotConfigCn, - applyMoonshotProviderConfig, - applyMoonshotProviderConfigCn, - applyOpencodeGoConfig, - applyOpencodeGoProviderConfig, - applyOpencodeZenConfig, - applyOpencodeZenProviderConfig, - applyQianfanConfig, - applyQianfanProviderConfig, - applySyntheticConfig, - applySyntheticProviderConfig, - applyTogetherConfig, - applyTogetherProviderConfig, - applyVeniceConfig, - applyVeniceProviderConfig, - applyVercelAiGatewayConfig, - applyVercelAiGatewayProviderConfig, - applyXaiConfig, - applyXaiProviderConfig, - applyXiaomiConfig, - applyXiaomiProviderConfig, - KILOCODE_DEFAULT_MODEL_REF, - KIMI_CODING_MODEL_REF, LITELLM_DEFAULT_MODEL_REF, - MISTRAL_DEFAULT_MODEL_REF, - MODELSTUDIO_DEFAULT_MODEL_REF, - MOONSHOT_DEFAULT_MODEL_REF, - QIANFAN_DEFAULT_MODEL_REF, - setKilocodeApiKey, - setKimiCodingApiKey, setLitellmApiKey, - setMistralApiKey, - setModelStudioApiKey, - setMoonshotApiKey, - setOpencodeGoApiKey, - setOpencodeZenApiKey, - setQianfanApiKey, - setSyntheticApiKey, - setTogetherApiKey, - setVeniceApiKey, - setVercelAiGatewayApiKey, - setVolcengineApiKey, - setByteplusApiKey, - setXaiApiKey, - setXiaomiApiKey, - SYNTHETIC_DEFAULT_MODEL_REF, - TOGETHER_DEFAULT_MODEL_REF, - VENICE_DEFAULT_MODEL_REF, - VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - XAI_DEFAULT_MODEL_REF, - XIAOMI_DEFAULT_MODEL_REF, } from "./onboard-auth.js"; -import type { AuthChoice, SecretInputMode } from "./onboard-types.js"; -import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./opencode-go-model-default.js"; -import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; +import type { SecretInputMode } from "./onboard-types.js"; type ApiKeyProviderConfigApplier = ( config: ApplyAuthChoiceParams["config"], @@ -90,7 +24,7 @@ type ApplyProviderDefaultModel = (args: { type ApplyApiKeyProviderParams = { params: ApplyAuthChoiceParams; - authChoice: AuthChoice; + authChoice: string; config: ApplyAuthChoiceParams["config"]; setConfig: (config: ApplyAuthChoiceParams["config"]) => void; getConfig: () => ApplyAuthChoiceParams["config"]; @@ -100,386 +34,6 @@ type ApplyApiKeyProviderParams = { getAgentModelOverride: () => string | undefined; }; -type SimpleApiKeyProviderFlow = { - provider: Parameters[0]["provider"]; - profileId: string; - expectedProviders: string[]; - envLabel: string; - promptMessage: string; - setCredential: ( - apiKey: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, - ) => void | Promise; - defaultModel: string; - applyDefaultConfig: ApiKeyProviderConfigApplier; - applyProviderConfig: ApiKeyProviderConfigApplier; - tokenProvider?: string; - normalize?: (value: string) => string; - validate?: (value: string) => string | undefined; - noteDefault?: string; - noteMessage?: string; - noteTitle?: string; -}; - -const VOLCENGINE_DEFAULT_MODEL_REF = "volcengine-plan/ark-code-latest"; -const BYTEPLUS_DEFAULT_MODEL_REF = "byteplus-plan/ark-code-latest"; - -const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial> = { - "ai-gateway-api-key": { - provider: "vercel-ai-gateway", - profileId: "vercel-ai-gateway:default", - expectedProviders: ["vercel-ai-gateway"], - envLabel: "AI_GATEWAY_API_KEY", - promptMessage: "Enter Vercel AI Gateway API key", - setCredential: setVercelAiGatewayApiKey, - defaultModel: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - applyDefaultConfig: applyVercelAiGatewayConfig, - applyProviderConfig: applyVercelAiGatewayProviderConfig, - noteDefault: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - }, - "moonshot-api-key": { - provider: "moonshot", - profileId: "moonshot:default", - expectedProviders: ["moonshot"], - envLabel: "MOONSHOT_API_KEY", - promptMessage: "Enter Moonshot API key", - setCredential: setMoonshotApiKey, - defaultModel: MOONSHOT_DEFAULT_MODEL_REF, - applyDefaultConfig: applyMoonshotConfig, - applyProviderConfig: applyMoonshotProviderConfig, - }, - "moonshot-api-key-cn": { - provider: "moonshot", - profileId: "moonshot:default", - expectedProviders: ["moonshot"], - envLabel: "MOONSHOT_API_KEY", - promptMessage: "Enter Moonshot API key (.cn)", - setCredential: setMoonshotApiKey, - defaultModel: MOONSHOT_DEFAULT_MODEL_REF, - applyDefaultConfig: applyMoonshotConfigCn, - applyProviderConfig: applyMoonshotProviderConfigCn, - }, - "kimi-code-api-key": { - provider: "kimi-coding", - profileId: "kimi-coding:default", - expectedProviders: ["kimi-code", "kimi-coding"], - envLabel: "KIMI_API_KEY", - promptMessage: "Enter Kimi Coding API key", - setCredential: setKimiCodingApiKey, - defaultModel: KIMI_CODING_MODEL_REF, - applyDefaultConfig: applyKimiCodeConfig, - applyProviderConfig: applyKimiCodeProviderConfig, - noteDefault: KIMI_CODING_MODEL_REF, - noteMessage: [ - "Kimi Coding uses a dedicated endpoint and API key.", - "Get your API key at: https://www.kimi.com/code/en", - ].join("\n"), - noteTitle: "Kimi Coding", - }, - "xiaomi-api-key": { - provider: "xiaomi", - profileId: "xiaomi:default", - expectedProviders: ["xiaomi"], - envLabel: "XIAOMI_API_KEY", - promptMessage: "Enter Xiaomi API key", - setCredential: setXiaomiApiKey, - defaultModel: XIAOMI_DEFAULT_MODEL_REF, - applyDefaultConfig: applyXiaomiConfig, - applyProviderConfig: applyXiaomiProviderConfig, - noteDefault: XIAOMI_DEFAULT_MODEL_REF, - }, - "xai-api-key": { - provider: "xai", - profileId: "xai:default", - expectedProviders: ["xai"], - envLabel: "XAI_API_KEY", - promptMessage: "Enter xAI API key", - setCredential: setXaiApiKey, - defaultModel: XAI_DEFAULT_MODEL_REF, - applyDefaultConfig: applyXaiConfig, - applyProviderConfig: applyXaiProviderConfig, - noteDefault: XAI_DEFAULT_MODEL_REF, - }, - "mistral-api-key": { - provider: "mistral", - profileId: "mistral:default", - expectedProviders: ["mistral"], - envLabel: "MISTRAL_API_KEY", - promptMessage: "Enter Mistral API key", - setCredential: setMistralApiKey, - defaultModel: MISTRAL_DEFAULT_MODEL_REF, - applyDefaultConfig: applyMistralConfig, - applyProviderConfig: applyMistralProviderConfig, - noteDefault: MISTRAL_DEFAULT_MODEL_REF, - }, - "venice-api-key": { - provider: "venice", - profileId: "venice:default", - expectedProviders: ["venice"], - envLabel: "VENICE_API_KEY", - promptMessage: "Enter Venice AI API key", - setCredential: setVeniceApiKey, - defaultModel: VENICE_DEFAULT_MODEL_REF, - applyDefaultConfig: applyVeniceConfig, - applyProviderConfig: applyVeniceProviderConfig, - noteDefault: VENICE_DEFAULT_MODEL_REF, - noteMessage: [ - "Venice AI provides privacy-focused inference with uncensored models.", - "Get your API key at: https://venice.ai/settings/api", - "Supports 'private' (fully private) and 'anonymized' (proxy) modes.", - ].join("\n"), - noteTitle: "Venice AI", - }, - "opencode-zen": { - provider: "opencode", - profileId: "opencode:default", - expectedProviders: ["opencode", "opencode-go"], - envLabel: "OPENCODE_API_KEY", - promptMessage: "Enter OpenCode API key", - setCredential: setOpencodeZenApiKey, - defaultModel: OPENCODE_ZEN_DEFAULT_MODEL, - applyDefaultConfig: applyOpencodeZenConfig, - applyProviderConfig: applyOpencodeZenProviderConfig, - noteDefault: OPENCODE_ZEN_DEFAULT_MODEL, - noteMessage: [ - "OpenCode uses one API key across the Zen and Go catalogs.", - "Zen provides access to Claude, GPT, Gemini, and more models.", - "Get your API key at: https://opencode.ai/auth", - "Choose the Zen catalog when you want the curated multi-model proxy.", - ].join("\n"), - noteTitle: "OpenCode", - }, - "opencode-go": { - provider: "opencode-go", - profileId: "opencode-go:default", - expectedProviders: ["opencode", "opencode-go"], - envLabel: "OPENCODE_API_KEY", - promptMessage: "Enter OpenCode API key", - setCredential: setOpencodeGoApiKey, - defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF, - applyDefaultConfig: applyOpencodeGoConfig, - applyProviderConfig: applyOpencodeGoProviderConfig, - noteDefault: OPENCODE_GO_DEFAULT_MODEL_REF, - noteMessage: [ - "OpenCode uses one API key across the Zen and Go catalogs.", - "Go provides access to Kimi, GLM, and MiniMax models through the Go catalog.", - "Get your API key at: https://opencode.ai/auth", - "Choose the Go catalog when you want the OpenCode-hosted Kimi/GLM/MiniMax lineup.", - ].join("\n"), - noteTitle: "OpenCode", - }, - "together-api-key": { - provider: "together", - profileId: "together:default", - expectedProviders: ["together"], - envLabel: "TOGETHER_API_KEY", - promptMessage: "Enter Together AI API key", - setCredential: setTogetherApiKey, - defaultModel: TOGETHER_DEFAULT_MODEL_REF, - applyDefaultConfig: applyTogetherConfig, - applyProviderConfig: applyTogetherProviderConfig, - noteDefault: TOGETHER_DEFAULT_MODEL_REF, - noteMessage: [ - "Together AI provides access to leading open-source models including Llama, DeepSeek, Qwen, and more.", - "Get your API key at: https://api.together.xyz/settings/api-keys", - ].join("\n"), - noteTitle: "Together AI", - }, - "qianfan-api-key": { - provider: "qianfan", - profileId: "qianfan:default", - expectedProviders: ["qianfan"], - envLabel: "QIANFAN_API_KEY", - promptMessage: "Enter QIANFAN API key", - setCredential: setQianfanApiKey, - defaultModel: QIANFAN_DEFAULT_MODEL_REF, - applyDefaultConfig: applyQianfanConfig, - applyProviderConfig: applyQianfanProviderConfig, - noteDefault: QIANFAN_DEFAULT_MODEL_REF, - noteMessage: [ - "Get your API key at: https://console.bce.baidu.com/qianfan/ais/console/apiKey", - "API key format: bce-v3/ALTAK-...", - ].join("\n"), - noteTitle: "QIANFAN", - }, - "kilocode-api-key": { - provider: "kilocode", - profileId: "kilocode:default", - expectedProviders: ["kilocode"], - envLabel: "KILOCODE_API_KEY", - promptMessage: "Enter Kilo Gateway API key", - setCredential: setKilocodeApiKey, - defaultModel: KILOCODE_DEFAULT_MODEL_REF, - applyDefaultConfig: applyKilocodeConfig, - applyProviderConfig: applyKilocodeProviderConfig, - noteDefault: KILOCODE_DEFAULT_MODEL_REF, - }, - "modelstudio-api-key-cn": { - provider: "modelstudio", - profileId: "modelstudio:default", - expectedProviders: ["modelstudio"], - envLabel: "MODELSTUDIO_API_KEY", - promptMessage: "Enter Alibaba Cloud Model Studio Coding Plan API key (China)", - setCredential: setModelStudioApiKey, - defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF, - applyDefaultConfig: applyModelStudioConfigCn, - applyProviderConfig: applyModelStudioProviderConfigCn, - noteDefault: MODELSTUDIO_DEFAULT_MODEL_REF, - noteMessage: [ - "Get your API key at: https://bailian.console.aliyun.com/", - "Endpoint: coding.dashscope.aliyuncs.com", - "Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.", - ].join("\n"), - noteTitle: "Alibaba Cloud Model Studio Coding Plan (China)", - normalize: (value) => String(value ?? "").trim(), - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }, - "modelstudio-api-key": { - provider: "modelstudio", - profileId: "modelstudio:default", - expectedProviders: ["modelstudio"], - envLabel: "MODELSTUDIO_API_KEY", - promptMessage: "Enter Alibaba Cloud Model Studio Coding Plan API key (Global/Intl)", - setCredential: setModelStudioApiKey, - defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF, - applyDefaultConfig: applyModelStudioConfig, - applyProviderConfig: applyModelStudioProviderConfig, - noteDefault: MODELSTUDIO_DEFAULT_MODEL_REF, - noteMessage: [ - "Get your API key at: https://bailian.console.aliyun.com/", - "Endpoint: coding-intl.dashscope.aliyuncs.com", - "Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.", - ].join("\n"), - noteTitle: "Alibaba Cloud Model Studio Coding Plan (Global/Intl)", - normalize: (value) => String(value ?? "").trim(), - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }, - "volcengine-api-key": { - provider: "volcengine", - profileId: "volcengine:default", - expectedProviders: ["volcengine"], - envLabel: "VOLCANO_ENGINE_API_KEY", - promptMessage: "Enter Volcano Engine API key", - setCredential: setVolcengineApiKey, - defaultModel: VOLCENGINE_DEFAULT_MODEL_REF, - applyDefaultConfig: (cfg) => applyPrimaryModel(cfg, VOLCENGINE_DEFAULT_MODEL_REF), - applyProviderConfig: (cfg) => - ensureModelAllowlistEntry({ - cfg, - modelRef: VOLCENGINE_DEFAULT_MODEL_REF, - }), - noteDefault: VOLCENGINE_DEFAULT_MODEL_REF, - }, - "byteplus-api-key": { - provider: "byteplus", - profileId: "byteplus:default", - expectedProviders: ["byteplus"], - envLabel: "BYTEPLUS_API_KEY", - promptMessage: "Enter BytePlus API key", - setCredential: setByteplusApiKey, - defaultModel: BYTEPLUS_DEFAULT_MODEL_REF, - applyDefaultConfig: (cfg) => applyPrimaryModel(cfg, BYTEPLUS_DEFAULT_MODEL_REF), - applyProviderConfig: (cfg) => - ensureModelAllowlistEntry({ - cfg, - modelRef: BYTEPLUS_DEFAULT_MODEL_REF, - }), - noteDefault: BYTEPLUS_DEFAULT_MODEL_REF, - }, - "synthetic-api-key": { - provider: "synthetic", - profileId: "synthetic:default", - expectedProviders: ["synthetic"], - envLabel: "SYNTHETIC_API_KEY", - promptMessage: "Enter Synthetic API key", - setCredential: setSyntheticApiKey, - defaultModel: SYNTHETIC_DEFAULT_MODEL_REF, - applyDefaultConfig: applySyntheticConfig, - applyProviderConfig: applySyntheticProviderConfig, - normalize: (value) => String(value ?? "").trim(), - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }, -}; - -async function applyApiKeyProviderWithDefaultModel({ - params, - config, - setConfig, - getConfig, - normalizedTokenProvider, - requestedSecretInputMode, - applyProviderDefaultModel, - getAgentModelOverride, - provider, - profileId, - expectedProviders, - envLabel, - promptMessage, - setCredential, - defaultModel, - applyDefaultConfig, - applyProviderConfig, - noteMessage, - noteTitle, - tokenProvider = normalizedTokenProvider, - normalize = normalizeApiKeyInput, - validate = validateApiKeyInput, - noteDefault = defaultModel, -}: ApplyApiKeyProviderParams & { - provider: Parameters[0]["provider"]; - profileId: string; - expectedProviders: string[]; - envLabel: string; - promptMessage: string; - setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => void | Promise; - defaultModel: string; - applyDefaultConfig: ApiKeyProviderConfigApplier; - applyProviderConfig: ApiKeyProviderConfigApplier; - noteMessage?: string; - noteTitle?: string; - tokenProvider?: string; - normalize?: (value: string) => string; - validate?: (value: string) => string | undefined; - noteDefault?: string; -}): Promise { - let nextConfig = config; - - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.token, - provider, - tokenProvider, - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders, - envLabel, - promptMessage, - setCredential: async (apiKey, mode) => { - await setCredential(apiKey, mode); - }, - noteMessage, - noteTitle, - normalize, - validate, - prompter: params.prompter, - }); - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider, - mode: "api_key", - }); - setConfig(nextConfig); - await applyProviderDefaultModel({ - defaultModel, - applyDefaultConfig, - applyProviderConfig, - noteDefault, - }); - - return { config: getConfig(), agentModelOverride: getAgentModelOverride() }; -} - export async function applyLiteLlmApiKeyProvider({ params, authChoice, @@ -544,50 +98,3 @@ export async function applyLiteLlmApiKeyProvider({ }); return { config: getConfig(), agentModelOverride: getAgentModelOverride() }; } - -export async function applySimpleAuthChoiceApiProvider({ - params, - authChoice, - config, - setConfig, - getConfig, - normalizedTokenProvider, - requestedSecretInputMode, - applyProviderDefaultModel, - getAgentModelOverride, -}: ApplyApiKeyProviderParams): Promise { - const simpleApiKeyProviderFlow = SIMPLE_API_KEY_PROVIDER_FLOWS[authChoice]; - if (!simpleApiKeyProviderFlow) { - return null; - } - - return await applyApiKeyProviderWithDefaultModel({ - params, - authChoice, - config, - setConfig, - getConfig, - normalizedTokenProvider, - requestedSecretInputMode, - applyProviderDefaultModel, - getAgentModelOverride, - provider: simpleApiKeyProviderFlow.provider, - profileId: simpleApiKeyProviderFlow.profileId, - expectedProviders: simpleApiKeyProviderFlow.expectedProviders, - envLabel: simpleApiKeyProviderFlow.envLabel, - promptMessage: simpleApiKeyProviderFlow.promptMessage, - setCredential: async (apiKey, mode) => - simpleApiKeyProviderFlow.setCredential(apiKey, params.agentDir, { - secretInputMode: mode ?? requestedSecretInputMode, - }), - defaultModel: simpleApiKeyProviderFlow.defaultModel, - applyDefaultConfig: simpleApiKeyProviderFlow.applyDefaultConfig, - applyProviderConfig: simpleApiKeyProviderFlow.applyProviderConfig, - noteDefault: simpleApiKeyProviderFlow.noteDefault, - noteMessage: simpleApiKeyProviderFlow.noteMessage, - noteTitle: simpleApiKeyProviderFlow.noteTitle, - tokenProvider: simpleApiKeyProviderFlow.tokenProvider, - normalize: simpleApiKeyProviderFlow.normalize, - validate: simpleApiKeyProviderFlow.validate, - }); -} diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index eef881c2b13..6376dd51c7d 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -1,71 +1,23 @@ -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; +import { resolveManifestProviderApiKeyChoice } from "../plugins/provider-auth-choices.js"; import { - normalizeSecretInputModeInput, - createAuthChoiceAgentModelNoter, createAuthChoiceDefaultModelApplierForMutableState, - ensureApiKeyFromOptionEnvOrPrompt, + normalizeSecretInputModeInput, normalizeTokenProviderInput, } from "./auth-choice.apply-helpers.js"; -import { - applyLiteLlmApiKeyProvider, - applySimpleAuthChoiceApiProvider, -} from "./auth-choice.apply.api-key-providers.js"; -import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; +import { applyLiteLlmApiKeyProvider } from "./auth-choice.apply.api-key-providers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoiceOpenRouter } from "./auth-choice.apply.openrouter.js"; -import { - applyGoogleGeminiModelDefault, - GOOGLE_GEMINI_DEFAULT_MODEL, -} from "./google-gemini-model-default.js"; -import { - applyAuthProfileConfig, - applyCloudflareAiGatewayConfig, - applyCloudflareAiGatewayProviderConfig, - applyZaiConfig, - applyZaiProviderConfig, - CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - setCloudflareAiGatewayConfig, - setGeminiApiKey, - setZaiApiKey, - ZAI_DEFAULT_MODEL_REF, -} from "./onboard-auth.js"; import type { AuthChoice } from "./onboard-types.js"; -import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; -const API_KEY_TOKEN_PROVIDER_AUTH_CHOICE: Record = { - openrouter: "openrouter-api-key", +const CORE_API_KEY_TOKEN_PROVIDER_AUTH_CHOICES: Partial> = { litellm: "litellm-api-key", - "vercel-ai-gateway": "ai-gateway-api-key", - "cloudflare-ai-gateway": "cloudflare-ai-gateway-api-key", - moonshot: "moonshot-api-key", - "kimi-code": "kimi-code-api-key", - "kimi-coding": "kimi-code-api-key", - google: "gemini-api-key", - zai: "zai-api-key", - xiaomi: "xiaomi-api-key", - synthetic: "synthetic-api-key", - venice: "venice-api-key", - together: "together-api-key", - huggingface: "huggingface-api-key", - mistral: "mistral-api-key", - opencode: "opencode-zen", - "opencode-go": "opencode-go", - kilocode: "kilocode-api-key", - qianfan: "qianfan-api-key", -}; - -const ZAI_AUTH_CHOICE_ENDPOINT: Partial< - Record -> = { - "zai-coding-global": "coding-global", - "zai-coding-cn": "coding-cn", - "zai-global": "global", - "zai-cn": "cn", }; export function normalizeApiKeyTokenProviderAuthChoice(params: { authChoice: AuthChoice; tokenProvider?: string; + config?: ApplyAuthChoiceParams["config"]; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; }): AuthChoice { if (params.authChoice !== "apiKey" || !params.tokenProvider) { return params.authChoice; @@ -74,10 +26,16 @@ export function normalizeApiKeyTokenProviderAuthChoice(params: { if (!normalizedTokenProvider) { return params.authChoice; } - if (normalizedTokenProvider === "anthropic" || normalizedTokenProvider === "openai") { - return params.authChoice; - } - return API_KEY_TOKEN_PROVIDER_AUTH_CHOICE[normalizedTokenProvider] ?? params.authChoice; + return ( + (resolveManifestProviderApiKeyChoice({ + providerId: normalizedTokenProvider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + })?.choiceId as AuthChoice | undefined) ?? + CORE_API_KEY_TOKEN_PROVIDER_AUTH_CHOICES[normalizedTokenProvider] ?? + params.authChoice + ); } export async function applyAuthChoiceApiProviders( @@ -85,7 +43,6 @@ export async function applyAuthChoiceApiProviders( ): Promise { let nextConfig = params.config; let agentModelOverride: string | undefined; - const noteAgentModel = createAuthChoiceAgentModelNoter(params); const applyProviderDefaultModel = createAuthChoiceDefaultModelApplierForMutableState( params, () => nextConfig, @@ -97,14 +54,12 @@ export async function applyAuthChoiceApiProviders( const authChoice = normalizeApiKeyTokenProviderAuthChoice({ authChoice: params.authChoice, tokenProvider: params.opts?.tokenProvider, + config: params.config, + env: process.env, }); const normalizedTokenProvider = normalizeTokenProviderInput(params.opts?.tokenProvider); const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - if (authChoice === "openrouter-api-key") { - return applyAuthChoiceOpenRouter(params); - } - const litellmResult = await applyLiteLlmApiKeyProvider({ params, authChoice, @@ -120,218 +75,5 @@ export async function applyAuthChoiceApiProviders( return litellmResult; } - const simpleProviderResult = await applySimpleAuthChoiceApiProvider({ - params, - authChoice, - config: nextConfig, - setConfig: (config) => (nextConfig = config), - getConfig: () => nextConfig, - normalizedTokenProvider, - requestedSecretInputMode, - applyProviderDefaultModel, - getAgentModelOverride: () => agentModelOverride, - }); - if (simpleProviderResult) { - return simpleProviderResult; - } - - if (authChoice === "cloudflare-ai-gateway-api-key") { - let accountId = params.opts?.cloudflareAiGatewayAccountId?.trim() ?? ""; - let gatewayId = params.opts?.cloudflareAiGatewayGatewayId?.trim() ?? ""; - - const ensureAccountGateway = async () => { - if (!accountId) { - const value = await params.prompter.text({ - message: "Enter Cloudflare Account ID", - validate: (val) => (String(val ?? "").trim() ? undefined : "Account ID is required"), - }); - accountId = String(value ?? "").trim(); - } - if (!gatewayId) { - const value = await params.prompter.text({ - message: "Enter Cloudflare AI Gateway ID", - validate: (val) => (String(val ?? "").trim() ? undefined : "Gateway ID is required"), - }); - gatewayId = String(value ?? "").trim(); - } - }; - - await ensureAccountGateway(); - - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.cloudflareAiGatewayApiKey, - tokenProvider: "cloudflare-ai-gateway", - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders: ["cloudflare-ai-gateway"], - provider: "cloudflare-ai-gateway", - envLabel: "CLOUDFLARE_AI_GATEWAY_API_KEY", - promptMessage: "Enter Cloudflare AI Gateway API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setCloudflareAiGatewayConfig(accountId, gatewayId, apiKey, params.agentDir, { - secretInputMode: mode, - }), - }); - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "cloudflare-ai-gateway:default", - provider: "cloudflare-ai-gateway", - mode: "api_key", - }); - await applyProviderDefaultModel({ - defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - applyDefaultConfig: (cfg) => - applyCloudflareAiGatewayConfig(cfg, { - accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, - gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, - }), - applyProviderConfig: (cfg) => - applyCloudflareAiGatewayProviderConfig(cfg, { - accountId: accountId || params.opts?.cloudflareAiGatewayAccountId, - gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId, - }), - noteDefault: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - }); - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "gemini-api-key") { - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.token, - provider: "google", - tokenProvider: normalizedTokenProvider, - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders: ["google"], - envLabel: "GEMINI_API_KEY", - promptMessage: "Enter Gemini API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setGeminiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - }); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "google:default", - provider: "google", - mode: "api_key", - }); - if (params.setDefaultModel) { - const applied = applyGoogleGeminiModelDefault(nextConfig); - nextConfig = applied.next; - if (applied.changed) { - await params.prompter.note( - `Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`, - "Model configured", - ); - } - } else { - agentModelOverride = GOOGLE_GEMINI_DEFAULT_MODEL; - await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL); - } - return { config: nextConfig, agentModelOverride }; - } - - if ( - authChoice === "zai-api-key" || - authChoice === "zai-coding-global" || - authChoice === "zai-coding-cn" || - authChoice === "zai-global" || - authChoice === "zai-cn" - ) { - let endpoint = ZAI_AUTH_CHOICE_ENDPOINT[authChoice]; - - const apiKey = await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.token, - provider: "zai", - tokenProvider: normalizedTokenProvider, - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders: ["zai"], - envLabel: "ZAI_API_KEY", - promptMessage: "Enter Z.AI API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setZaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - }); - - let modelIdOverride: string | undefined; - if (endpoint) { - const detected = await detectZaiEndpoint({ apiKey, endpoint }); - if (detected) { - modelIdOverride = detected.modelId; - await params.prompter.note(detected.note, "Z.AI endpoint"); - } - } else { - // zai-api-key: auto-detect endpoint + choose a working default model. - const detected = await detectZaiEndpoint({ apiKey }); - if (detected) { - endpoint = detected.endpoint; - modelIdOverride = detected.modelId; - await params.prompter.note(detected.note, "Z.AI endpoint"); - } else { - endpoint = await params.prompter.select({ - message: "Select Z.AI endpoint", - options: [ - { - value: "coding-global", - label: "Coding-Plan-Global", - hint: "GLM Coding Plan Global (api.z.ai)", - }, - { - value: "coding-cn", - label: "Coding-Plan-CN", - hint: "GLM Coding Plan CN (open.bigmodel.cn)", - }, - { - value: "global", - label: "Global", - hint: "Z.AI Global (api.z.ai)", - }, - { - value: "cn", - label: "CN", - hint: "Z.AI CN (open.bigmodel.cn)", - }, - ], - initialValue: "global", - }); - } - } - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "zai:default", - provider: "zai", - mode: "api_key", - }); - - const defaultModel = modelIdOverride ? `zai/${modelIdOverride}` : ZAI_DEFAULT_MODEL_REF; - await applyProviderDefaultModel({ - defaultModel, - applyDefaultConfig: (config) => - applyZaiConfig(config, { - endpoint, - ...(modelIdOverride ? { modelId: modelIdOverride } : {}), - }), - applyProviderConfig: (config) => - applyZaiProviderConfig(config, { - endpoint, - ...(modelIdOverride ? { modelId: modelIdOverride } : {}), - }), - noteDefault: defaultModel, - }); - - return { config: nextConfig, agentModelOverride }; - } - - if (authChoice === "huggingface-api-key") { - return applyAuthChoiceHuggingface({ ...params, authChoice }); - } - return null; } diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts deleted file mode 100644 index 5b55252067f..00000000000 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; -import { - createAuthTestLifecycle, - createExitThrowingRuntime, - createWizardPrompter, - readAuthProfilesForAgent, - setupAuthTestEnv, -} from "./test-wizard-helpers.js"; - -function createHuggingfacePrompter(params: { - text: WizardPrompter["text"]; - select: WizardPrompter["select"]; - confirm?: WizardPrompter["confirm"]; - note?: WizardPrompter["note"]; -}): WizardPrompter { - const overrides: Partial = { - text: params.text, - select: params.select, - }; - if (params.confirm) { - overrides.confirm = params.confirm; - } - if (params.note) { - overrides.note = params.note; - } - return createWizardPrompter(overrides, { defaultSelect: "" }); -} - -type ApplyHuggingfaceParams = Parameters[0]; - -async function runHuggingfaceApply( - params: Omit & - Partial>, -) { - return await applyAuthChoiceHuggingface({ - authChoice: "huggingface-api-key", - setDefaultModel: params.setDefaultModel ?? true, - ...params, - }); -} - -describe("applyAuthChoiceHuggingface", () => { - const lifecycle = createAuthTestLifecycle([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - "HF_TOKEN", - "HUGGINGFACE_HUB_TOKEN", - ]); - - async function setupTempState() { - const env = await setupAuthTestEnv("openclaw-hf-"); - lifecycle.setStateDir(env.stateDir); - return env.agentDir; - } - - async function readAuthProfiles(agentDir: string) { - return await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - } - - afterEach(async () => { - await lifecycle.cleanup(); - }); - - it("returns null when authChoice is not huggingface-api-key", async () => { - const result = await applyAuthChoiceHuggingface({ - authChoice: "openrouter-api-key", - config: {}, - prompter: {} as WizardPrompter, - runtime: createExitThrowingRuntime(), - setDefaultModel: false, - }); - expect(result).toBeNull(); - }); - - it("prompts for key and model, then writes config and auth profile", async () => { - const agentDir = await setupTempState(); - - const text = vi.fn().mockResolvedValue("hf-test-token"); - const select: WizardPrompter["select"] = vi.fn( - async (params) => params.options?.[0]?.value as never, - ); - const prompter = createHuggingfacePrompter({ text, select }); - const runtime = createExitThrowingRuntime(); - - const result = await runHuggingfaceApply({ - config: {}, - prompter, - runtime, - }); - - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["huggingface:default"]).toMatchObject({ - provider: "huggingface", - mode: "api_key", - }); - expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toMatch( - /^huggingface\/.+/, - ); - expect(text).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining("Hugging Face") }), - ); - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ message: "Default Hugging Face model" }), - ); - - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-test-token"); - }); - - it.each([ - { - caseName: "does not prompt to reuse env token when opts.token already provided", - tokenProvider: "huggingface", - token: "hf-opts-token", - envToken: "hf-env-token", - }, - { - caseName: "accepts mixed-case tokenProvider from opts without prompting", - tokenProvider: " HuGgInGfAcE ", - token: "hf-opts-mixed", - envToken: undefined, - }, - ])("$caseName", async ({ tokenProvider, token, envToken }) => { - const agentDir = await setupTempState(); - if (envToken) { - process.env.HF_TOKEN = envToken; - } else { - delete process.env.HF_TOKEN; - } - delete process.env.HUGGINGFACE_HUB_TOKEN; - - const text = vi.fn().mockResolvedValue("hf-text-token"); - const select: WizardPrompter["select"] = vi.fn( - async (params) => params.options?.[0]?.value as never, - ); - const confirm = vi.fn(async () => true); - const prompter = createHuggingfacePrompter({ text, select, confirm }); - const runtime = createExitThrowingRuntime(); - - const result = await runHuggingfaceApply({ - config: {}, - prompter, - runtime, - opts: { - tokenProvider, - token, - }, - }); - - expect(result).not.toBeNull(); - expect(confirm).not.toHaveBeenCalled(); - expect(text).not.toHaveBeenCalled(); - - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["huggingface:default"]?.key).toBe(token); - }); - - it("notes when selected Hugging Face model uses a locked router policy", async () => { - await setupTempState(); - delete process.env.HF_TOKEN; - delete process.env.HUGGINGFACE_HUB_TOKEN; - - const text = vi.fn().mockResolvedValue("hf-test-token"); - const select: WizardPrompter["select"] = vi.fn(async (params) => { - const options = (params.options ?? []) as Array<{ value: string }>; - const cheapest = options.find((option) => option.value.endsWith(":cheapest")); - return (cheapest?.value ?? options[0]?.value ?? "") as never; - }); - const note: WizardPrompter["note"] = vi.fn(async () => {}); - const prompter = createHuggingfacePrompter({ text, select, note }); - const runtime = createExitThrowingRuntime(); - - const result = await runHuggingfaceApply({ - config: {}, - prompter, - runtime, - }); - - expect(result).not.toBeNull(); - expect(String(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model))).toContain( - ":cheapest", - ); - expect(note).toHaveBeenCalledWith( - "Provider locked — router will choose backend by cost or speed.", - "Hugging Face", - ); - }); -}); diff --git a/src/commands/auth-choice.apply.huggingface.ts b/src/commands/auth-choice.apply.huggingface.ts deleted file mode 100644 index 91bfd533cb0..00000000000 --- a/src/commands/auth-choice.apply.huggingface.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { - discoverHuggingfaceModels, - isHuggingfacePolicyLocked, -} from "../agents/huggingface-models.js"; -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; -import { - createAuthChoiceAgentModelNoter, - ensureApiKeyFromOptionEnvOrPrompt, - normalizeSecretInputModeInput, -} from "./auth-choice.apply-helpers.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; -import { ensureModelAllowlistEntry } from "./model-allowlist.js"; -import { - applyAuthProfileConfig, - applyHuggingfaceProviderConfig, - setHuggingfaceApiKey, - HUGGINGFACE_DEFAULT_MODEL_REF, -} from "./onboard-auth.js"; - -export async function applyAuthChoiceHuggingface( - params: ApplyAuthChoiceParams, -): Promise { - if (params.authChoice !== "huggingface-api-key") { - return null; - } - - let nextConfig = params.config; - let agentModelOverride: string | undefined; - const noteAgentModel = createAuthChoiceAgentModelNoter(params); - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - - const hfKey = await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.token, - tokenProvider: params.opts?.tokenProvider, - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders: ["huggingface"], - provider: "huggingface", - envLabel: "Hugging Face token", - promptMessage: "Enter Hugging Face API key (HF token)", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setHuggingfaceApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - noteMessage: [ - "Hugging Face Inference Providers offer OpenAI-compatible chat completions.", - "Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').", - ].join("\n"), - noteTitle: "Hugging Face", - }); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "huggingface:default", - provider: "huggingface", - mode: "api_key", - }); - - const models = await discoverHuggingfaceModels(hfKey); - const modelRefPrefix = "huggingface/"; - const options: { value: string; label: string }[] = []; - for (const m of models) { - const baseRef = `${modelRefPrefix}${m.id}`; - const label = m.name ?? m.id; - options.push({ value: baseRef, label }); - options.push({ value: `${baseRef}:cheapest`, label: `${label} (cheapest)` }); - options.push({ value: `${baseRef}:fastest`, label: `${label} (fastest)` }); - } - const defaultRef = HUGGINGFACE_DEFAULT_MODEL_REF; - options.sort((a, b) => { - if (a.value === defaultRef) { - return -1; - } - if (b.value === defaultRef) { - return 1; - } - return a.label.localeCompare(b.label, undefined, { sensitivity: "base" }); - }); - const selectedModelRef = - options.length === 0 - ? defaultRef - : options.length === 1 - ? options[0].value - : await params.prompter.select({ - message: "Default Hugging Face model", - options, - initialValue: options.some((o) => o.value === defaultRef) - ? defaultRef - : options[0].value, - }); - - if (isHuggingfacePolicyLocked(selectedModelRef)) { - await params.prompter.note( - "Provider locked — router will choose backend by cost or speed.", - "Hugging Face", - ); - } - - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: selectedModelRef, - applyDefaultConfig: (config) => { - const withProvider = applyHuggingfaceProviderConfig(config); - const existingModel = withProvider.agents?.defaults?.model; - const withPrimary = { - ...withProvider, - agents: { - ...withProvider.agents, - defaults: { - ...withProvider.agents?.defaults, - model: { - ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : {}), - primary: selectedModelRef, - }, - }, - }, - }; - return ensureModelAllowlistEntry({ - cfg: withPrimary, - modelRef: selectedModelRef, - }); - }, - applyProviderConfig: applyHuggingfaceProviderConfig, - noteDefault: selectedModelRef, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - - return { config: nextConfig, agentModelOverride }; -} diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts deleted file mode 100644 index 9b5442b108c..00000000000 --- a/src/commands/auth-choice.apply.minimax.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; -import { - createAuthTestLifecycle, - createExitThrowingRuntime, - createWizardPrompter, - readAuthProfilesForAgent, - setupAuthTestEnv, -} from "./test-wizard-helpers.js"; - -describe("applyAuthChoiceMiniMax", () => { - const lifecycle = createAuthTestLifecycle([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - "MINIMAX_API_KEY", - "MINIMAX_OAUTH_TOKEN", - ]); - - async function setupTempState() { - const env = await setupAuthTestEnv("openclaw-minimax-"); - lifecycle.setStateDir(env.stateDir); - return env.agentDir; - } - - async function readAuthProfiles(agentDir: string) { - return await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - } - - function resetMiniMaxEnv(): void { - delete process.env.MINIMAX_API_KEY; - delete process.env.MINIMAX_OAUTH_TOKEN; - } - - async function runMiniMaxChoice(params: { - authChoice: Parameters[0]["authChoice"]; - opts?: Parameters[0]["opts"]; - env?: { apiKey?: string }; - prompterText?: () => Promise; - }) { - const agentDir = await setupTempState(); - resetMiniMaxEnv(); - if (params.env?.apiKey !== undefined) { - process.env.MINIMAX_API_KEY = params.env.apiKey; - } - - const text = vi.fn(async () => "should-not-be-used"); - const confirm = vi.fn(async () => true); - const result = await applyAuthChoiceMiniMax({ - authChoice: params.authChoice, - config: {}, - // Pass select: undefined so ref-mode uses the non-interactive fallback (same as old test behavior). - prompter: createWizardPrompter({ - text: params.prompterText ?? text, - confirm, - select: undefined, - }), - runtime: createExitThrowingRuntime(), - setDefaultModel: true, - ...(params.opts ? { opts: params.opts } : {}), - }); - - return { agentDir, result, text, confirm }; - } - - afterEach(async () => { - await lifecycle.cleanup(); - }); - - it("returns null for unrelated authChoice", async () => { - const result = await applyAuthChoiceMiniMax({ - authChoice: "openrouter-api-key", - config: {}, - prompter: createWizardPrompter({}), - runtime: createExitThrowingRuntime(), - setDefaultModel: true, - }); - - expect(result).toBeNull(); - }); - - it.each([ - { - caseName: "uses opts token for minimax-global-api without prompt", - authChoice: "minimax-global-api" as const, - tokenProvider: "minimax", - token: "mm-opts-token", - profileId: "minimax:global", - expectedModel: "minimax/MiniMax-M2.5", - }, - { - caseName: "uses opts token for minimax-cn-api with trimmed/case-insensitive tokenProvider", - authChoice: "minimax-cn-api" as const, - tokenProvider: " MINIMAX ", - token: "mm-cn-opts-token", - profileId: "minimax:cn", - expectedModel: "minimax/MiniMax-M2.5", - }, - ])("$caseName", async ({ authChoice, tokenProvider, token, profileId, expectedModel }) => { - const { agentDir, result, text, confirm } = await runMiniMaxChoice({ - authChoice, - opts: { tokenProvider, token }, - }); - - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.[profileId]).toMatchObject({ - provider: "minimax", - mode: "api_key", - }); - expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( - expectedModel, - ); - expect(text).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); - - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.[profileId]?.key).toBe(token); - }); - - it.each([ - { - name: "uses env token for minimax-cn-api as plaintext by default", - opts: undefined, - expectKey: "mm-env-token", - expectKeyRef: undefined, - expectConfirmCalls: 1, - }, - { - name: "uses env token for minimax-cn-api as keyRef in ref mode", - opts: { secretInputMode: "ref" as const }, // pragma: allowlist secret - expectKey: undefined, - expectKeyRef: { - source: "env", - provider: "default", - id: "MINIMAX_API_KEY", - }, - expectConfirmCalls: 0, - }, - ])("$name", async ({ opts, expectKey, expectKeyRef, expectConfirmCalls }) => { - const { agentDir, result, text, confirm } = await runMiniMaxChoice({ - authChoice: "minimax-cn-api", - opts, - env: { apiKey: "mm-env-token" }, // pragma: allowlist secret - }); - - expect(result).not.toBeNull(); - if (!opts) { - expect(result?.config.auth?.profiles?.["minimax:cn"]).toMatchObject({ - provider: "minimax", - mode: "api_key", - }); - expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( - "minimax/MiniMax-M2.5", - ); - } - expect(text).not.toHaveBeenCalled(); - expect(confirm).toHaveBeenCalledTimes(expectConfirmCalls); - - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["minimax:cn"]?.key).toBe(expectKey); - if (expectKeyRef) { - expect(parsed.profiles?.["minimax:cn"]?.keyRef).toEqual(expectKeyRef); - } else { - expect(parsed.profiles?.["minimax:cn"]?.keyRef).toBeUndefined(); - } - }); - - it("minimax-global-api uses minimax:global profile and minimax/MiniMax-M2.5 model", async () => { - const { agentDir, result, text, confirm } = await runMiniMaxChoice({ - authChoice: "minimax-global-api", - opts: { - tokenProvider: "minimax", - token: "mm-global-token", - }, - }); - - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["minimax:global"]).toMatchObject({ - provider: "minimax", - mode: "api_key", - }); - expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( - "minimax/MiniMax-M2.5", - ); - expect(result?.config.models?.providers?.minimax?.baseUrl).toContain("minimax.io"); - expect(text).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); - - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["minimax:global"]?.key).toBe("mm-global-token"); - }); - - it("minimax-cn-api sets CN baseUrl", async () => { - const { result } = await runMiniMaxChoice({ - authChoice: "minimax-cn-api", - opts: { - tokenProvider: "minimax", - token: "mm-cn-token", - }, - }); - - expect(result).not.toBeNull(); - expect(result?.config.models?.providers?.minimax?.baseUrl).toContain("minimaxi.com"); - }); -}); diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts deleted file mode 100644 index 6438b94c043..00000000000 --- a/src/commands/auth-choice.apply.minimax.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; -import { - createAuthChoiceDefaultModelApplierForMutableState, - ensureApiKeyFromOptionEnvOrPrompt, - normalizeSecretInputModeInput, -} from "./auth-choice.apply-helpers.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; -import { - applyAuthProfileConfig, - applyMinimaxApiConfig, - applyMinimaxApiConfigCn, - applyMinimaxApiProviderConfig, - applyMinimaxApiProviderConfigCn, - setMinimaxApiKey, -} from "./onboard-auth.js"; - -export async function applyAuthChoiceMiniMax( - params: ApplyAuthChoiceParams, -): Promise { - // OAuth paths — delegate to plugin, no API key needed - if (params.authChoice === "minimax-global-oauth") { - return await applyAuthChoicePluginProvider(params, { - authChoice: "minimax-global-oauth", - pluginId: "minimax", - providerId: "minimax-portal", - methodId: "oauth", - label: "MiniMax", - }); - } - - if (params.authChoice === "minimax-cn-oauth") { - return await applyAuthChoicePluginProvider(params, { - authChoice: "minimax-cn-oauth", - pluginId: "minimax", - providerId: "minimax-portal", - methodId: "oauth-cn", - label: "MiniMax CN", - }); - } - - // API key paths - if (params.authChoice === "minimax-global-api" || params.authChoice === "minimax-cn-api") { - const isCn = params.authChoice === "minimax-cn-api"; - const profileId = isCn ? "minimax:cn" : "minimax:global"; - const keyLink = isCn - ? "https://platform.minimaxi.com/user-center/basic-information/interface-key" - : "https://platform.minimax.io/user-center/basic-information/interface-key"; - const promptMessage = `Enter MiniMax ${isCn ? "CN " : ""}API key (sk-api- or sk-cp-)\n${keyLink}`; - - let nextConfig = params.config; - let agentModelOverride: string | undefined; - const applyProviderDefaultModel = createAuthChoiceDefaultModelApplierForMutableState( - params, - () => nextConfig, - (config) => (nextConfig = config), - () => agentModelOverride, - (model) => (agentModelOverride = model), - ); - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - - // Warn when both Global and CN share the same `minimax` provider entry — configuring one - // overwrites the other's baseUrl. Only show when the other profile is already present. - const otherProfileId = isCn ? "minimax:global" : "minimax:cn"; - const hasOtherProfile = Boolean(nextConfig.auth?.profiles?.[otherProfileId]); - const noteMessage = hasOtherProfile - ? `Note: Global and CN both use the "minimax" provider entry. Saving this key will overwrite the existing ${isCn ? "Global" : "CN"} endpoint (${otherProfileId}).` - : undefined; - - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.token, - tokenProvider: params.opts?.tokenProvider, - secretInputMode: requestedSecretInputMode, - config: nextConfig, - // Accept "minimax-cn" as a legacy tokenProvider alias for the CN path. - expectedProviders: isCn ? ["minimax", "minimax-cn"] : ["minimax"], - provider: "minimax", - envLabel: "MINIMAX_API_KEY", - promptMessage, - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - noteMessage, - setCredential: async (apiKey, mode) => - setMinimaxApiKey(apiKey, params.agentDir, profileId, { secretInputMode: mode }), - }); - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "minimax", - mode: "api_key", - }); - - await applyProviderDefaultModel({ - defaultModel: "minimax/MiniMax-M2.5", - applyDefaultConfig: (config) => - isCn ? applyMinimaxApiConfigCn(config) : applyMinimaxApiConfig(config), - applyProviderConfig: (config) => - isCn ? applyMinimaxApiProviderConfigCn(config) : applyMinimaxApiProviderConfig(config), - }); - - return { config: nextConfig, agentModelOverride }; - } - - return null; -} diff --git a/src/commands/auth-choice.apply.openrouter.ts b/src/commands/auth-choice.apply.openrouter.ts deleted file mode 100644 index 4cf01762615..00000000000 --- a/src/commands/auth-choice.apply.openrouter.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js"; -import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js"; -import { - createAuthChoiceAgentModelNoter, - ensureApiKeyFromOptionEnvOrPrompt, - normalizeSecretInputModeInput, -} from "./auth-choice.apply-helpers.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; -import { - applyAuthProfileConfig, - applyOpenrouterConfig, - applyOpenrouterProviderConfig, - setOpenrouterApiKey, - OPENROUTER_DEFAULT_MODEL_REF, -} from "./onboard-auth.js"; - -export async function applyAuthChoiceOpenRouter( - params: ApplyAuthChoiceParams, -): Promise { - let nextConfig = params.config; - let agentModelOverride: string | undefined; - const noteAgentModel = createAuthChoiceAgentModelNoter(params); - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - - const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); - const profileOrder = resolveAuthProfileOrder({ - cfg: nextConfig, - store, - provider: "openrouter", - }); - const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); - const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; - let profileId = "openrouter:default"; - let mode: "api_key" | "oauth" | "token" = "api_key"; - let hasCredential = false; - - if (existingProfileId && existingCred?.type) { - profileId = existingProfileId; - mode = - existingCred.type === "oauth" ? "oauth" : existingCred.type === "token" ? "token" : "api_key"; - hasCredential = true; - } - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") { - await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir, { - secretInputMode: requestedSecretInputMode, - }); - hasCredential = true; - } - - if (!hasCredential) { - await ensureApiKeyFromOptionEnvOrPrompt({ - token: params.opts?.token, - tokenProvider: params.opts?.tokenProvider, - secretInputMode: requestedSecretInputMode, - config: nextConfig, - expectedProviders: ["openrouter"], - provider: "openrouter", - envLabel: "OPENROUTER_API_KEY", - promptMessage: "Enter OpenRouter API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => - setOpenrouterApiKey(apiKey, params.agentDir, { secretInputMode: mode }), - }); - hasCredential = true; - } - - if (hasCredential) { - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "openrouter", - mode, - }); - } - - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: OPENROUTER_DEFAULT_MODEL_REF, - applyDefaultConfig: applyOpenrouterConfig, - applyProviderConfig: applyOpenrouterProviderConfig, - noteDefault: OPENROUTER_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - - return { config: nextConfig, agentModelOverride }; -} diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index 76994d27b32..746eb219fff 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -29,6 +29,38 @@ export type PluginProviderAuthChoiceOptions = { label: string; }; +function restoreConfiguredPrimaryModel( + nextConfig: ApplyAuthChoiceParams["config"], + originalConfig: ApplyAuthChoiceParams["config"], +): ApplyAuthChoiceParams["config"] { + const originalModel = originalConfig.agents?.defaults?.model; + const nextAgents = nextConfig.agents; + const nextDefaults = nextAgents?.defaults; + if (!nextDefaults) { + return nextConfig; + } + if (originalModel !== undefined) { + return { + ...nextConfig, + agents: { + ...nextAgents, + defaults: { + ...nextDefaults, + model: originalModel, + }, + }, + }; + } + const { model: _model, ...restDefaults } = nextDefaults; + return { + ...nextConfig, + agents: { + ...nextAgents, + defaults: restDefaults, + }, + }; +} + async function loadPluginProviderRuntime() { return import("./auth-choice.apply.plugin-provider.runtime.js"); } @@ -140,14 +172,15 @@ export async function applyAuthChoiceLoadedPluginProvider( agentId: params.agentId, workspaceDir, secretInputMode: params.opts?.secretInputMode, - allowSecretRefPrompt: true, + allowSecretRefPrompt: false, opts: params.opts, }); + let nextConfig = applied.config; let agentModelOverride: string | undefined; if (applied.defaultModel) { if (params.setDefaultModel) { - const nextConfig = applyDefaultModel(applied.config, applied.defaultModel); + nextConfig = applyDefaultModel(nextConfig, applied.defaultModel); await runProviderModelSelectedHook({ config: nextConfig, model: applied.defaultModel, @@ -161,10 +194,11 @@ export async function applyAuthChoiceLoadedPluginProvider( ); return { config: nextConfig }; } + nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config); agentModelOverride = applied.defaultModel; } - return { config: applied.config, agentModelOverride }; + return { config: nextConfig, agentModelOverride }; } export async function applyAuthChoicePluginProvider( @@ -225,7 +259,7 @@ export async function applyAuthChoicePluginProvider( agentId, workspaceDir, secretInputMode: params.opts?.secretInputMode, - allowSecretRefPrompt: true, + allowSecretRefPrompt: false, opts: params.opts, }); nextConfig = applied.config; @@ -245,7 +279,10 @@ export async function applyAuthChoicePluginProvider( `Default model set to ${applied.defaultModel}`, "Model configured", ); - } else if (params.agentId) { + } else { + nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config); + } + if (!params.setDefaultModel && params.agentId) { agentModelOverride = applied.defaultModel; await params.prompter.note( `Default model set to ${applied.defaultModel} for agent "${params.agentId}".`, diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index f8dab665f23..cf96b8f8905 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -4,7 +4,6 @@ import type { WizardPrompter } from "../wizard/prompts.js"; import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api-providers.js"; -import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; import { applyAuthChoiceLoadedPluginProvider } from "./auth-choice.apply.plugin-provider.js"; import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; @@ -33,6 +32,8 @@ export async function applyAuthChoice( const normalizedProviderAuthChoice = normalizeApiKeyTokenProviderAuthChoice({ authChoice: normalizedAuthChoice, tokenProvider: params.opts?.tokenProvider, + config: params.config, + env: process.env, }); const normalizedParams = normalizedProviderAuthChoice === params.authChoice @@ -42,7 +43,6 @@ export async function applyAuthChoice( applyAuthChoiceLoadedPluginProvider, applyAuthChoiceOAuth, applyAuthChoiceApiProviders, - applyAuthChoiceMiniMax, ]; for (const handler of handlers) { diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 52a6211a87f..f6ca9d29332 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -2,11 +2,29 @@ import fs from "node:fs/promises"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import anthropicPlugin from "../../extensions/anthropic/index.js"; +import cloudflareAiGatewayPlugin from "../../extensions/cloudflare-ai-gateway/index.js"; +import googlePlugin from "../../extensions/google/index.js"; import huggingfacePlugin from "../../extensions/huggingface/index.js"; import kimiCodingPlugin from "../../extensions/kimi-coding/index.js"; +import minimaxPlugin from "../../extensions/minimax/index.js"; +import mistralPlugin from "../../extensions/mistral/index.js"; +import moonshotPlugin from "../../extensions/moonshot/index.js"; import ollamaPlugin from "../../extensions/ollama/index.js"; import openAIPlugin from "../../extensions/openai/index.js"; +import opencodeGoPlugin from "../../extensions/opencode-go/index.js"; +import opencodePlugin from "../../extensions/opencode/index.js"; +import openrouterPlugin from "../../extensions/openrouter/index.js"; +import qianfanPlugin from "../../extensions/qianfan/index.js"; +import qwenPortalAuthPlugin from "../../extensions/qwen-portal-auth/index.js"; +import syntheticPlugin from "../../extensions/synthetic/index.js"; import togetherPlugin from "../../extensions/together/index.js"; +import venicePlugin from "../../extensions/venice/index.js"; +import vercelAiGatewayPlugin from "../../extensions/vercel-ai-gateway/index.js"; +import xaiPlugin from "../../extensions/xai/index.js"; +import xiaomiPlugin from "../../extensions/xiaomi/index.js"; +import { setDetectZaiEndpointForTesting } from "../../extensions/zai/detect.js"; +import zaiPlugin from "../../extensions/zai/index.js"; +import { resolveAgentDir } from "../agents/agent-scope.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { ProviderPlugin } from "../plugins/types.js"; import { createCapturedPluginRegistration } from "../test-utils/plugin-registration.js"; @@ -67,11 +85,27 @@ function createDefaultProviderPlugins() { const captured = createCapturedPluginRegistration(); for (const plugin of [ anthropicPlugin, + cloudflareAiGatewayPlugin, + googlePlugin, huggingfacePlugin, kimiCodingPlugin, + minimaxPlugin, + mistralPlugin, + moonshotPlugin, ollamaPlugin, openAIPlugin, + opencodeGoPlugin, + opencodePlugin, + openrouterPlugin, + qianfanPlugin, + qwenPortalAuthPlugin, + syntheticPlugin, togetherPlugin, + venicePlugin, + vercelAiGatewayPlugin, + xaiPlugin, + xiaomiPlugin, + zaiPlugin, ]) { plugin.register(captured.api); } @@ -153,12 +187,14 @@ describe("applyAuthChoice", () => { resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins()); detectZaiEndpoint.mockReset(); detectZaiEndpoint.mockResolvedValue(null); + setDetectZaiEndpointForTesting(detectZaiEndpoint); loginOpenAICodexOAuth.mockReset(); loginOpenAICodexOAuth.mockResolvedValue(null); await lifecycle.cleanup(); activeStateDir = null; }); + setDetectZaiEndpointForTesting(detectZaiEndpoint); resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins()); it("does not throw when openai-codex oauth fails", async () => { @@ -978,10 +1014,22 @@ describe("applyAuthChoice", () => { provider: scenario.profileProvider, mode: "api_key", }); - expect((await readAuthProfile(scenario.profileId))?.key).toBe(scenario.token); + const profileStore = + scenario.agentId && scenario.agentId !== "default" + ? await readAuthProfilesForAgent<{ profiles?: Record }>( + resolveAgentDir(result.config, scenario.agentId), + ) + : await readAuthProfiles(); + expect(profileStore.profiles?.[scenario.profileId]?.key).toBe(scenario.token); } if (scenario.extraProfileId) { - expect((await readAuthProfile(scenario.extraProfileId))?.key).toBe(scenario.token); + const profileStore = + scenario.agentId && scenario.agentId !== "default" + ? await readAuthProfilesForAgent<{ profiles?: Record }>( + resolveAgentDir(result.config, scenario.agentId), + ) + : await readAuthProfiles(); + expect(profileStore.profiles?.[scenario.extraProfileId]?.key).toBe(scenario.token); } if (scenario.expectProviderConfigUndefined) { expect( @@ -1393,6 +1441,7 @@ describe("applyAuthChoice", () => { id: scenario.authId, label: scenario.authLabel, kind: "device_code", + wizard: { choiceId: scenario.authChoice }, run: vi.fn(async () => ({ profiles: [ { diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 10de2ecbcb6..abf8362d694 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -1,10 +1,16 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { withEnvAsync } from "../test-utils/env.js"; -import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "./onboard-auth.js"; +import { + MINIMAX_API_BASE_URL, + MINIMAX_CN_API_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_GLOBAL_BASE_URL, +} from "./onboard-auth.js"; import { createThrowingRuntime, readJsonFile, @@ -18,8 +24,6 @@ type OnboardEnv = { }; const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})); -type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint; -const detectZaiEndpoint = vi.hoisted(() => vi.fn(async () => null)); vi.mock("./onboard-helpers.js", async (importOriginal) => { const actual = await importOriginal(); @@ -29,10 +33,6 @@ vi.mock("./onboard-helpers.js", async (importOriginal) => { }; }); -vi.mock("./zai-endpoint-detect.js", () => ({ - detectZaiEndpoint, -})); - const { runNonInteractiveSetup } = await import("./onboard-non-interactive.js"); const NON_INTERACTIVE_DEFAULT_OPTIONS = { @@ -61,6 +61,45 @@ type ProviderAuthConfigSnapshot = { }; }; +function createZaiFetchMock(responses: Record): typeof fetch { + return vi.fn(async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : ""; + const parsedBody = + typeof init?.body === "string" ? (JSON.parse(init.body) as { model?: string }) : {}; + const key = `${url}::${parsedBody.model ?? ""}`; + const status = responses[key] ?? 404; + return new Response( + JSON.stringify( + status === 200 ? { ok: true } : { error: { code: "unsupported", message: "unsupported" } }, + ), + { + status, + headers: { "content-type": "application/json" }, + }, + ); + }) as typeof fetch; +} + +async function withZaiProbeFetch( + responses: Record, + run: (fetchMock: typeof fetch) => Promise, +): Promise { + const originalVitest = process.env.VITEST; + delete process.env.VITEST; + const fetchMock = createZaiFetchMock(responses); + vi.stubGlobal("fetch", fetchMock); + try { + return await run(fetchMock); + } finally { + vi.unstubAllGlobals(); + if (originalVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = originalVitest; + } + } +} + async function removeDirWithRetry(dir: string): Promise { for (let attempt = 0; attempt < 5; attempt += 1) { try { @@ -186,11 +225,6 @@ describe("onboard (non-interactive): provider auth", () => { ({ ensureAuthProfileStore, upsertAuthProfile } = await import("../agents/auth-profiles.js")); }); - beforeEach(() => { - detectZaiEndpoint.mockReset(); - detectZaiEndpoint.mockResolvedValue(null); - }); - it("stores MiniMax API key and uses global baseUrl by default", async () => { await withOnboardEnv("openclaw-onboard-minimax-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { @@ -230,62 +264,68 @@ describe("onboard (non-interactive): provider auth", () => { }); it("stores Z.AI API key and uses global baseUrl by default", async () => { - await withOnboardEnv("openclaw-onboard-zai-", async (env) => { - detectZaiEndpoint.mockResolvedValueOnce({ - endpoint: "global", - baseUrl: "https://api.z.ai/api/paas/v4", - modelId: "glm-5", - note: "Verified GLM-5 on global endpoint.", - }); - const cfg = await runOnboardingAndReadConfig(env, { - authChoice: "zai-api-key", - zaiApiKey: "zai-test-key", // pragma: allowlist secret - }); + await withZaiProbeFetch( + { + [`${ZAI_GLOBAL_BASE_URL}/chat/completions::glm-5`]: 200, + }, + async (fetchMock) => + await withOnboardEnv("openclaw-onboard-zai-", async (env) => { + const cfg = await runOnboardingAndReadConfig(env, { + authChoice: "zai-api-key", + zaiApiKey: "zai-test-key", // pragma: allowlist secret + }); - expect(cfg.auth?.profiles?.["zai:default"]?.provider).toBe("zai"); - expect(cfg.auth?.profiles?.["zai:default"]?.mode).toBe("api_key"); - expect(cfg.models?.providers?.zai?.baseUrl).toBe("https://api.z.ai/api/paas/v4"); - expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5"); - await expectApiKeyProfile({ profileId: "zai:default", provider: "zai", key: "zai-test-key" }); - }); + expect(cfg.auth?.profiles?.["zai:default"]?.provider).toBe("zai"); + expect(cfg.auth?.profiles?.["zai:default"]?.mode).toBe("api_key"); + expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_GLOBAL_BASE_URL); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5"); + expect(fetchMock).toHaveBeenCalled(); + await expectApiKeyProfile({ + profileId: "zai:default", + provider: "zai", + key: "zai-test-key", + }); + }), + ); }); it("supports Z.AI CN coding endpoint auth choice", async () => { - await withOnboardEnv("openclaw-onboard-zai-cn-", async (env) => { - detectZaiEndpoint.mockResolvedValueOnce({ - endpoint: "coding-cn", - baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", - modelId: "glm-4.7", - note: "Coding Plan CN endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.", - }); - const cfg = await runOnboardingAndReadConfig(env, { - authChoice: "zai-coding-cn", - zaiApiKey: "zai-test-key", // pragma: allowlist secret - }); + await withZaiProbeFetch( + { + [`${ZAI_CODING_CN_BASE_URL}/chat/completions::glm-5`]: 404, + [`${ZAI_CODING_CN_BASE_URL}/chat/completions::glm-4.7`]: 200, + }, + async (fetchMock) => + await withOnboardEnv("openclaw-onboard-zai-cn-", async (env) => { + const cfg = await runOnboardingAndReadConfig(env, { + authChoice: "zai-coding-cn", + zaiApiKey: "zai-test-key", // pragma: allowlist secret + }); - expect(cfg.models?.providers?.zai?.baseUrl).toBe( - "https://open.bigmodel.cn/api/coding/paas/v4", - ); - expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-4.7"); - }); + expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-4.7"); + expect(fetchMock).toHaveBeenCalled(); + }), + ); }); it("supports Z.AI Coding Plan global endpoint with GLM-5 when available", async () => { - await withOnboardEnv("openclaw-onboard-zai-coding-global-", async (env) => { - detectZaiEndpoint.mockResolvedValueOnce({ - endpoint: "coding-global", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - modelId: "glm-5", - note: "Verified GLM-5 on coding-global endpoint.", - }); - const cfg = await runOnboardingAndReadConfig(env, { - authChoice: "zai-coding-global", - zaiApiKey: "zai-test-key", // pragma: allowlist secret - }); + await withZaiProbeFetch( + { + [`${ZAI_CODING_GLOBAL_BASE_URL}/chat/completions::glm-5`]: 200, + }, + async (fetchMock) => + await withOnboardEnv("openclaw-onboard-zai-coding-global-", async (env) => { + const cfg = await runOnboardingAndReadConfig(env, { + authChoice: "zai-coding-global", + zaiApiKey: "zai-test-key", // pragma: allowlist secret + }); - expect(cfg.models?.providers?.zai?.baseUrl).toBe("https://api.z.ai/api/coding/paas/v4"); - expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5"); - }); + expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5"); + expect(fetchMock).toHaveBeenCalled(); + }), + ); }); it("stores xAI API key and sets default model", async () => { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts index a04dda68fd1..4c0454401ad 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts @@ -1,499 +1,22 @@ import type { OpenClawConfig } from "../../../config/config.js"; import type { SecretInput } from "../../../config/types.secrets.js"; import type { RuntimeEnv } from "../../../runtime.js"; -import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js"; -import { applyPrimaryModel } from "../../model-picker.js"; import { applyAuthProfileConfig, - applyHuggingfaceConfig, - applyKilocodeConfig, - applyKimiCodeConfig, applyLitellmConfig, - applyMistralConfig, - applyModelStudioConfig, - applyModelStudioConfigCn, - applyMoonshotConfig, - applyMoonshotConfigCn, - applyOpencodeGoConfig, - applyOpencodeZenConfig, - applyOpenrouterConfig, - applyQianfanConfig, - applySyntheticConfig, - applyTogetherConfig, - applyVeniceConfig, - applyVercelAiGatewayConfig, - applyXaiConfig, - applyXiaomiConfig, - setAnthropicApiKey, - setGeminiApiKey, - setHuggingfaceApiKey, - setKilocodeApiKey, - setKimiCodingApiKey, setLitellmApiKey, - setMistralApiKey, - setModelStudioApiKey, - setMoonshotApiKey, - setOpenaiApiKey, - setOpencodeGoApiKey, - setOpencodeZenApiKey, - setOpenrouterApiKey, - setQianfanApiKey, - setSyntheticApiKey, - setTogetherApiKey, - setVeniceApiKey, - setVercelAiGatewayApiKey, - setVolcengineApiKey, - setXaiApiKey, - setXiaomiApiKey, - setByteplusApiKey, } from "../../onboard-auth.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; -import { applyOpenAIConfig } from "../../openai-model-default.js"; type ApiKeyStorageOptions = { secretInputMode: "plaintext" | "ref"; }; -type SimpleApiKeyAuthChoice = { - authChoices: AuthChoice[]; - provider: string; - flagValue?: string; - flagName: `--${string}`; - envVar: string; - profileId: string; - setCredential: (value: SecretInput, options?: ApiKeyStorageOptions) => Promise | void; - applyConfig: (cfg: OpenClawConfig) => OpenClawConfig; -}; - type ResolvedNonInteractiveApiKey = { key: string; source: "profile" | "env" | "flag"; }; -function buildSimpleApiKeyAuthChoices(params: { opts: OnboardOptions }): SimpleApiKeyAuthChoice[] { - const withStorage = - ( - setter: ( - value: SecretInput, - agentDir?: string, - options?: ApiKeyStorageOptions, - ) => Promise | void, - ) => - (value: SecretInput, options?: ApiKeyStorageOptions) => - setter(value, undefined, options); - - return [ - { - authChoices: ["apiKey"], - provider: "anthropic", - flagValue: params.opts.anthropicApiKey, - flagName: "--anthropic-api-key", - envVar: "ANTHROPIC_API_KEY", - profileId: "anthropic:default", - setCredential: withStorage(setAnthropicApiKey), - applyConfig: (cfg) => - applyAuthProfileConfig(cfg, { - profileId: "anthropic:default", - provider: "anthropic", - mode: "api_key", - }), - }, - { - authChoices: ["gemini-api-key"], - provider: "google", - flagValue: params.opts.geminiApiKey, - flagName: "--gemini-api-key", - envVar: "GEMINI_API_KEY", - profileId: "google:default", - setCredential: withStorage(setGeminiApiKey), - applyConfig: (cfg) => - applyGoogleGeminiModelDefault( - applyAuthProfileConfig(cfg, { - profileId: "google:default", - provider: "google", - mode: "api_key", - }), - ).next, - }, - { - authChoices: ["xiaomi-api-key"], - provider: "xiaomi", - flagValue: params.opts.xiaomiApiKey, - flagName: "--xiaomi-api-key", - envVar: "XIAOMI_API_KEY", - profileId: "xiaomi:default", - setCredential: withStorage(setXiaomiApiKey), - applyConfig: (cfg) => - applyXiaomiConfig( - applyAuthProfileConfig(cfg, { - profileId: "xiaomi:default", - provider: "xiaomi", - mode: "api_key", - }), - ), - }, - { - authChoices: ["xai-api-key"], - provider: "xai", - flagValue: params.opts.xaiApiKey, - flagName: "--xai-api-key", - envVar: "XAI_API_KEY", - profileId: "xai:default", - setCredential: withStorage(setXaiApiKey), - applyConfig: (cfg) => - applyXaiConfig( - applyAuthProfileConfig(cfg, { - profileId: "xai:default", - provider: "xai", - mode: "api_key", - }), - ), - }, - { - authChoices: ["mistral-api-key"], - provider: "mistral", - flagValue: params.opts.mistralApiKey, - flagName: "--mistral-api-key", - envVar: "MISTRAL_API_KEY", - profileId: "mistral:default", - setCredential: withStorage(setMistralApiKey), - applyConfig: (cfg) => - applyMistralConfig( - applyAuthProfileConfig(cfg, { - profileId: "mistral:default", - provider: "mistral", - mode: "api_key", - }), - ), - }, - { - authChoices: ["volcengine-api-key"], - provider: "volcengine", - flagValue: params.opts.volcengineApiKey, - flagName: "--volcengine-api-key", - envVar: "VOLCANO_ENGINE_API_KEY", - profileId: "volcengine:default", - setCredential: withStorage(setVolcengineApiKey), - applyConfig: (cfg) => - applyPrimaryModel( - applyAuthProfileConfig(cfg, { - profileId: "volcengine:default", - provider: "volcengine", - mode: "api_key", - }), - "volcengine-plan/ark-code-latest", - ), - }, - { - authChoices: ["byteplus-api-key"], - provider: "byteplus", - flagValue: params.opts.byteplusApiKey, - flagName: "--byteplus-api-key", - envVar: "BYTEPLUS_API_KEY", - profileId: "byteplus:default", - setCredential: withStorage(setByteplusApiKey), - applyConfig: (cfg) => - applyPrimaryModel( - applyAuthProfileConfig(cfg, { - profileId: "byteplus:default", - provider: "byteplus", - mode: "api_key", - }), - "byteplus-plan/ark-code-latest", - ), - }, - { - authChoices: ["qianfan-api-key"], - provider: "qianfan", - flagValue: params.opts.qianfanApiKey, - flagName: "--qianfan-api-key", - envVar: "QIANFAN_API_KEY", - profileId: "qianfan:default", - setCredential: withStorage(setQianfanApiKey), - applyConfig: (cfg) => - applyQianfanConfig( - applyAuthProfileConfig(cfg, { - profileId: "qianfan:default", - provider: "qianfan", - mode: "api_key", - }), - ), - }, - { - authChoices: ["modelstudio-api-key-cn"], - provider: "modelstudio", - flagValue: params.opts.modelstudioApiKeyCn, - flagName: "--modelstudio-api-key-cn", - envVar: "MODELSTUDIO_API_KEY", - profileId: "modelstudio:default", - setCredential: withStorage(setModelStudioApiKey), - applyConfig: (cfg) => - applyModelStudioConfigCn( - applyAuthProfileConfig(cfg, { - profileId: "modelstudio:default", - provider: "modelstudio", - mode: "api_key", - }), - ), - }, - { - authChoices: ["modelstudio-api-key"], - provider: "modelstudio", - flagValue: params.opts.modelstudioApiKey, - flagName: "--modelstudio-api-key", - envVar: "MODELSTUDIO_API_KEY", - profileId: "modelstudio:default", - setCredential: withStorage(setModelStudioApiKey), - applyConfig: (cfg) => - applyModelStudioConfig( - applyAuthProfileConfig(cfg, { - profileId: "modelstudio:default", - provider: "modelstudio", - mode: "api_key", - }), - ), - }, - { - authChoices: ["openai-api-key"], - provider: "openai", - flagValue: params.opts.openaiApiKey, - flagName: "--openai-api-key", - envVar: "OPENAI_API_KEY", - profileId: "openai:default", - setCredential: withStorage(setOpenaiApiKey), - applyConfig: (cfg) => - applyOpenAIConfig( - applyAuthProfileConfig(cfg, { - profileId: "openai:default", - provider: "openai", - mode: "api_key", - }), - ), - }, - { - authChoices: ["openrouter-api-key"], - provider: "openrouter", - flagValue: params.opts.openrouterApiKey, - flagName: "--openrouter-api-key", - envVar: "OPENROUTER_API_KEY", - profileId: "openrouter:default", - setCredential: withStorage(setOpenrouterApiKey), - applyConfig: (cfg) => - applyOpenrouterConfig( - applyAuthProfileConfig(cfg, { - profileId: "openrouter:default", - provider: "openrouter", - mode: "api_key", - }), - ), - }, - { - authChoices: ["kilocode-api-key"], - provider: "kilocode", - flagValue: params.opts.kilocodeApiKey, - flagName: "--kilocode-api-key", - envVar: "KILOCODE_API_KEY", - profileId: "kilocode:default", - setCredential: withStorage(setKilocodeApiKey), - applyConfig: (cfg) => - applyKilocodeConfig( - applyAuthProfileConfig(cfg, { - profileId: "kilocode:default", - provider: "kilocode", - mode: "api_key", - }), - ), - }, - { - authChoices: ["litellm-api-key"], - provider: "litellm", - flagValue: params.opts.litellmApiKey, - flagName: "--litellm-api-key", - envVar: "LITELLM_API_KEY", - profileId: "litellm:default", - setCredential: withStorage(setLitellmApiKey), - applyConfig: (cfg) => - applyLitellmConfig( - applyAuthProfileConfig(cfg, { - profileId: "litellm:default", - provider: "litellm", - mode: "api_key", - }), - ), - }, - { - authChoices: ["ai-gateway-api-key"], - provider: "vercel-ai-gateway", - flagValue: params.opts.aiGatewayApiKey, - flagName: "--ai-gateway-api-key", - envVar: "AI_GATEWAY_API_KEY", - profileId: "vercel-ai-gateway:default", - setCredential: withStorage(setVercelAiGatewayApiKey), - applyConfig: (cfg) => - applyVercelAiGatewayConfig( - applyAuthProfileConfig(cfg, { - profileId: "vercel-ai-gateway:default", - provider: "vercel-ai-gateway", - mode: "api_key", - }), - ), - }, - { - authChoices: ["moonshot-api-key"], - provider: "moonshot", - flagValue: params.opts.moonshotApiKey, - flagName: "--moonshot-api-key", - envVar: "MOONSHOT_API_KEY", - profileId: "moonshot:default", - setCredential: withStorage(setMoonshotApiKey), - applyConfig: (cfg) => - applyMoonshotConfig( - applyAuthProfileConfig(cfg, { - profileId: "moonshot:default", - provider: "moonshot", - mode: "api_key", - }), - ), - }, - { - authChoices: ["moonshot-api-key-cn"], - provider: "moonshot", - flagValue: params.opts.moonshotApiKey, - flagName: "--moonshot-api-key", - envVar: "MOONSHOT_API_KEY", - profileId: "moonshot:default", - setCredential: withStorage(setMoonshotApiKey), - applyConfig: (cfg) => - applyMoonshotConfigCn( - applyAuthProfileConfig(cfg, { - profileId: "moonshot:default", - provider: "moonshot", - mode: "api_key", - }), - ), - }, - { - authChoices: ["kimi-code-api-key"], - provider: "kimi-coding", - flagValue: params.opts.kimiCodeApiKey, - flagName: "--kimi-code-api-key", - envVar: "KIMI_API_KEY", - profileId: "kimi-coding:default", - setCredential: withStorage(setKimiCodingApiKey), - applyConfig: (cfg) => - applyKimiCodeConfig( - applyAuthProfileConfig(cfg, { - profileId: "kimi-coding:default", - provider: "kimi-coding", - mode: "api_key", - }), - ), - }, - { - authChoices: ["synthetic-api-key"], - provider: "synthetic", - flagValue: params.opts.syntheticApiKey, - flagName: "--synthetic-api-key", - envVar: "SYNTHETIC_API_KEY", - profileId: "synthetic:default", - setCredential: withStorage(setSyntheticApiKey), - applyConfig: (cfg) => - applySyntheticConfig( - applyAuthProfileConfig(cfg, { - profileId: "synthetic:default", - provider: "synthetic", - mode: "api_key", - }), - ), - }, - { - authChoices: ["venice-api-key"], - provider: "venice", - flagValue: params.opts.veniceApiKey, - flagName: "--venice-api-key", - envVar: "VENICE_API_KEY", - profileId: "venice:default", - setCredential: withStorage(setVeniceApiKey), - applyConfig: (cfg) => - applyVeniceConfig( - applyAuthProfileConfig(cfg, { - profileId: "venice:default", - provider: "venice", - mode: "api_key", - }), - ), - }, - { - authChoices: ["opencode-zen"], - provider: "opencode", - flagValue: params.opts.opencodeZenApiKey, - flagName: "--opencode-zen-api-key", - envVar: "OPENCODE_API_KEY (or OPENCODE_ZEN_API_KEY)", - profileId: "opencode:default", - setCredential: withStorage(setOpencodeZenApiKey), - applyConfig: (cfg) => - applyOpencodeZenConfig( - applyAuthProfileConfig(cfg, { - profileId: "opencode:default", - provider: "opencode", - mode: "api_key", - }), - ), - }, - { - authChoices: ["opencode-go"], - provider: "opencode-go", - flagValue: params.opts.opencodeGoApiKey, - flagName: "--opencode-go-api-key", - envVar: "OPENCODE_API_KEY", - profileId: "opencode-go:default", - setCredential: withStorage(setOpencodeGoApiKey), - applyConfig: (cfg) => - applyOpencodeGoConfig( - applyAuthProfileConfig(cfg, { - profileId: "opencode-go:default", - provider: "opencode-go", - mode: "api_key", - }), - ), - }, - { - authChoices: ["together-api-key"], - provider: "together", - flagValue: params.opts.togetherApiKey, - flagName: "--together-api-key", - envVar: "TOGETHER_API_KEY", - profileId: "together:default", - setCredential: withStorage(setTogetherApiKey), - applyConfig: (cfg) => - applyTogetherConfig( - applyAuthProfileConfig(cfg, { - profileId: "together:default", - provider: "together", - mode: "api_key", - }), - ), - }, - { - authChoices: ["huggingface-api-key"], - provider: "huggingface", - flagValue: params.opts.huggingfaceApiKey, - flagName: "--huggingface-api-key", - envVar: "HF_TOKEN", - profileId: "huggingface:default", - setCredential: withStorage(setHuggingfaceApiKey), - applyConfig: (cfg) => - applyHuggingfaceConfig( - applyAuthProfileConfig(cfg, { - profileId: "huggingface:default", - provider: "huggingface", - mode: "api_key", - }), - ), - }, - ]; -} - export async function applySimpleNonInteractiveApiKeyChoice(params: { authChoice: AuthChoice; nextConfig: OpenClawConfig; @@ -514,19 +37,16 @@ export async function applySimpleNonInteractiveApiKeyChoice(params: { setter: (value: SecretInput) => Promise | void, ) => Promise; }): Promise { - const definition = buildSimpleApiKeyAuthChoices({ - opts: params.opts, - }).find((entry) => entry.authChoices.includes(params.authChoice)); - if (!definition) { + if (params.authChoice !== "litellm-api-key") { return undefined; } const resolved = await params.resolveApiKey({ - provider: definition.provider, + provider: "litellm", cfg: params.baseConfig, - flagValue: definition.flagValue, - flagName: definition.flagName, - envVar: definition.envVar, + flagValue: params.opts.litellmApiKey, + flagName: "--litellm-api-key", + envVar: "LITELLM_API_KEY", runtime: params.runtime, }); if (!resolved) { @@ -534,10 +54,16 @@ export async function applySimpleNonInteractiveApiKeyChoice(params: { } if ( !(await params.maybeSetResolvedApiKey(resolved, (value) => - definition.setCredential(value, params.apiKeyStorageOptions), + setLitellmApiKey(value, undefined, params.apiKeyStorageOptions), )) ) { return null; } - return definition.applyConfig(params.nextConfig); + return applyLitellmConfig( + applyAuthProfileConfig(params.nextConfig, { + profileId: "litellm:default", + provider: "litellm", + mode: "api_key", + }), + ); } diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 0cc8d2883a1..8d9b820fc52 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -84,6 +84,8 @@ export async function applyNonInteractivePluginProviderChoice(params: { providers: resolvePluginProviders({ config: resolutionConfig, workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, }), choice: params.authChoice, }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 5c61e247c89..c52be44afda 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -4,15 +4,11 @@ import type { SecretInput } from "../../../config/types.secrets.js"; import type { RuntimeEnv } from "../../../runtime.js"; import { resolveDefaultSecretProviderAlias } from "../../../secrets/ref-contract.js"; import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.js"; +import { normalizeApiKeyTokenProviderAuthChoice } from "../../auth-choice.apply.api-providers.js"; import { applyAuthProfileConfig, applyCloudflareAiGatewayConfig, - applyMinimaxApiConfig, - applyMinimaxApiConfigCn, - applyZaiConfig, setCloudflareAiGatewayConfig, - setMinimaxApiKey, - setZaiApiKey, } from "../../onboard-auth.js"; import { applyCustomApiConfig, @@ -21,7 +17,6 @@ import { resolveCustomProviderId, } from "../../onboard-custom.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; -import { detectZaiEndpoint } from "../../zai-endpoint-detect.js"; import { resolveNonInteractiveApiKey } from "../api-keys.js"; import { applySimpleNonInteractiveApiKeyChoice } from "./auth-choice.api-key-providers.js"; import { applyNonInteractivePluginProviderChoice } from "./auth-choice.plugin-providers.js"; @@ -37,7 +32,13 @@ export async function applyNonInteractiveAuthChoice(params: { runtime: RuntimeEnv; baseConfig: OpenClawConfig; }): Promise { - const { authChoice, opts, runtime, baseConfig } = params; + const { opts, runtime, baseConfig } = params; + const authChoice = normalizeApiKeyTokenProviderAuthChoice({ + authChoice: params.authChoice, + tokenProvider: opts.tokenProvider, + config: params.nextConfig, + env: process.env, + }); let nextConfig = params.nextConfig; const requestedSecretInputMode = normalizeSecretInputModeInput(opts.secretInputMode); if (opts.secretInputMode && !requestedSecretInputMode) { @@ -188,72 +189,6 @@ export async function applyNonInteractiveAuthChoice(params: { return simpleApiKeyChoice; } - if ( - authChoice === "zai-api-key" || - authChoice === "zai-coding-global" || - authChoice === "zai-coding-cn" || - authChoice === "zai-global" || - authChoice === "zai-cn" - ) { - const resolved = await resolveApiKey({ - provider: "zai", - cfg: baseConfig, - flagValue: opts.zaiApiKey, - flagName: "--zai-api-key", - envVar: "ZAI_API_KEY", - runtime, - }); - if (!resolved) { - return null; - } - if ( - !(await maybeSetResolvedApiKey(resolved, (value) => - setZaiApiKey(value, undefined, apiKeyStorageOptions), - )) - ) { - return null; - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "zai:default", - provider: "zai", - mode: "api_key", - }); - - // Determine endpoint from authChoice or detect from the API key. - let endpoint: "global" | "cn" | "coding-global" | "coding-cn" | undefined; - let modelIdOverride: string | undefined; - - if (authChoice === "zai-coding-global") { - endpoint = "coding-global"; - } else if (authChoice === "zai-coding-cn") { - endpoint = "coding-cn"; - } else if (authChoice === "zai-global") { - endpoint = "global"; - } else if (authChoice === "zai-cn") { - endpoint = "cn"; - } - - if (endpoint) { - const detected = await detectZaiEndpoint({ apiKey: resolved.key, endpoint }); - if (detected) { - modelIdOverride = detected.modelId; - } - } else { - const detected = await detectZaiEndpoint({ apiKey: resolved.key }); - if (detected) { - endpoint = detected.endpoint; - modelIdOverride = detected.modelId; - } else { - endpoint = "global"; - } - } - - return applyZaiConfig(nextConfig, { - endpoint, - ...(modelIdOverride ? { modelId: modelIdOverride } : {}), - }); - } - if (authChoice === "cloudflare-ai-gateway-api-key") { const accountId = opts.cloudflareAiGatewayAccountId?.trim() ?? ""; const gatewayId = opts.cloudflareAiGatewayGatewayId?.trim() ?? ""; @@ -320,38 +255,6 @@ export async function applyNonInteractiveAuthChoice(params: { return null; } - if (authChoice === "minimax-global-api" || authChoice === "minimax-cn-api") { - const isCn = authChoice === "minimax-cn-api"; - const profileId = isCn ? "minimax:cn" : "minimax:global"; - const resolved = await resolveApiKey({ - provider: "minimax", - cfg: baseConfig, - flagValue: opts.minimaxApiKey, - flagName: "--minimax-api-key", - envVar: "MINIMAX_API_KEY", - runtime, - // Disable profile fallback: both regions share provider "minimax", so an existing - // Global profile key must not be silently reused when configuring CN (and vice versa). - allowProfile: false, - }); - if (!resolved) { - return null; - } - if ( - !(await maybeSetResolvedApiKey(resolved, (value) => - setMinimaxApiKey(value, undefined, profileId, apiKeyStorageOptions), - )) - ) { - return null; - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "minimax", - mode: "api_key", - }); - return isCn ? applyMinimaxApiConfigCn(nextConfig) : applyMinimaxApiConfig(nextConfig); - } - if (authChoice === "custom-api-key") { try { const customAuth = parseNonInteractiveCustomApiFlags({ diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index e43e23631cb..46070deab34 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -24,6 +24,7 @@ export type NormalizedPluginsConfig = { }; export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ + "amazon-bedrock", "anthropic", "byteplus", "cloudflare-ai-gateway", diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index df8e172fcfb..75fa4afb77d 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -1,6 +1,7 @@ import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; +import { applyPrimaryModel } from "../commands/model-picker.js"; import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -23,6 +24,8 @@ type ProviderApiKeyAuthMethodOptions = { envVar: string; promptMessage: string; profileId?: string; + profileIds?: string[]; + allowProfile?: boolean; defaultModel?: string; expectedProviders?: string[]; metadata?: Record; @@ -39,18 +42,39 @@ function resolveProfileId(params: { providerId: string; profileId?: string }) { return params.profileId?.trim() || `${params.providerId}:default`; } +function resolveProfileIds(params: { + providerId: string; + profileId?: string; + profileIds?: string[]; +}) { + const explicit = Array.from( + new Set((params.profileIds ?? []).map((value) => value.trim()).filter(Boolean)), + ); + if (explicit.length > 0) { + return explicit; + } + return [resolveProfileId(params)]; +} + function applyApiKeyConfig(params: { ctx: ProviderAuthMethodNonInteractiveContext; providerId: string; - profileId: string; + profileIds: string[]; + defaultModel?: string; applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; }) { - const next = applyAuthProfileConfig(params.ctx.config, { - profileId: params.profileId, - provider: params.providerId, - mode: "api_key", - }); - return params.applyConfig ? params.applyConfig(next) : next; + let next = params.ctx.config; + for (const profileId of params.profileIds) { + next = applyAuthProfileConfig(next, { + profileId, + provider: profileId.split(":", 1)[0]?.trim() || params.providerId, + mode: "api_key", + }); + } + if (params.applyConfig) { + next = params.applyConfig(next); + } + return params.defaultModel ? applyPrimaryModel(next, params.defaultModel) : next; } export function createProviderApiKeyAuthMethod( @@ -99,19 +123,19 @@ export function createProviderApiKeyAuthMethod( throw new Error(`Missing API key input for provider "${params.providerId}".`); } const credentialInput = capturedSecretInput ?? ""; + const profileIds = resolveProfileIds(params); return { - profiles: [ - { - profileId: resolveProfileId(params), - credential: buildApiKeyCredential( - params.providerId, - credentialInput, - params.metadata, - capturedMode ? { secretInputMode: capturedMode } : undefined, - ), - }, - ], + profiles: profileIds.map((profileId) => ({ + profileId, + credential: buildApiKeyCredential( + profileId.split(":", 1)[0]?.trim() || params.providerId, + credentialInput, + params.metadata, + capturedMode ? { secretInputMode: capturedMode } : undefined, + ), + })), + ...(params.applyConfig ? { configPatch: params.applyConfig(ctx.config) } : {}), ...(params.defaultModel ? { defaultModel: params.defaultModel } : {}), }; }, @@ -122,32 +146,36 @@ export function createProviderApiKeyAuthMethod( flagValue: resolveStringOption(opts, params.optionKey), flagName: params.flagName, envVar: params.envVar, + ...(params.allowProfile === false ? { allowProfile: false } : {}), }); if (!resolved) { return null; } - const profileId = resolveProfileId(params); + const profileIds = resolveProfileIds(params); if (resolved.source !== "profile") { - const credential = ctx.toApiKeyCredential({ - provider: params.providerId, - resolved, - ...(params.metadata ? { metadata: params.metadata } : {}), - }); - if (!credential) { - return null; + for (const profileId of profileIds) { + const credential = ctx.toApiKeyCredential({ + provider: profileId.split(":", 1)[0]?.trim() || params.providerId, + resolved, + ...(params.metadata ? { metadata: params.metadata } : {}), + }); + if (!credential) { + return null; + } + upsertAuthProfile({ + profileId, + credential, + agentDir: ctx.agentDir, + }); } - upsertAuthProfile({ - profileId, - credential, - agentDir: ctx.agentDir, - }); } return applyApiKeyConfig({ ctx, providerId: params.providerId, - profileId, + profileIds, + defaultModel: params.defaultModel, applyConfig: params.applyConfig, }); }, diff --git a/src/plugins/provider-auth-choices.ts b/src/plugins/provider-auth-choices.ts index 1d583c618f4..86388d9b6e5 100644 --- a/src/plugins/provider-auth-choices.ts +++ b/src/plugins/provider-auth-choices.ts @@ -1,3 +1,4 @@ +import { normalizeProviderIdForAuth } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -72,6 +73,25 @@ export function resolveManifestProviderAuthChoice( ); } +export function resolveManifestProviderApiKeyChoice(params: { + providerId: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderAuthChoiceMetadata | undefined { + const normalizedProviderId = normalizeProviderIdForAuth(params.providerId); + if (!normalizedProviderId) { + return undefined; + } + + return resolveManifestProviderAuthChoices(params).find((choice) => { + if (!choice.optionKey) { + return false; + } + return normalizeProviderIdForAuth(choice.providerId) === normalizedProviderId; + }); +} + export function resolveManifestProviderOnboardAuthFlags(params?: { config?: OpenClawConfig; workspaceDir?: string; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 04da1293a09..b9b6e801214 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -107,6 +107,13 @@ export type ProviderAuthKind = "oauth" | "api_key" | "token" | "device_code" | " export type ProviderAuthResult = { profiles: Array<{ profileId: string; credential: AuthProfileCredential }>; + /** + * Optional config patch to merge after credentials are written. + * + * Use this for provider-owned onboarding defaults such as + * `models.providers.` entries, default aliases, or agent model helpers. + * The caller still persists auth-profile bindings separately. + */ configPatch?: Partial; defaultModel?: string; notes?: string[]; From e575f419a537973934ae3d09399ae83803cb0559 Mon Sep 17 00:00:00 2001 From: Prompt Driven Date: Sun, 15 Mar 2026 21:10:29 -0700 Subject: [PATCH 03/56] fix(infra): wire gaxios-fetch-compat shim to prevent node-fetch crash on Node.js 25 --- src/entry.ts | 1 + src/infra/gaxios-fetch-compat.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/infra/gaxios-fetch-compat.ts diff --git a/src/entry.ts b/src/entry.ts index 9b693c756e3..1136d4c954a 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -7,6 +7,7 @@ import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; +import "./infra/gaxios-fetch-compat.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; diff --git a/src/infra/gaxios-fetch-compat.ts b/src/infra/gaxios-fetch-compat.ts new file mode 100644 index 00000000000..2c0d0391a58 --- /dev/null +++ b/src/infra/gaxios-fetch-compat.ts @@ -0,0 +1,27 @@ +/** + * Compatibility shim for gaxios@7.x on Node.js 22+. + * + * gaxios checks `typeof window !== 'undefined'` to decide between + * `window.fetch` (browser) and `import('node-fetch')` (Node.js). + * On Node.js 22+, `globalThis.fetch` is available natively, but gaxios + * does not check for it before falling back to node-fetch. + * + * node-fetch@3.x ESM loading is broken on Node.js 25 (ESM translator + * calls hasOwnProperty on null, throwing "Cannot convert undefined or + * null to object"). This causes ALL google-vertex auth requests to fail + * before any network call is made. + * + * Fix: define a minimal `window` shim with `fetch = globalThis.fetch` so + * gaxios uses the native fetch implementation instead of importing node-fetch. + * + * This module must be imported (as a side effect) before any google-auth-library + * or gaxios request is made. + */ +if ( + typeof (globalThis as Record)["window"] === "undefined" && + typeof globalThis.fetch === "function" +) { + // Tell gaxios it's in a "browser-like" environment with a working fetch. + // Only the `fetch` property is needed; gaxios only reads `window.fetch`. + (globalThis as Record)["window"] = { fetch: globalThis.fetch }; +} From 1aabce78e73cc0e64ec3308ed1ae14123ca539ff Mon Sep 17 00:00:00 2001 From: Prompt Driven Date: Sun, 15 Mar 2026 22:19:19 -0700 Subject: [PATCH 04/56] fix(infra): also wire gaxios-fetch-compat shim into src/index.ts (gateway entry) --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 4daf6521df7..83a5caacfa9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import "./infra/gaxios-fetch-compat.js"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { formatUncaughtError } from "./infra/errors.js"; From 94a01c978907f9ef4d1b23efc7767ee18c77769b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 08:21:40 +0000 Subject: [PATCH 05/56] fix: keep gaxios compat off the package root (#47914) (thanks @pdd-cli) --- CHANGELOG.md | 1 + src/entry.ts | 4 +- src/index.test.ts | 14 ++ src/index.ts | 8 +- src/infra/gaxios-fetch-compat.test.ts | 60 +++++++ src/infra/gaxios-fetch-compat.ts | 237 +++++++++++++++++++++++--- 6 files changed, 295 insertions(+), 29 deletions(-) create mode 100644 src/infra/gaxios-fetch-compat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a80bae4ced0..4f9fb7a653d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. - Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI. - Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79. +- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. ## 2026.3.13 diff --git a/src/entry.ts b/src/entry.ts index 1136d4c954a..3496e48f0e9 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -7,7 +7,6 @@ import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; -import "./infra/gaxios-fetch-compat.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; @@ -42,6 +41,9 @@ if ( ) { // Imported as a dependency — skip all entry-point side effects. } else { + const { installGaxiosFetchCompat } = await import("./infra/gaxios-fetch-compat.js"); + + installGaxiosFetchCompat(); process.title = "openclaw"; ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); diff --git a/src/index.test.ts b/src/index.test.ts index d53d492c527..e1cd55a39e2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -34,6 +34,20 @@ describe("legacy root entry", () => { expect(runtimeMocks.runCli).not.toHaveBeenCalled(); }); + it("keeps library imports free of global window shims", async () => { + const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + Reflect.deleteProperty(globalThis as object, "window"); + + try { + await import("./index.js"); + expect("window" in globalThis).toBe(false); + } finally { + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, "window", originalWindowDescriptor); + } + } + }); + it("delegates legacy direct-entry execution to run-main", async () => { const mod = await import("./index.js"); const argv = ["node", "dist/index.js", "status"]; diff --git a/src/index.ts b/src/index.ts index 83a5caacfa9..92cf6269cc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node -import "./infra/gaxios-fetch-compat.js"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { formatUncaughtError } from "./infra/errors.js"; @@ -33,7 +32,12 @@ export const waitForever = library.waitForever; // Legacy direct file entrypoint only. Package root exports now live in library.ts. export async function runLegacyCliEntry(argv: string[] = process.argv): Promise { - const { runCli } = await import("./cli/run-main.js"); + const [{ installGaxiosFetchCompat }, { runCli }] = await Promise.all([ + import("./infra/gaxios-fetch-compat.js"), + import("./cli/run-main.js"), + ]); + + installGaxiosFetchCompat(); await runCli(argv); } diff --git a/src/infra/gaxios-fetch-compat.test.ts b/src/infra/gaxios-fetch-compat.test.ts new file mode 100644 index 00000000000..4d7559f3eee --- /dev/null +++ b/src/infra/gaxios-fetch-compat.test.ts @@ -0,0 +1,60 @@ +import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent } from "undici"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("gaxios fetch compat", () => { + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("uses native fetch without defining window or importing node-fetch", async () => { + const fetchMock = vi.fn(async () => { + return new Response("ok", { + headers: { "content-type": "text/plain" }, + status: 200, + }); + }); + + vi.stubGlobal("fetch", fetchMock); + vi.doMock("node-fetch", () => { + throw new Error("node-fetch should not load"); + }); + + const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js"); + const { Gaxios } = await import("gaxios"); + + installGaxiosFetchCompat(); + + const res = await new Gaxios().request({ + responseType: "text", + url: "https://example.com", + }); + + expect(res.data).toBe("ok"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect("window" in globalThis).toBe(false); + }); + + it("translates proxy agents into undici dispatchers for native fetch", async () => { + const fetchMock = vi.fn(async () => { + return new Response("ok", { + headers: { "content-type": "text/plain" }, + status: 200, + }); + }); + const { createGaxiosCompatFetch } = await import("./gaxios-fetch-compat.js"); + + const compatFetch = createGaxiosCompatFetch(fetchMock); + await compatFetch("https://example.com", { + agent: new HttpsProxyAgent("http://proxy.example:8080"), + } as RequestInit); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [, init] = fetchMock.mock.calls[0] ?? []; + + expect(init).not.toHaveProperty("agent"); + expect((init as { dispatcher?: unknown })?.dispatcher).toBeInstanceOf(ProxyAgent); + }); +}); diff --git a/src/infra/gaxios-fetch-compat.ts b/src/infra/gaxios-fetch-compat.ts index 2c0d0391a58..e4d3688d7e5 100644 --- a/src/infra/gaxios-fetch-compat.ts +++ b/src/infra/gaxios-fetch-compat.ts @@ -1,27 +1,212 @@ -/** - * Compatibility shim for gaxios@7.x on Node.js 22+. - * - * gaxios checks `typeof window !== 'undefined'` to decide between - * `window.fetch` (browser) and `import('node-fetch')` (Node.js). - * On Node.js 22+, `globalThis.fetch` is available natively, but gaxios - * does not check for it before falling back to node-fetch. - * - * node-fetch@3.x ESM loading is broken on Node.js 25 (ESM translator - * calls hasOwnProperty on null, throwing "Cannot convert undefined or - * null to object"). This causes ALL google-vertex auth requests to fail - * before any network call is made. - * - * Fix: define a minimal `window` shim with `fetch = globalThis.fetch` so - * gaxios uses the native fetch implementation instead of importing node-fetch. - * - * This module must be imported (as a side effect) before any google-auth-library - * or gaxios request is made. - */ -if ( - typeof (globalThis as Record)["window"] === "undefined" && - typeof globalThis.fetch === "function" -) { - // Tell gaxios it's in a "browser-like" environment with a working fetch. - // Only the `fetch` property is needed; gaxios only reads `window.fetch`. - (globalThis as Record)["window"] = { fetch: globalThis.fetch }; +import type { ConnectionOptions } from "node:tls"; +import { Gaxios } from "gaxios"; +import type { Dispatcher } from "undici"; +import { Agent as UndiciAgent, ProxyAgent } from "undici"; + +type ProxyRule = RegExp | URL | string; +type TlsCert = ConnectionOptions["cert"]; +type TlsKey = ConnectionOptions["key"]; + +type GaxiosFetchRequestInit = RequestInit & { + agent?: unknown; + cert?: TlsCert; + dispatcher?: Dispatcher; + fetchImplementation?: typeof fetch; + key?: TlsKey; + noProxy?: ProxyRule[]; + proxy?: string | URL; +}; + +type ProxyAgentLike = { + connectOpts?: { cert?: TlsCert; key?: TlsKey }; + proxy: URL; +}; + +type TlsAgentLike = { + options?: { cert?: TlsCert; key?: TlsKey }; +}; + +type GaxiosPrototype = { + _defaultAdapter: (this: Gaxios, config: GaxiosFetchRequestInit) => Promise; +}; + +let installState: "not-installed" | "installed" = "not-installed"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function hasDispatcher(value: unknown): value is Dispatcher { + return isRecord(value) && typeof value.dispatch === "function"; +} + +function hasProxyAgentShape(value: unknown): value is ProxyAgentLike { + return isRecord(value) && value.proxy instanceof URL; +} + +function hasTlsAgentShape(value: unknown): value is TlsAgentLike { + return isRecord(value) && isRecord(value.options); +} + +function resolveTlsOptions( + init: GaxiosFetchRequestInit, + url: URL, +): { cert?: TlsCert; key?: TlsKey } { + const explicit = { + cert: init.cert, + key: init.key, + }; + if (explicit.cert !== undefined || explicit.key !== undefined) { + return explicit; + } + + const agent = typeof init.agent === "function" ? init.agent(url) : init.agent; + if (hasProxyAgentShape(agent)) { + return { + cert: agent.connectOpts?.cert, + key: agent.connectOpts?.key, + }; + } + if (hasTlsAgentShape(agent)) { + return { + cert: agent.options?.cert, + key: agent.options?.key, + }; + } + return {}; +} + +function urlMayUseProxy(url: URL, noProxy: ProxyRule[] = []): boolean { + const rules = [...noProxy]; + const envRules = (process.env.NO_PROXY ?? process.env.no_proxy)?.split(",") ?? []; + for (const rule of envRules) { + const trimmed = rule.trim(); + if (trimmed.length > 0) { + rules.push(trimmed); + } + } + + for (const rule of rules) { + if (rule instanceof RegExp) { + if (rule.test(url.toString())) { + return false; + } + continue; + } + if (rule instanceof URL) { + if (rule.origin === url.origin) { + return false; + } + continue; + } + if (rule.startsWith("*.") || rule.startsWith(".")) { + const cleanedRule = rule.replace(/^\*\./, "."); + if (url.hostname.endsWith(cleanedRule)) { + return false; + } + continue; + } + if (rule === url.origin || rule === url.hostname || rule === url.href) { + return false; + } + } + + return true; +} + +function resolveProxyUri(init: GaxiosFetchRequestInit, url: URL): string | undefined { + if (init.proxy) { + const proxyUri = String(init.proxy); + return urlMayUseProxy(url, init.noProxy) ? proxyUri : undefined; + } + + const envProxy = + process.env.HTTPS_PROXY ?? + process.env.https_proxy ?? + process.env.HTTP_PROXY ?? + process.env.http_proxy; + if (!envProxy) { + return undefined; + } + + return urlMayUseProxy(url, init.noProxy) ? envProxy : undefined; +} + +function buildDispatcher(init: GaxiosFetchRequestInit, url: URL): Dispatcher | undefined { + if (init.dispatcher) { + return init.dispatcher; + } + + const agent = typeof init.agent === "function" ? init.agent(url) : init.agent; + if (hasDispatcher(agent)) { + return agent; + } + + const { cert, key } = resolveTlsOptions(init, url); + const proxyUri = + resolveProxyUri(init, url) ?? (hasProxyAgentShape(agent) ? String(agent.proxy) : undefined); + if (proxyUri) { + return new ProxyAgent({ + requestTls: cert !== undefined || key !== undefined ? { cert, key } : undefined, + uri: proxyUri, + }); + } + + if (cert !== undefined || key !== undefined) { + return new UndiciAgent({ + connect: { cert, key }, + }); + } + + return undefined; +} + +export function createGaxiosCompatFetch(baseFetch: typeof fetch = globalThis.fetch): typeof fetch { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const gaxiosInit = (init ?? {}) as GaxiosFetchRequestInit; + const requestUrl = + input instanceof Request + ? new URL(input.url) + : new URL(typeof input === "string" ? input : input.toString()); + const dispatcher = buildDispatcher(gaxiosInit, requestUrl); + + const nextInit: RequestInit = { ...gaxiosInit }; + delete (nextInit as GaxiosFetchRequestInit).agent; + delete (nextInit as GaxiosFetchRequestInit).cert; + delete (nextInit as GaxiosFetchRequestInit).fetchImplementation; + delete (nextInit as GaxiosFetchRequestInit).key; + delete (nextInit as GaxiosFetchRequestInit).noProxy; + delete (nextInit as GaxiosFetchRequestInit).proxy; + + if (dispatcher) { + (nextInit as RequestInit & { dispatcher: Dispatcher }).dispatcher = dispatcher; + } + + return baseFetch(input, nextInit); + }; +} + +export function installGaxiosFetchCompat(): void { + if (installState === "installed" || typeof globalThis.fetch !== "function") { + return; + } + + const prototype = Gaxios.prototype as unknown as GaxiosPrototype; + const originalDefaultAdapter = prototype._defaultAdapter; + const compatFetch = createGaxiosCompatFetch(); + + prototype._defaultAdapter = function patchedDefaultAdapter( + this: Gaxios, + config: GaxiosFetchRequestInit, + ): Promise { + if (config.fetchImplementation) { + return originalDefaultAdapter.call(this, config); + } + return originalDefaultAdapter.call(this, { + ...config, + fetchImplementation: compatFetch, + }); + }; + + installState = "installed"; } From 7e74adef916ff75b96e3ab82fdbc2dc3624dca85 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:34:22 -0700 Subject: [PATCH 06/56] refactor: shrink public channel plugin sdk surfaces --- docs/tools/plugin.md | 12 +- extensions/discord/index.ts | 4 +- extensions/discord/src/channel.setup.ts | 8 +- extensions/discord/src/channel.ts | 24 ++-- extensions/discord/src/runtime.ts | 2 +- extensions/discord/src/subagent-hooks.ts | 6 +- extensions/imessage/index.ts | 4 +- extensions/imessage/src/channel.setup.ts | 10 +- extensions/imessage/src/channel.ts | 10 +- extensions/imessage/src/runtime.ts | 2 +- extensions/signal/index.ts | 4 +- extensions/signal/src/channel.setup.ts | 10 +- extensions/signal/src/channel.ts | 10 +- extensions/signal/src/runtime.ts | 2 +- extensions/slack/index.ts | 4 +- extensions/slack/src/channel.setup.ts | 12 +- extensions/slack/src/channel.ts | 24 ++-- extensions/slack/src/runtime.ts | 2 +- extensions/telegram/index.ts | 4 +- extensions/telegram/src/channel.setup.ts | 14 ++- extensions/telegram/src/channel.test.ts | 2 +- extensions/telegram/src/channel.ts | 24 ++-- extensions/telegram/src/runtime.ts | 2 +- extensions/whatsapp/index.ts | 4 +- extensions/whatsapp/src/runtime.ts | 3 +- src/agents/pi-embedded-runner/compact.ts | 4 +- src/agents/pi-embedded-runner/run/attempt.ts | 4 +- src/agents/tools/discord-actions-guild.ts | 4 +- src/agents/tools/discord-actions-messaging.ts | 14 ++- .../tools/discord-actions-moderation.ts | 2 +- src/agents/tools/discord-actions-presence.ts | 2 +- src/agents/tools/discord-actions.ts | 2 +- src/agents/tools/slack-actions.ts | 4 +- src/agents/tools/telegram-actions.ts | 15 ++- src/agents/tools/whatsapp-actions.ts | 2 +- src/agents/tools/whatsapp-target-auth.ts | 2 +- src/auto-reply/reply/commands-approve.ts | 2 +- src/auto-reply/reply/commands-models.ts | 2 +- .../reply/directive-handling.model.ts | 2 +- src/auto-reply/templating.ts | 2 +- src/channel-web.ts | 16 ++- src/channels/plugins/actions/discord.ts | 2 +- src/channels/plugins/actions/signal.ts | 2 +- src/channels/plugins/actions/telegram.ts | 2 +- .../plugins/agent-tools/whatsapp-login.ts | 2 +- src/channels/plugins/group-mentions.ts | 2 +- src/channels/plugins/slack.actions.ts | 4 +- ...ad-only-account-inspect.discord.runtime.ts | 4 +- ...read-only-account-inspect.slack.runtime.ts | 4 +- ...d-only-account-inspect.telegram.runtime.ts | 4 +- src/cli/deps.test.ts | 12 +- src/cli/deps.ts | 14 +-- src/commands/doctor-config-flow.ts | 2 +- src/config/plugin-auto-enable.ts | 2 +- src/config/schema.help.ts | 2 +- .../explicit-session-key-normalization.ts | 2 +- src/config/types.discord.ts | 2 +- src/cron/isolated-agent/delivery-target.ts | 2 +- src/gateway/server-http.ts | 2 +- src/infra/state-migrations.ts | 2 +- src/plugin-sdk/discord.ts | 88 --------------- src/plugin-sdk/imessage.ts | 22 ---- src/plugin-sdk/index.ts | 104 ------------------ src/plugin-sdk/signal.ts | 16 +-- src/plugin-sdk/slack.ts | 45 +------- src/plugin-sdk/subpaths.test.ts | 41 +++---- src/plugin-sdk/telegram.ts | 65 ----------- src/plugin-sdk/whatsapp.ts | 55 --------- src/security/audit-channel.runtime.ts | 2 +- 69 files changed, 218 insertions(+), 570 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 246724719ff..9269e8b1faf 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -573,12 +573,12 @@ authoring plugins: - `openclaw/plugin-sdk/core` for generic plugin APIs, provider auth types, and shared helpers such as routing/session utilities and logger-backed runtimes. - `openclaw/plugin-sdk/compat` for bundled/internal plugin code that needs broader shared runtime helpers than `core`. -- `openclaw/plugin-sdk/telegram` for Telegram channel plugins. -- `openclaw/plugin-sdk/discord` for Discord channel plugins. -- `openclaw/plugin-sdk/slack` for Slack channel plugins. -- `openclaw/plugin-sdk/signal` for Signal channel plugins. -- `openclaw/plugin-sdk/imessage` for iMessage channel plugins. -- `openclaw/plugin-sdk/whatsapp` for WhatsApp channel plugins. +- `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. +- `openclaw/plugin-sdk/signal` for Signal channel plugin types and shared channel-facing helpers. Built-in Signal implementation internals stay private to the bundled extension. +- `openclaw/plugin-sdk/imessage` for iMessage channel plugin types and shared channel-facing helpers. Built-in iMessage implementation internals stay private to the bundled extension. +- `openclaw/plugin-sdk/whatsapp` for WhatsApp channel plugin types and shared channel-facing helpers. Built-in WhatsApp implementation internals stay private to the bundled extension. - `openclaw/plugin-sdk/line` for LINE channel plugins. - `openclaw/plugin-sdk/msteams` for the bundled Microsoft Teams plugin surface. - Bundled extension-specific subpaths are also available: diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index b08a27f80b5..04906b6fd5d 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/discord"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js"; diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index ac79acf443e..ee157e3c9bb 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -7,13 +7,15 @@ import { buildChannelConfigSchema, DiscordConfigSchema, getChatChannelMeta, - inspectDiscordAccount, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { listDiscordAccountIds, resolveDefaultDiscordAccountId, resolveDiscordAccount, - type ChannelPlugin, type ResolvedDiscordAccount, -} from "openclaw/plugin-sdk/discord"; +} from "./accounts.js"; import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; async function loadDiscordChannelRuntime() { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index ac858b9c108..966a5a1cbcd 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -17,41 +17,45 @@ import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, - collectDiscordAuditChannelIds, - collectDiscordStatusIssues, DEFAULT_ACCOUNT_ID, DiscordConfigSchema, getChatChannelMeta, - inspectDiscordAccount, - listDiscordAccountIds, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, - looksLikeDiscordTargetId, - normalizeDiscordMessagingTarget, - normalizeDiscordOutboundTarget, PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, - resolveDiscordAccount, - resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, type ChannelMessageActionAdapter, type ChannelPlugin, type OpenClawConfig, - type ResolvedDiscordAccount, } from "openclaw/plugin-sdk/discord"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { + listDiscordAccountIds, + resolveDiscordAccount, + resolveDefaultDiscordAccountId, + type ResolvedDiscordAccount, +} from "./accounts.js"; +import { collectDiscordAuditChannelIds } from "./audit.js"; import { isDiscordExecApprovalClientEnabled, shouldSuppressLocalDiscordExecApprovalPrompt, } from "./exec-approvals.js"; +import { + looksLikeDiscordTargetId, + normalizeDiscordMessagingTarget, + normalizeDiscordOutboundTarget, +} from "./normalize.js"; import type { DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; +import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 2cc0074f457..066dcdbad12 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/discord"; const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = createPluginRuntimeStore("Discord runtime not initialized"); diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index f6e6056538b..f73511dba20 100644 --- a/extensions/discord/src/subagent-hooks.ts +++ b/extensions/discord/src/subagent-hooks.ts @@ -1,10 +1,10 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { resolveDiscordAccount } from "./accounts.js"; import { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, - resolveDiscordAccount, unbindThreadBindingsBySessionKey, -} from "openclaw/plugin-sdk/discord"; +} from "./monitor/thread-bindings.js"; function summarizeError(err: unknown): string { if (err instanceof Error) { diff --git a/extensions/imessage/index.ts b/extensions/imessage/index.ts index cf0c6b3d8bd..7eb0e80b070 100644 --- a/extensions/imessage/index.ts +++ b/extensions/imessage/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/imessage"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/imessage"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { imessagePlugin } from "./src/channel.js"; import { setIMessageRuntime } from "./src/runtime.js"; diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index 075e50f0dda..a4e58844b3b 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -9,15 +9,17 @@ import { formatTrimmedAllowFromEntries, getChatChannelMeta, IMessageConfigSchema, - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, setAccountEnabledInConfigSection, type ChannelPlugin, - type ResolvedIMessageAccount, } from "openclaw/plugin-sdk/imessage"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + type ResolvedIMessageAccount, +} from "./accounts.js"; import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; async function loadIMessageChannelRuntime() { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 2d5b0ba9b39..b0d94a1a437 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -12,23 +12,25 @@ import { formatTrimmedAllowFromEntries, getChatChannelMeta, IMessageConfigSchema, - listIMessageAccountIds, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, - type ResolvedIMessageAccount, } from "openclaw/plugin-sdk/imessage"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + type ResolvedIMessageAccount, +} from "./accounts.js"; import { getIMessageRuntime } from "./runtime.js"; import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index 7bc726cb089..8805ce3141f 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/imessage"; const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = createPluginRuntimeStore("iMessage runtime not initialized"); diff --git a/extensions/signal/index.ts b/extensions/signal/index.ts index 0a7b988d7f0..e1069e466e2 100644 --- a/extensions/signal/index.ts +++ b/extensions/signal/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/signal"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/signal"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { signalPlugin } from "./src/channel.js"; import { setSignalRuntime } from "./src/runtime.js"; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index 544efa0f64f..88a7035c199 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -8,15 +8,17 @@ import { DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, getChatChannelMeta, - listSignalAccountIds, normalizeE164, - resolveDefaultSignalAccountId, - resolveSignalAccount, setAccountEnabledInConfigSection, SignalConfigSchema, type ChannelPlugin, - type ResolvedSignalAccount, } from "openclaw/plugin-sdk/signal"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, + type ResolvedSignalAccount, +} from "./accounts.js"; import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; async function loadSignalChannelRuntime() { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index bb65502e017..e1675a019d1 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -14,23 +14,25 @@ import { DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, getChatChannelMeta, - listSignalAccountIds, looksLikeSignalTargetId, normalizeE164, normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - resolveDefaultSignalAccountId, - resolveSignalAccount, setAccountEnabledInConfigSection, SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, - type ResolvedSignalAccount, } from "openclaw/plugin-sdk/signal"; import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, + type ResolvedSignalAccount, +} from "./accounts.js"; import { markdownToSignalTextChunks } from "./format.js"; import { looksLikeUuid, diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index 480c174ab26..1b004d82b8a 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/signal"; const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = createPluginRuntimeStore("Signal runtime not initialized"); diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 57d855141be..6f5945616c7 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/slack"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/slack"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { slackPlugin } from "./src/channel.js"; import { setSlackRuntime } from "./src/runtime.js"; diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 83cd1625059..b5723ea5130 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -6,15 +6,17 @@ import { import { buildChannelConfigSchema, getChatChannelMeta, - inspectSlackAccount, - isSlackInteractiveRepliesEnabled, + SlackConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/slack"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackAccount, - SlackConfigSchema, - type ChannelPlugin, type ResolvedSlackAccount, -} from "openclaw/plugin-sdk/slack"; +} from "./accounts.js"; +import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; async function loadSlackChannelRuntime() { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 3b0e347ba24..a07608d836a 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -16,12 +16,7 @@ import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - extractSlackToolSend, getChatChannelMeta, - handleSlackMessageAction, - inspectSlackAccount, - listSlackMessageActions, - listSlackAccountIds, listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, @@ -29,22 +24,28 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromRequiredCredentialStatuses, - resolveDefaultSlackAccountId, - resolveSlackAccount, - resolveSlackReplyToMode, - isSlackInteractiveRepliesEnabled, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, - buildSlackThreadingToolContext, SlackConfigSchema, type ChannelPlugin, type OpenClawConfig, - type ResolvedSlackAccount, } from "openclaw/plugin-sdk/slack"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { + listEnabledSlackAccounts, + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + resolveSlackReplyToMode, + type ResolvedSlackAccount, +} from "./accounts.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; +import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; +import { handleSlackMessageAction } from "./message-action-dispatch.js"; +import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; @@ -52,6 +53,7 @@ import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; import { parseSlackTarget } from "./targets.js"; +import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; const meta = getChatChannelMeta("slack"); const SLACK_CHANNEL_TYPE_CACHE = new Map(); diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index 7961547004c..fd1a2ba17c6 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/slack"; const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = createPluginRuntimeStore("Slack runtime not initialized"); diff --git a/extensions/telegram/index.ts b/extensions/telegram/index.ts index 37367c5280c..a2492fca87d 100644 --- a/extensions/telegram/index.ts +++ b/extensions/telegram/index.ts @@ -1,5 +1,5 @@ -import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/telegram"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/telegram"; +import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { telegramPlugin } from "./src/channel.js"; import { setTelegramRuntime } from "./src/runtime.js"; diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 6abc8ba0c62..f28a96afff7 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -6,17 +6,19 @@ import { import { buildChannelConfigSchema, getChatChannelMeta, - inspectTelegramAccount, - listTelegramAccountIds, normalizeAccountId, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, TelegramConfigSchema, type ChannelPlugin, type OpenClawConfig, - type ResolvedTelegramAccount, - type TelegramProbe, } from "openclaw/plugin-sdk/telegram"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "./accounts.js"; +import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 965a66d0f2c..476260f2969 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -3,10 +3,10 @@ import type { ChannelGatewayContext, OpenClawConfig, PluginRuntime, - ResolvedTelegramAccount, } from "openclaw/plugin-sdk/telegram"; import { describe, expect, it, vi } from "vitest"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import type { ResolvedTelegramAccount } from "./accounts.js"; import { telegramPlugin } from "./channel.js"; import { setTelegramRuntime } from "./runtime.js"; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 37915fa07df..cfb5e8a5f8d 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -16,32 +16,20 @@ import { buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, - collectTelegramStatusIssues, DEFAULT_ACCOUNT_ID, getChatChannelMeta, - inspectTelegramAccount, - listTelegramAccountIds, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, - looksLikeTelegramTargetId, normalizeAccountId, - normalizeTelegramMessagingTarget, PAIRING_APPROVED_MESSAGE, - parseTelegramReplyToMessageId, - parseTelegramThreadId, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, - sendTelegramPayloadMessages, TelegramConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, type OpenClawConfig, - type ResolvedTelegramAccount, - type TelegramProbe, } from "openclaw/plugin-sdk/telegram"; import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; @@ -51,16 +39,28 @@ import { resolveOutboundSendDep, } from "../../../src/infra/outbound/send-deps.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "./accounts.js"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { isTelegramExecApprovalClientEnabled, resolveTelegramExecApprovalTarget, } from "./exec-approvals.js"; +import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; +import { sendTelegramPayloadMessages } from "./outbound-adapter.js"; +import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; +import type { TelegramProbe } from "./probe.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; +import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; type TelegramSendFn = ReturnType< diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index 8923cdd3e8d..97ba41a3a4d 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/telegram"; const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = createPluginRuntimeStore("Telegram runtime not initialized"); diff --git a/extensions/whatsapp/index.ts b/extensions/whatsapp/index.ts index 9279a2c038d..1b19ff6775d 100644 --- a/extensions/whatsapp/index.ts +++ b/extensions/whatsapp/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/whatsapp"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/whatsapp"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { whatsappPlugin } from "./src/channel.js"; import { setWhatsAppRuntime } from "./src/runtime.js"; diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index bf415eb17db..c3644a531d2 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,4 +1,5 @@ -import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/whatsapp"; +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = createPluginRuntimeStore("WhatsApp runtime not initialized"); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 41f681f8de5..67c5b8184b2 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -19,11 +19,11 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getMemorySearchManager } from "../../memory/index.js"; -import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js"; +import { resolveSignalReactionLevel } from "../../plugin-sdk-internal/signal.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, -} from "../../plugin-sdk/telegram.js"; +} from "../../plugin-sdk-internal/telegram.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 2194e87f9e0..bb2cad960bd 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -16,11 +16,11 @@ import { ensureGlobalUndiciStreamTimeouts, } from "../../../infra/net/undici-global-dispatcher.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; -import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js"; +import { resolveSignalReactionLevel } from "../../../plugin-sdk-internal/signal.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramReactionLevel, -} from "../../../plugin-sdk/telegram.js"; +} from "../../../plugin-sdk-internal/telegram.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { PluginHookAgentContext, diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index fa427d87650..54386ad4267 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -19,8 +19,8 @@ import { setChannelPermissionDiscord, uploadEmojiDiscord, uploadStickerDiscord, -} from "../../plugin-sdk/discord.js"; -import { getPresence } from "../../plugin-sdk/discord.js"; +} from "../../plugin-sdk-internal/discord.js"; +import { getPresence } from "../../plugin-sdk-internal/discord.js"; import { type ActionGate, jsonResult, diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 20fdfcc6a02..8a7f93aacbb 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,7 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -23,9 +22,16 @@ import { sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, -} from "../../plugin-sdk/discord.js"; -import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js"; -import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js"; +} from "../../plugin-sdk-internal/discord.js"; +import type { + DiscordSendComponents, + DiscordSendEmbeds, +} from "../../plugin-sdk-internal/discord.js"; +import { + readDiscordComponentSpec, + resolveDiscordChannelId, +} from "../../plugin-sdk-internal/discord.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { assertMediaNotDataUrl } from "../sandbox-paths.js"; diff --git a/src/agents/tools/discord-actions-moderation.ts b/src/agents/tools/discord-actions-moderation.ts index 56d7a80d4c9..63c3cc601bc 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/src/agents/tools/discord-actions-moderation.ts @@ -5,7 +5,7 @@ import { hasAnyGuildPermissionDiscord, kickMemberDiscord, timeoutMemberDiscord, -} from "../../plugin-sdk/discord.js"; +} from "../../plugin-sdk-internal/discord.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; import { isDiscordModerationAction, diff --git a/src/agents/tools/discord-actions-presence.ts b/src/agents/tools/discord-actions-presence.ts index 53c42829bb0..fdfa53e2323 100644 --- a/src/agents/tools/discord-actions-presence.ts +++ b/src/agents/tools/discord-actions-presence.ts @@ -1,7 +1,7 @@ import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; -import { getGateway } from "../../plugin-sdk/discord.js"; +import { getGateway } from "../../plugin-sdk-internal/discord.js"; import { type ActionGate, jsonResult, readStringParam } from "./common.js"; const ACTIVITY_TYPE_MAP: Record = { diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index b953e56cffd..0e380b8d383 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; -import { createDiscordActionGate } from "../../plugin-sdk/discord.js"; +import { createDiscordActionGate } from "../../plugin-sdk-internal/discord.js"; import { readStringParam } from "./common.js"; import { handleDiscordGuildAction } from "./discord-actions-guild.js"; import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index e9089cbfdcc..c7fc16ed8b1 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -15,14 +15,14 @@ import { removeSlackReaction, sendSlackMessage, unpinSlackMessage, -} from "../../plugin-sdk/slack.js"; +} from "../../plugin-sdk-internal/slack.js"; import { parseSlackBlocksInput, parseSlackTarget, recordSlackThreadParticipation, resolveSlackAccount, resolveSlackChannelId, -} from "../../plugin-sdk/slack.js"; +} from "../../plugin-sdk-internal/slack.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { createActionGate, diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index d648b1e5f41..9f2d48831c3 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,15 +1,17 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; -import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { createTelegramActionGate, resolveTelegramPollActionGateState, -} from "../../plugin-sdk/telegram.js"; -import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js"; +} from "../../plugin-sdk-internal/telegram.js"; +import type { + TelegramButtonStyle, + TelegramInlineButtons, +} from "../../plugin-sdk-internal/telegram.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../plugin-sdk/telegram.js"; +} from "../../plugin-sdk-internal/telegram.js"; import { createForumTopicTelegram, deleteMessageTelegram, @@ -19,13 +21,14 @@ import { sendMessageTelegram, sendPollTelegram, sendStickerTelegram, -} from "../../plugin-sdk/telegram.js"; +} from "../../plugin-sdk-internal/telegram.js"; import { getCacheStats, resolveTelegramReactionLevel, resolveTelegramToken, searchStickers, -} from "../../plugin-sdk/telegram.js"; +} from "../../plugin-sdk-internal/telegram.js"; +import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { jsonResult, diff --git a/src/agents/tools/whatsapp-actions.ts b/src/agents/tools/whatsapp-actions.ts index a84dc0a3d5b..30f36331d18 100644 --- a/src/agents/tools/whatsapp-actions.ts +++ b/src/agents/tools/whatsapp-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; -import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js"; +import { sendReactionWhatsApp } from "../../plugin-sdk-internal/whatsapp.js"; import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js"; diff --git a/src/agents/tools/whatsapp-target-auth.ts b/src/agents/tools/whatsapp-target-auth.ts index edc0052fbab..76e7e15d084 100644 --- a/src/agents/tools/whatsapp-target-auth.ts +++ b/src/agents/tools/whatsapp-target-auth.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../../config/config.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; +import { resolveWhatsAppAccount } from "../../plugin-sdk-internal/whatsapp.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; import { ToolAuthorizationError } from "./common.js"; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 630ea988c05..5f259c1b45a 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -3,7 +3,7 @@ import { logVerbose } from "../../globals.js"; import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "../../plugin-sdk/telegram.js"; +} from "../../plugin-sdk-internal/telegram.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 25f309361d2..99e02cfa81e 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -16,7 +16,7 @@ import { calculateTotalPages, getModelsPageSize, type ProviderInfo, -} from "../../plugin-sdk/telegram.js"; +} from "../../plugin-sdk-internal/telegram.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 521d3bd6fea..d27bdb25d61 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -8,7 +8,7 @@ import { } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { buildBrowseProvidersButton } from "../../plugin-sdk/telegram.js"; +import { buildBrowseProvidersButton } from "../../plugin-sdk-internal/telegram.js"; import { shortenHomePath } from "../../utils.js"; import { resolveSelectedAndActiveModel } from "../model-runtime.js"; import type { ReplyPayload } from "../types.js"; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index a32fdc3ba87..b426b18eab5 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,7 +3,7 @@ import type { MediaUnderstandingDecision, MediaUnderstandingOutput, } from "../media-understanding/types.js"; -import type { StickerMetadata } from "../plugin-sdk/telegram.js"; +import type { StickerMetadata } from "../plugin-sdk-internal/telegram.js"; import type { InputProvenance } from "../sessions/input-provenance.js"; import type { InternalMessageChannel } from "../utils/message-channel.js"; import type { CommandArgs } from "./commands-registry.types.js"; diff --git a/src/channel-web.ts b/src/channel-web.ts index e6df4bda0d7..f7e451b142a 100644 --- a/src/channel-web.ts +++ b/src/channel-web.ts @@ -7,11 +7,15 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "./plugin-sdk/whatsapp.js"; -export { extractMediaPlaceholder, extractText, monitorWebInbox } from "./plugin-sdk/whatsapp.js"; -export { loginWeb } from "./plugin-sdk/whatsapp.js"; -export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk/whatsapp.js"; -export { sendMessageWhatsApp } from "./plugin-sdk/whatsapp.js"; +} from "./plugin-sdk-internal/whatsapp.js"; +export { + extractMediaPlaceholder, + extractText, + monitorWebInbox, +} from "./plugin-sdk-internal/whatsapp.js"; +export { loginWeb } from "./plugin-sdk-internal/whatsapp.js"; +export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk-internal/whatsapp.js"; +export { sendMessageWhatsApp } from "./plugin-sdk-internal/whatsapp.js"; export { createWaSocket, formatError, @@ -22,4 +26,4 @@ export { WA_WEB_AUTH_DIR, waitForWaConnection, webAuthExists, -} from "./plugin-sdk/whatsapp.js"; +} from "./plugin-sdk-internal/whatsapp.js"; diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts index 4615a88f3c5..ec11ca6c970 100644 --- a/src/channels/plugins/actions/discord.ts +++ b/src/channels/plugins/actions/discord.ts @@ -1,2 +1,2 @@ // Public entrypoint for the Discord channel action adapter. -export * from "../../../plugin-sdk/discord.js"; +export * from "../../../plugin-sdk-internal/discord.js"; diff --git a/src/channels/plugins/actions/signal.ts b/src/channels/plugins/actions/signal.ts index 60a70bac4c0..7db723f305e 100644 --- a/src/channels/plugins/actions/signal.ts +++ b/src/channels/plugins/actions/signal.ts @@ -5,7 +5,7 @@ import { resolveSignalAccount, resolveSignalReactionLevel, sendReactionSignal, -} from "../../../plugin-sdk/signal.js"; +} from "../../../plugin-sdk-internal/signal.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js"; import { resolveReactionMessageId } from "./reaction-message-id.js"; diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index e811e757b94..e34c4598ade 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -1,2 +1,2 @@ // Public entrypoint for the Telegram channel action adapter. -export * from "../../../plugin-sdk/telegram.js"; +export * from "../../../plugin-sdk-internal/telegram.js"; diff --git a/src/channels/plugins/agent-tools/whatsapp-login.ts b/src/channels/plugins/agent-tools/whatsapp-login.ts index 2204225bdda..661b49e083b 100644 --- a/src/channels/plugins/agent-tools/whatsapp-login.ts +++ b/src/channels/plugins/agent-tools/whatsapp-login.ts @@ -1,2 +1,2 @@ // Shim: keep legacy import path while the runtime loads the plugin SDK surface. -export * from "../../../plugin-sdk/whatsapp.js"; +export * from "../../../plugin-sdk-internal/whatsapp.js"; diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index f825fc73fe5..94079daed04 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -10,7 +10,7 @@ import type { GroupToolPolicyConfig, } from "../../config/types.tools.js"; import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js"; -import { inspectSlackAccount } from "../../plugin-sdk/slack.js"; +import { inspectSlackAccount } from "../../plugin-sdk-internal/slack.js"; import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js"; import type { ChannelGroupContext } from "./types.js"; diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 8c7f02ee9ec..7e74af7058d 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -1,11 +1,11 @@ import { handleSlackAction, type SlackActionContext } from "../../agents/tools/slack-actions.js"; -import { handleSlackMessageAction } from "../../plugin-sdk/slack-message-actions.js"; import { extractSlackToolSend, isSlackInteractiveRepliesEnabled, listSlackMessageActions, resolveSlackChannelId, -} from "../../plugin-sdk/slack.js"; +} from "../../plugin-sdk-internal/slack.js"; +import { handleSlackMessageAction } from "../../plugin-sdk/slack-message-actions.js"; import type { ChannelMessageActionAdapter } from "./types.js"; export function createSlackActions(providerId: string): ChannelMessageActionAdapter { diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts index 9d2ac6ef427..00d0943b1ec 100644 --- a/src/channels/read-only-account-inspect.discord.runtime.ts +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -1,2 +1,2 @@ -export { inspectDiscordAccount } from "../plugin-sdk/discord.js"; -export type { InspectedDiscordAccount } from "../plugin-sdk/discord.js"; +export { inspectDiscordAccount } from "../plugin-sdk-internal/discord.js"; +export type { InspectedDiscordAccount } from "../plugin-sdk-internal/discord.js"; diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts index a7526e2ea95..c3e2bd5d83c 100644 --- a/src/channels/read-only-account-inspect.slack.runtime.ts +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -1,2 +1,2 @@ -export { inspectSlackAccount } from "../plugin-sdk/slack.js"; -export type { InspectedSlackAccount } from "../plugin-sdk/slack.js"; +export { inspectSlackAccount } from "../plugin-sdk-internal/slack.js"; +export type { InspectedSlackAccount } from "../plugin-sdk-internal/slack.js"; diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts index 0ab48f2c241..1e633a0ff8e 100644 --- a/src/channels/read-only-account-inspect.telegram.runtime.ts +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -1,2 +1,2 @@ -export { inspectTelegramAccount } from "../plugin-sdk/telegram.js"; -export type { InspectedTelegramAccount } from "../plugin-sdk/telegram.js"; +export { inspectTelegramAccount } from "../plugin-sdk-internal/telegram.js"; +export type { InspectedTelegramAccount } from "../plugin-sdk-internal/telegram.js"; diff --git a/src/cli/deps.test.ts b/src/cli/deps.test.ts index 64b66313907..d13f2998987 100644 --- a/src/cli/deps.test.ts +++ b/src/cli/deps.test.ts @@ -19,32 +19,32 @@ const sendFns = vi.hoisted(() => ({ imessage: vi.fn(async () => ({ messageId: "i1", chatId: "imessage:1" })), })); -vi.mock("../plugin-sdk/whatsapp.js", () => { +vi.mock("../plugin-sdk-internal/whatsapp.js", () => { moduleLoads.whatsapp(); return { sendMessageWhatsApp: sendFns.whatsapp }; }); -vi.mock("../plugin-sdk/telegram.js", () => { +vi.mock("../plugin-sdk-internal/telegram.js", () => { moduleLoads.telegram(); return { sendMessageTelegram: sendFns.telegram }; }); -vi.mock("../plugin-sdk/discord.js", () => { +vi.mock("../plugin-sdk-internal/discord.js", () => { moduleLoads.discord(); return { sendMessageDiscord: sendFns.discord }; }); -vi.mock("../plugin-sdk/slack.js", () => { +vi.mock("../plugin-sdk-internal/slack.js", () => { moduleLoads.slack(); return { sendMessageSlack: sendFns.slack }; }); -vi.mock("../plugin-sdk/signal.js", () => { +vi.mock("../plugin-sdk-internal/signal.js", () => { moduleLoads.signal(); return { sendMessageSignal: sendFns.signal }; }); -vi.mock("../plugin-sdk/imessage.js", () => { +vi.mock("../plugin-sdk-internal/imessage.js", () => { moduleLoads.imessage(); return { sendMessageIMessage: sendFns.imessage }; }); diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 7ebfbf74f5b..84bb107f97e 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -35,32 +35,32 @@ export function createDefaultDeps(): CliDeps { return { whatsapp: createLazySender( "whatsapp", - () => import("../plugin-sdk/whatsapp.js") as Promise>, + () => import("../plugin-sdk-internal/whatsapp.js") as Promise>, "sendMessageWhatsApp", ), telegram: createLazySender( "telegram", - () => import("../plugin-sdk/telegram.js") as Promise>, + () => import("../plugin-sdk-internal/telegram.js") as Promise>, "sendMessageTelegram", ), discord: createLazySender( "discord", - () => import("../plugin-sdk/discord.js") as Promise>, + () => import("../plugin-sdk-internal/discord.js") as Promise>, "sendMessageDiscord", ), slack: createLazySender( "slack", - () => import("../plugin-sdk/slack.js") as Promise>, + () => import("../plugin-sdk-internal/slack.js") as Promise>, "sendMessageSlack", ), signal: createLazySender( "signal", - () => import("../plugin-sdk/signal.js") as Promise>, + () => import("../plugin-sdk-internal/signal.js") as Promise>, "sendMessageSignal", ), imessage: createLazySender( "imessage", - () => import("../plugin-sdk/imessage.js") as Promise>, + () => import("../plugin-sdk-internal/imessage.js") as Promise>, "sendMessageIMessage", ), }; @@ -70,4 +70,4 @@ export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); } -export { logWebSelfId } from "../plugin-sdk/whatsapp.js"; +export { logWebSelfId } from "../plugin-sdk-internal/whatsapp.js"; diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index a1cbf5fa6d9..0c52c9b582a 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -29,7 +29,7 @@ import { listTelegramAccountIds, normalizeTelegramAllowFromEntry, resolveTelegramAccount, -} from "../plugin-sdk/telegram.js"; +} from "../plugin-sdk-internal/telegram.js"; import { formatChannelAccountsDefaultPath, formatSetExplicitDefaultInstruction, diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 1c93c61c800..7ca736ee448 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -8,7 +8,7 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.js"; -import { hasAnyWhatsAppAuth } from "../plugin-sdk/whatsapp.js"; +import { hasAnyWhatsAppAuth } from "../plugin-sdk-internal/whatsapp.js"; import { loadPluginManifestRegistry, type PluginManifestRegistry, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 3d93ee58465..b008b8bf869 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1,7 +1,7 @@ import { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../plugin-sdk/discord.js"; +} from "../plugin-sdk-internal/discord.js"; import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js"; import { IRC_FIELD_HELP } from "./schema.irc.js"; import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js"; diff --git a/src/config/sessions/explicit-session-key-normalization.ts b/src/config/sessions/explicit-session-key-normalization.ts index 08543e5a6d0..16b43a7c43c 100644 --- a/src/config/sessions/explicit-session-key-normalization.ts +++ b/src/config/sessions/explicit-session-key-normalization.ts @@ -1,5 +1,5 @@ import type { MsgContext } from "../../auto-reply/templating.js"; -import { normalizeExplicitDiscordSessionKey } from "../../plugin-sdk/discord.js"; +import { normalizeExplicitDiscordSessionKey } from "../../plugin-sdk-internal/discord.js"; type ExplicitSessionKeyNormalizer = (sessionKey: string, ctx: MsgContext) => string; type ExplicitSessionKeyNormalizerEntry = { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index c9269c6b8fd..aea4e7f8cfd 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,4 +1,4 @@ -import type { DiscordPluralKitConfig } from "../plugin-sdk/discord.js"; +import type { DiscordPluralKitConfig } from "../plugin-sdk-internal/discord.js"; import type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index e903cd15cab..585e273e613 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -13,7 +13,7 @@ import { resolveSessionDeliveryTarget, } from "../../infra/outbound/targets.js"; import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; +import { resolveWhatsAppAccount } from "../../plugin-sdk-internal/whatsapp.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js"; import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 0ad655f4990..fe35da1f356 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -13,7 +13,7 @@ import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; -import { handleSlackHttpRequest } from "../plugin-sdk/slack.js"; +import { handleSlackHttpRequest } from "../plugin-sdk-internal/slack.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index b429365a4a4..6646ab02e75 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -15,7 +15,7 @@ import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js import type { SessionScope } from "../config/sessions/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js"; -import { listTelegramAccountIds } from "../plugin-sdk/telegram.js"; +import { listTelegramAccountIds } from "../plugin-sdk-internal/telegram.js"; import { buildAgentMainSessionKey, DEFAULT_ACCOUNT_ID, diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index d89f768071b..82ffb8dde5c 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,21 +1,8 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; -export type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; -export type { - DiscordSendComponents, - DiscordSendEmbeds, -} from "../../extensions/discord/src/send.shared.js"; export * from "./channel-plugin-common.js"; -export { - createDiscordActionGate, - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "../../extensions/discord/src/accounts.js"; -export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -24,19 +11,6 @@ export { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; -export { - looksLikeDiscordTargetId, - normalizeDiscordMessagingTarget, - normalizeDiscordOutboundTarget, -} from "../../extensions/discord/src/normalize.js"; -export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { collectDiscordStatusIssues } from "../../extensions/discord/src/status-issues.js"; -export { - DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, - DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../../extensions/discord/src/monitor/timeouts.js"; -export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/src/session-key-normalization.js"; -export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js"; export { resolveDefaultGroupPolicy, @@ -46,70 +20,8 @@ export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; -export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; -export { - autoBindSpawnedDiscordSubagent, - listThreadBindingsBySessionKey, - unbindThreadBindingsBySessionKey, -} from "../../extensions/discord/src/monitor/thread-bindings.js"; -export { getGateway } from "../../extensions/discord/src/monitor/gateway-registry.js"; -export { getPresence } from "../../extensions/discord/src/monitor/presence-cache.js"; -export { readDiscordComponentSpec } from "../../extensions/discord/src/components.js"; -export { resolveDiscordChannelId } from "../../extensions/discord/src/targets.js"; -export { - addRoleDiscord, - banMemberDiscord, - createChannelDiscord, - createScheduledEventDiscord, - createThreadDiscord, - deleteChannelDiscord, - deleteMessageDiscord, - editChannelDiscord, - editMessageDiscord, - fetchChannelInfoDiscord, - fetchChannelPermissionsDiscord, - fetchMemberInfoDiscord, - fetchMessageDiscord, - fetchReactionsDiscord, - fetchRoleInfoDiscord, - fetchVoiceStatusDiscord, - hasAnyGuildPermissionDiscord, - kickMemberDiscord, - listGuildChannelsDiscord, - listGuildEmojisDiscord, - listPinsDiscord, - listScheduledEventsDiscord, - listThreadsDiscord, - moveChannelDiscord, - pinMessageDiscord, - reactMessageDiscord, - readMessagesDiscord, - removeChannelPermissionDiscord, - removeOwnReactionsDiscord, - removeReactionDiscord, - removeRoleDiscord, - searchMessagesDiscord, - sendDiscordComponentMessage, - sendMessageDiscord, - sendPollDiscord, - sendStickerDiscord, - sendVoiceMessageDiscord, - setChannelPermissionDiscord, - timeoutMemberDiscord, - unpinMessageDiscord, - uploadEmojiDiscord, - uploadStickerDiscord, -} from "../../extensions/discord/src/send.js"; -export { discordMessageActions } from "../../extensions/discord/src/channel-actions.js"; -export type { - ThreadBindingManager, - ThreadBindingRecord, - ThreadBindingTargetKind, -} from "../../extensions/discord/src/monitor/thread-bindings.js"; - export { buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary, diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 0fe2e278d40..f896799b323 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -1,11 +1,5 @@ -export type { ResolvedIMessageAccount } from "../../extensions/imessage/src/accounts.js"; export type { IMessageAccountConfig } from "../config/types.js"; export * from "./channel-plugin-common.js"; -export { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "../../extensions/imessage/src/accounts.js"; export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, @@ -15,20 +9,6 @@ export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, } from "../channels/plugins/normalize/imessage.js"; -export { - createAllowedChatSenderMatcher, - parseChatAllowTargetPrefixes, - parseChatTargetPrefixesOrThrow, - resolveServicePrefixedChatTarget, - resolveServicePrefixedAllowTarget, - resolveServicePrefixedOrChatAllowTarget, - resolveServicePrefixedTarget, -} from "../../extensions/imessage/src/target-parsing-helpers.js"; -export type { - ChatSenderAllowParams, - ParsedChatTarget, -} from "../../extensions/imessage/src/target-parsing-helpers.js"; -export { sendMessageIMessage } from "../../extensions/imessage/src/send.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, @@ -38,8 +18,6 @@ export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { imessageSetupWizard } from "../../extensions/imessage/src/setup-surface.js"; -export { imessageSetupAdapter } from "../../extensions/imessage/src/setup-core.js"; export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 4afc94eebab..e43be3bfadd 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -661,110 +661,6 @@ export { extractOriginalFilename } from "../media/store.js"; export { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; export type { SkillCommandSpec } from "../agents/skills.js"; -// Channel: Discord -export { - autoBindSpawnedDiscordSubagent, - collectDiscordAuditChannelIds, - collectDiscordStatusIssues, - discordSetupAdapter, - discordSetupWizard, - inspectDiscordAccount, - listDiscordAccountIds, - looksLikeDiscordTargetId, - normalizeDiscordMessagingTarget, - normalizeDiscordOutboundTarget, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, - type InspectedDiscordAccount, - type ResolvedDiscordAccount, - type ThreadBindingManager, - type ThreadBindingRecord, - type ThreadBindingTargetKind, - listThreadBindingsBySessionKey, - unbindThreadBindingsBySessionKey, -} from "./discord.js"; - -// Channel: iMessage -export { - createAllowedChatSenderMatcher, - imessageSetupAdapter, - imessageSetupWizard, - listIMessageAccountIds, - looksLikeIMessageTargetId, - normalizeIMessageMessagingTarget, - parseChatAllowTargetPrefixes, - parseChatTargetPrefixesOrThrow, - resolveServicePrefixedChatTarget, - resolveServicePrefixedAllowTarget, - resolveServicePrefixedOrChatAllowTarget, - resolveServicePrefixedTarget, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, - type ChatSenderAllowParams, - type ParsedChatTarget, - type ResolvedIMessageAccount, -} from "./imessage.js"; - -// Channel: Slack -export { - buildSlackThreadingToolContext, - extractSlackToolSend, - inspectSlackAccount, - listEnabledSlackAccounts, - listSlackAccountIds, - listSlackMessageActions, - looksLikeSlackTargetId, - normalizeSlackMessagingTarget, - resolveDefaultSlackAccountId, - resolveSlackAccount, - resolveSlackReplyToMode, - slackSetupAdapter, - slackSetupWizard, - type InspectedSlackAccount, - type ResolvedSlackAccount, -} from "./slack.js"; - -// Channel: Telegram -export { - buildBrowseProvidersButton, - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - fetchTelegramChatId, - getModelsPageSize, - inspectTelegramAccount, - isNumericTelegramUserId, - isTelegramExecApprovalApprover, - isTelegramExecApprovalClientEnabled, - listTelegramAccountIds, - looksLikeTelegramTargetId, - normalizeTelegramAllowFromEntry, - normalizeTelegramMessagingTarget, - parseTelegramReplyToMessageId, - parseTelegramThreadId, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, - telegramSetupAdapter, - telegramSetupWizard, - type ResolvedTelegramAccount, - type InspectedTelegramAccount, - type ProviderInfo, - type TelegramProbe, - collectTelegramStatusIssues, -} from "./telegram.js"; - -// Channel: Signal -export { - listSignalAccountIds, - looksLikeSignalTargetId, - normalizeSignalMessagingTarget, - resolveDefaultSignalAccountId, - resolveSignalAccount, - signalSetupAdapter, - signalSetupWizard, - type ResolvedSignalAccount, -} from "./signal.js"; - // Channel: WhatsApp — WhatsApp-specific exports moved to extensions/whatsapp/src/ export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 3683115143e..86f83b06318 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,19 +1,7 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; -export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; export type { SignalAccountConfig } from "../config/types.js"; export * from "./channel-plugin-common.js"; -export { - listEnabledSignalAccounts, - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "../../extensions/signal/src/accounts.js"; -export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js"; -export { - removeReactionSignal, - sendReactionSignal, -} from "../../extensions/signal/src/send-reactions.js"; -export { sendMessageSignal } from "../../extensions/signal/src/send.js"; + export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, @@ -23,8 +11,6 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; -export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; -export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; export { normalizeE164 } from "../utils.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 8ac3915ec31..93ad140bfad 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,17 +1,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { SlackAccountConfig } from "../config/types.slack.js"; -export type { InspectedSlackAccount } from "../../extensions/slack/src/account-inspect.js"; -export type { ResolvedSlackAccount } from "../../extensions/slack/src/accounts.js"; export * from "./channel-plugin-common.js"; -export { - listEnabledSlackAccounts, - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, - resolveSlackReplyToMode, -} from "../../extensions/slack/src/accounts.js"; -export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; -export { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; + export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -25,34 +15,6 @@ export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, } from "../channels/plugins/normalize/slack.js"; -export { parseSlackTarget, resolveSlackChannelId } from "./slack-targets.js"; -export { - extractSlackToolSend, - listSlackMessageActions, -} from "../../extensions/slack/src/message-actions.js"; -export { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; -export { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; -export { handleSlackHttpRequest } from "../../extensions/slack/src/http/index.js"; -export { sendMessageSlack } from "../../extensions/slack/src/send.js"; -export { - deleteSlackMessage, - downloadSlackFile, - editSlackMessage, - getSlackMemberInfo, - listSlackEmojis, - listSlackPins, - listSlackReactions, - pinSlackMessage, - reactSlackMessage, - readSlackMessages, - removeOwnSlackReactions, - removeSlackReaction, - sendSlackMessage, - unpinSlackMessage, -} from "../../extensions/slack/src/actions.js"; -export { recordSlackThreadParticipation } from "../../extensions/slack/src/sent-thread-cache.js"; -export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; - export { resolveDefaultGroupPolicy, resolveOpenProviderRuntimeGroupPolicy, @@ -61,8 +23,5 @@ export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; -export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; - -export { handleSlackMessageAction } from "./slack-message-actions.js"; +export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 5c26ebf44ca..5e3f62849d7 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -20,6 +20,8 @@ const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ load: () => importPluginSdkSubpath(`openclaw/plugin-sdk/${id}`), })); +const asExports = (mod: object) => mod as Record; + describe("plugin-sdk subpath exports", () => { it("exports compat helpers", () => { expect(typeof compatSdk.emptyPluginConfigSchema).toBe("function"); @@ -34,37 +36,38 @@ describe("plugin-sdk subpath exports", () => { }); it("exports Discord helpers", () => { - expect(typeof discordSdk.resolveDiscordAccount).toBe("function"); - expect(typeof discordSdk.inspectDiscordAccount).toBe("function"); - expect(typeof discordSdk.discordSetupWizard).toBe("object"); - expect(typeof discordSdk.discordSetupAdapter).toBe("object"); + expect(typeof discordSdk.buildChannelConfigSchema).toBe("function"); + expect(typeof discordSdk.DiscordConfigSchema).toBe("object"); + expect(typeof discordSdk.projectCredentialSnapshotFields).toBe("function"); + expect("resolveDiscordAccount" in asExports(discordSdk)).toBe(false); }); it("exports Slack helpers", () => { - expect(typeof slackSdk.resolveSlackAccount).toBe("function"); - expect(typeof slackSdk.inspectSlackAccount).toBe("function"); - expect(typeof slackSdk.handleSlackMessageAction).toBe("function"); - expect(typeof slackSdk.slackSetupWizard).toBe("object"); - expect(typeof slackSdk.slackSetupAdapter).toBe("object"); + expect(typeof slackSdk.buildChannelConfigSchema).toBe("function"); + expect(typeof slackSdk.SlackConfigSchema).toBe("object"); + expect(typeof slackSdk.looksLikeSlackTargetId).toBe("function"); + expect("resolveSlackAccount" in asExports(slackSdk)).toBe(false); }); it("exports Telegram helpers", () => { - expect(typeof telegramSdk.resolveTelegramAccount).toBe("function"); - expect(typeof telegramSdk.inspectTelegramAccount).toBe("function"); - expect(typeof telegramSdk.telegramSetupWizard).toBe("object"); - expect(typeof telegramSdk.telegramSetupAdapter).toBe("object"); + expect(typeof telegramSdk.buildChannelConfigSchema).toBe("function"); + expect(typeof telegramSdk.TelegramConfigSchema).toBe("object"); + expect(typeof telegramSdk.projectCredentialSnapshotFields).toBe("function"); + expect("resolveTelegramAccount" in asExports(telegramSdk)).toBe(false); }); it("exports Signal helpers", () => { - expect(typeof signalSdk.resolveSignalAccount).toBe("function"); - expect(typeof signalSdk.signalSetupWizard).toBe("object"); - expect(typeof signalSdk.signalSetupAdapter).toBe("object"); + expect(typeof signalSdk.buildBaseAccountStatusSnapshot).toBe("function"); + expect(typeof signalSdk.SignalConfigSchema).toBe("object"); + expect(typeof signalSdk.normalizeSignalMessagingTarget).toBe("function"); + expect("resolveSignalAccount" in asExports(signalSdk)).toBe(false); }); it("exports iMessage helpers", () => { - expect(typeof imessageSdk.resolveIMessageAccount).toBe("function"); - expect(typeof imessageSdk.imessageSetupWizard).toBe("object"); - expect(typeof imessageSdk.imessageSetupAdapter).toBe("object"); + expect(typeof imessageSdk.IMessageConfigSchema).toBe("object"); + expect(typeof imessageSdk.resolveIMessageConfigAllowFrom).toBe("function"); + expect(typeof imessageSdk.looksLikeIMessageTargetId).toBe("function"); + expect("resolveIMessageAccount" in asExports(imessageSdk)).toBe(false); }); it("exports IRC helpers", async () => { diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 52d0269f712..397a48fa019 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -8,16 +8,8 @@ export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js"; -export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; -export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; -export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; -export type { - TelegramButtonStyle, - TelegramInlineButtons, -} from "../../extensions/telegram/src/button-types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { @@ -32,17 +24,8 @@ export { } from "../channels/plugins/config-helpers.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - export { getChatChannelMeta } from "../channels/registry.js"; -export { - createTelegramActionGate, - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramPollActionGateState, - resolveTelegramAccount, -} from "../../extensions/telegram/src/accounts.js"; -export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -51,52 +34,6 @@ export { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; -export { - looksLikeTelegramTargetId, - normalizeTelegramMessagingTarget, -} from "../../extensions/telegram/src/normalize.js"; -export { - parseTelegramReplyToMessageId, - parseTelegramThreadId, -} from "../../extensions/telegram/src/outbound-params.js"; -export { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -} from "../../extensions/telegram/src/allow-from.js"; -export { fetchTelegramChatId } from "../../extensions/telegram/src/api-fetch.js"; -export { - resolveTelegramInlineButtonsScope, - resolveTelegramTargetChatType, -} from "../../extensions/telegram/src/inline-buttons.js"; -export { resolveTelegramReactionLevel } from "../../extensions/telegram/src/reaction-level.js"; -export { - createForumTopicTelegram, - deleteMessageTelegram, - editForumTopicTelegram, - editMessageTelegram, - reactMessageTelegram, - sendMessageTelegram, - sendPollTelegram, - sendStickerTelegram, -} from "../../extensions/telegram/src/send.js"; -export { getCacheStats, searchStickers } from "../../extensions/telegram/src/sticker-cache.js"; -export { resolveTelegramToken } from "../../extensions/telegram/src/token.js"; -export { telegramMessageActions } from "../../extensions/telegram/src/channel-actions.js"; -export { collectTelegramStatusIssues } from "../../extensions/telegram/src/status-issues.js"; -export { sendTelegramPayloadMessages } from "../../extensions/telegram/src/outbound-adapter.js"; -export { - buildBrowseProvidersButton, - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - getModelsPageSize, - type ProviderInfo, -} from "../../extensions/telegram/src/model-buttons.js"; -export { - isTelegramExecApprovalApprover, - isTelegramExecApprovalClientEnabled, -} from "../../extensions/telegram/src/exec-approvals.js"; -export type { StickerMetadata } from "../../extensions/telegram/src/bot/types.js"; export { resolveAllowlistProviderRuntimeGroupPolicy, @@ -106,8 +43,6 @@ export { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { telegramSetupWizard } from "../../extensions/telegram/src/setup-surface.js"; -export { telegramSetupAdapter } from "../../extensions/telegram/src/setup-core.js"; export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildTokenChannelStatusSummary } from "./status-helpers.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 56db52bfc13..7e4debbef43 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -6,7 +6,6 @@ export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { @@ -15,7 +14,6 @@ export { } from "../channels/plugins/setup-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; - export { getChatChannelMeta } from "../channels/registry.js"; export { formatWhatsAppConfigAllowFromEntries, @@ -26,58 +24,6 @@ export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; -export { - hasAnyWhatsAppAuth, - listEnabledWhatsAppAccounts, - resolveWhatsAppAccount, -} from "../../extensions/whatsapp/src/accounts.js"; -export { - WA_WEB_AUTH_DIR, - logWebSelfId, - logoutWeb, - pickWebChannel, - webAuthExists, -} from "../../extensions/whatsapp/src/auth-store.js"; -export { - DEFAULT_WEB_MEDIA_BYTES, - HEARTBEAT_PROMPT, - HEARTBEAT_TOKEN, - monitorWebChannel, - resolveHeartbeatRecipients, - runWebHeartbeatOnce, -} from "../../extensions/whatsapp/src/auto-reply.js"; -export type { - WebChannelStatus, - WebMonitorTuning, -} from "../../extensions/whatsapp/src/auto-reply.js"; -export { - extractMediaPlaceholder, - extractText, - monitorWebInbox, -} from "../../extensions/whatsapp/src/inbound.js"; -export type { - WebInboundMessage, - WebListenerCloseReason, -} from "../../extensions/whatsapp/src/inbound.js"; -export { loginWeb } from "../../extensions/whatsapp/src/login.js"; -export { - getDefaultLocalRoots, - loadWebMedia, - loadWebMediaRaw, - optimizeImageToJpeg, -} from "../../extensions/whatsapp/src/media.js"; -export { - sendMessageWhatsApp, - sendPollWhatsApp, - sendReactionWhatsApp, -} from "../../extensions/whatsapp/src/send.js"; -export { - createWaSocket, - formatError, - getStatusCode, - waitForWaConnection, -} from "../../extensions/whatsapp/src/session.js"; -export { createWhatsAppLoginTool } from "../../extensions/whatsapp/src/agent-tools-login.js"; export { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; export { collectAllowlistProviderGroupPolicyWarnings, @@ -104,5 +50,4 @@ export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js export { createActionGate, readStringParam } from "../agents/tools/common.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; - export { normalizeE164 } from "../utils.js"; diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index 867f0a91162..c3435fc2a64 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -6,4 +6,4 @@ export { export { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "../plugin-sdk/telegram.js"; +} from "../plugin-sdk-internal/telegram.js"; From 4c8678c0b4a0bf62a0f223b0fdd74592529edb07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:34:35 -0700 Subject: [PATCH 07/56] refactor: add private channel sdk bridges --- .../slack/src/message-action-dispatch.ts | 331 ++++++++++++++++++ src/plugin-sdk-internal/discord.ts | 116 ++++++ src/plugin-sdk-internal/imessage.ts | 46 +++ src/plugin-sdk-internal/signal.ts | 38 ++ src/plugin-sdk-internal/slack.ts | 68 ++++ src/plugin-sdk-internal/telegram.ts | 113 ++++++ src/plugin-sdk-internal/whatsapp.ts | 108 ++++++ 7 files changed, 820 insertions(+) create mode 100644 extensions/slack/src/message-action-dispatch.ts create mode 100644 src/plugin-sdk-internal/discord.ts create mode 100644 src/plugin-sdk-internal/imessage.ts create mode 100644 src/plugin-sdk-internal/signal.ts create mode 100644 src/plugin-sdk-internal/slack.ts create mode 100644 src/plugin-sdk-internal/telegram.ts create mode 100644 src/plugin-sdk-internal/whatsapp.ts diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts new file mode 100644 index 00000000000..b251d0b80eb --- /dev/null +++ b/extensions/slack/src/message-action-dispatch.ts @@ -0,0 +1,331 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import { buildSlackInteractiveBlocks } from "./blocks-render.js"; + +type SlackActionInvoke = ( + action: Record, + cfg: ChannelMessageActionContext["cfg"], + toolContext?: ChannelMessageActionContext["toolContext"], +) => Promise>; + +type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger"; + +type InteractiveReplyButton = { + label: string; + value: string; + style?: InteractiveButtonStyle; +}; + +type InteractiveReplyOption = { + label: string; + value: string; +}; + +type InteractiveReplyBlock = + | { type: "text"; text: string } + | { type: "buttons"; buttons: InteractiveReplyButton[] } + | { type: "select"; placeholder?: string; options: InteractiveReplyOption[] }; + +type InteractiveReply = { + blocks: InteractiveReplyBlock[]; +}; + +function readTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined { + const style = readTrimmedString(value)?.toLowerCase(); + return style === "primary" || style === "secondary" || style === "success" || style === "danger" + ? style + : undefined; +} + +function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + const label = readTrimmedString(record.label) ?? readTrimmedString(record.text); + const value = + readTrimmedString(record.value) ?? + readTrimmedString(record.callbackData) ?? + readTrimmedString(record.callback_data); + if (!label || !value) { + return undefined; + } + return { label, value, style: normalizeButtonStyle(record.style) }; +} + +function normalizeInteractiveOption(raw: unknown): InteractiveReplyOption | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + const label = readTrimmedString(record.label) ?? readTrimmedString(record.text); + const value = readTrimmedString(record.value); + return label && value ? { label, value } : undefined; +} + +function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + const blocks = Array.isArray(record.blocks) + ? record.blocks + .map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return undefined; + } + const block = entry as Record; + const type = readTrimmedString(block.type)?.toLowerCase(); + if (type === "text") { + const text = readTrimmedString(block.text); + return text ? ({ type: "text", text } as const) : undefined; + } + if (type === "buttons") { + const buttons = Array.isArray(block.buttons) + ? block.buttons + .map((button) => normalizeInteractiveButton(button)) + .filter((button): button is InteractiveReplyButton => Boolean(button)) + : []; + return buttons.length > 0 ? ({ type: "buttons", buttons } as const) : undefined; + } + if (type === "select") { + const options = Array.isArray(block.options) + ? block.options + .map((option) => normalizeInteractiveOption(option)) + .filter((option): option is InteractiveReplyOption => Boolean(option)) + : []; + return options.length > 0 + ? ({ + type: "select", + placeholder: readTrimmedString(block.placeholder), + options, + } as const) + : undefined; + } + return undefined; + }) + .filter((entry): entry is InteractiveReplyBlock => Boolean(entry)) + : []; + return blocks.length > 0 ? { blocks } : undefined; +} + +function readStringParam( + params: Record, + key: string, + options: { required?: boolean; trim?: boolean; label?: string; allowEmpty?: boolean } = {}, +): string | undefined { + const { required = false, trim = true, label = key, allowEmpty = false } = options; + const raw = params[key]; + if (typeof raw !== "string") { + if (required) { + throw new Error(`${label} required`); + } + return undefined; + } + const value = trim ? raw.trim() : raw; + if (!value && !allowEmpty) { + if (required) { + throw new Error(`${label} required`); + } + return undefined; + } + return value; +} + +function readNumberParam( + params: Record, + key: string, + options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {}, +): number | undefined { + const { required = false, label = key, integer = false, strict = false } = options; + const raw = params[key]; + let value: number | undefined; + if (typeof raw === "number" && Number.isFinite(raw)) { + value = raw; + } else if (typeof raw === "string") { + const trimmed = raw.trim(); + if (trimmed) { + const parsed = strict ? Number(trimmed) : Number.parseFloat(trimmed); + if (Number.isFinite(parsed)) { + value = parsed; + } + } + } + if (value === undefined) { + if (required) { + throw new Error(`${label} required`); + } + return undefined; + } + return integer ? Math.trunc(value) : value; +} + +function readSlackBlocksParam(actionParams: Record) { + return parseSlackBlocksInput(actionParams.blocks) as Record[] | undefined; +} + +export async function handleSlackMessageAction(params: { + providerId: string; + ctx: ChannelMessageActionContext; + invoke: SlackActionInvoke; + normalizeChannelId?: (channelId: string) => string; + includeReadThreadId?: boolean; +}): Promise> { + const { providerId, ctx, invoke, normalizeChannelId, includeReadThreadId = false } = params; + const { action, cfg, params: actionParams } = ctx; + const accountId = ctx.accountId ?? undefined; + const resolveChannelId = () => { + const channelId = + readStringParam(actionParams, "channelId") ?? + readStringParam(actionParams, "to", { required: true }); + return normalizeChannelId ? normalizeChannelId(channelId) : channelId; + }; + + if (action === "send") { + const to = readStringParam(actionParams, "to", { required: true }); + const content = readStringParam(actionParams, "message", { allowEmpty: true }); + const mediaUrl = readStringParam(actionParams, "media", { trim: false }); + const interactive = normalizeInteractiveReply(actionParams.interactive); + const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined; + const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks; + if (!content && !mediaUrl && !blocks) { + throw new Error("Slack send requires message, blocks, or media."); + } + if (mediaUrl && blocks) { + throw new Error("Slack send does not support blocks with media."); + } + const threadId = readStringParam(actionParams, "threadId"); + const replyTo = readStringParam(actionParams, "replyTo"); + return await invoke( + { + action: "sendMessage", + to, + content: content ?? "", + mediaUrl: mediaUrl ?? undefined, + accountId, + threadTs: threadId ?? replyTo ?? undefined, + ...(blocks ? { blocks } : {}), + }, + cfg, + ctx.toolContext, + ); + } + + if (action === "react") { + const messageId = readStringParam(actionParams, "messageId", { required: true }); + const emoji = readStringParam(actionParams, "emoji", { allowEmpty: true }); + const remove = typeof actionParams.remove === "boolean" ? actionParams.remove : undefined; + return await invoke( + { action: "react", channelId: resolveChannelId(), messageId, emoji, remove, accountId }, + cfg, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(actionParams, "messageId", { required: true }); + const limit = readNumberParam(actionParams, "limit", { integer: true }); + return await invoke( + { action: "reactions", channelId: resolveChannelId(), messageId, limit, accountId }, + cfg, + ); + } + + if (action === "read") { + const limit = readNumberParam(actionParams, "limit", { integer: true }); + const readAction: Record = { + action: "readMessages", + channelId: resolveChannelId(), + limit, + before: readStringParam(actionParams, "before"), + after: readStringParam(actionParams, "after"), + accountId, + }; + if (includeReadThreadId) { + readAction.threadId = readStringParam(actionParams, "threadId"); + } + return await invoke(readAction, cfg); + } + + if (action === "edit") { + const messageId = readStringParam(actionParams, "messageId", { required: true }); + const content = readStringParam(actionParams, "message", { allowEmpty: true }); + const blocks = readSlackBlocksParam(actionParams); + if (!content && !blocks) { + throw new Error("Slack edit requires message or blocks."); + } + return await invoke( + { + action: "editMessage", + channelId: resolveChannelId(), + messageId, + content: content ?? "", + blocks, + accountId, + }, + cfg, + ); + } + + if (action === "delete") { + const messageId = readStringParam(actionParams, "messageId", { required: true }); + return await invoke( + { action: "deleteMessage", channelId: resolveChannelId(), messageId, accountId }, + cfg, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" + ? undefined + : readStringParam(actionParams, "messageId", { required: true }); + return await invoke( + { + action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", + channelId: resolveChannelId(), + messageId, + accountId, + }, + cfg, + ); + } + + if (action === "member-info") { + const userId = readStringParam(actionParams, "userId", { required: true }); + return await invoke({ action: "memberInfo", userId, accountId }, cfg); + } + + if (action === "emoji-list") { + const limit = readNumberParam(actionParams, "limit", { integer: true }); + return await invoke({ action: "emojiList", limit, accountId }, cfg); + } + + if (action === "download-file") { + const fileId = readStringParam(actionParams, "fileId", { required: true }); + const channelId = + readStringParam(actionParams, "channelId") ?? readStringParam(actionParams, "to"); + const threadId = + readStringParam(actionParams, "threadId") ?? readStringParam(actionParams, "replyTo"); + return await invoke( + { + action: "downloadFile", + fileId, + channelId: channelId ?? undefined, + threadId: threadId ?? undefined, + accountId, + }, + cfg, + ); + } + + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); +} diff --git a/src/plugin-sdk-internal/discord.ts b/src/plugin-sdk-internal/discord.ts new file mode 100644 index 00000000000..9a29900c717 --- /dev/null +++ b/src/plugin-sdk-internal/discord.ts @@ -0,0 +1,116 @@ +export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; +export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +export type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; +export type { + DiscordSendComponents, + DiscordSendEmbeds, +} from "../../extensions/discord/src/send.shared.js"; +export * from "../plugin-sdk/channel-plugin-common.js"; + +export { + createDiscordActionGate, + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "../../extensions/discord/src/accounts.js"; +export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "../channels/account-snapshot-fields.js"; +export { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, +} from "../channels/plugins/directory-config.js"; +export { + looksLikeDiscordTargetId, + normalizeDiscordMessagingTarget, + normalizeDiscordOutboundTarget, +} from "../../extensions/discord/src/normalize.js"; +export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; +export { collectDiscordStatusIssues } from "../../extensions/discord/src/status-issues.js"; +export { + DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, + DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, +} from "../../extensions/discord/src/monitor/timeouts.js"; +export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/src/session-key-normalization.js"; +export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js"; + +export { + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, +} from "../config/runtime-group-policy.js"; +export { + resolveDiscordGroupRequireMention, + resolveDiscordGroupToolPolicy, +} from "../channels/plugins/group-mentions.js"; +export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; +export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; + +export { + autoBindSpawnedDiscordSubagent, + listThreadBindingsBySessionKey, + unbindThreadBindingsBySessionKey, +} from "../../extensions/discord/src/monitor/thread-bindings.js"; +export { getGateway } from "../../extensions/discord/src/monitor/gateway-registry.js"; +export { getPresence } from "../../extensions/discord/src/monitor/presence-cache.js"; +export { readDiscordComponentSpec } from "../../extensions/discord/src/components.js"; +export { resolveDiscordChannelId } from "../../extensions/discord/src/targets.js"; +export { + addRoleDiscord, + banMemberDiscord, + createChannelDiscord, + createScheduledEventDiscord, + createThreadDiscord, + deleteChannelDiscord, + deleteMessageDiscord, + editChannelDiscord, + editMessageDiscord, + fetchChannelInfoDiscord, + fetchChannelPermissionsDiscord, + fetchMemberInfoDiscord, + fetchMessageDiscord, + fetchReactionsDiscord, + fetchRoleInfoDiscord, + fetchVoiceStatusDiscord, + hasAnyGuildPermissionDiscord, + kickMemberDiscord, + listGuildChannelsDiscord, + listGuildEmojisDiscord, + listPinsDiscord, + listScheduledEventsDiscord, + listThreadsDiscord, + moveChannelDiscord, + pinMessageDiscord, + reactMessageDiscord, + readMessagesDiscord, + removeChannelPermissionDiscord, + removeOwnReactionsDiscord, + removeReactionDiscord, + removeRoleDiscord, + searchMessagesDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + sendVoiceMessageDiscord, + setChannelPermissionDiscord, + timeoutMemberDiscord, + unpinMessageDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, +} from "../../extensions/discord/src/send.js"; +export { discordMessageActions } from "../../extensions/discord/src/channel-actions.js"; +export type { + ThreadBindingManager, + ThreadBindingRecord, + ThreadBindingTargetKind, +} from "../../extensions/discord/src/monitor/thread-bindings.js"; + +export { + buildComputedAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "../plugin-sdk/status-helpers.js"; diff --git a/src/plugin-sdk-internal/imessage.ts b/src/plugin-sdk-internal/imessage.ts new file mode 100644 index 00000000000..170dd7ff188 --- /dev/null +++ b/src/plugin-sdk-internal/imessage.ts @@ -0,0 +1,46 @@ +export type { ResolvedIMessageAccount } from "../../extensions/imessage/src/accounts.js"; +export type { IMessageAccountConfig } from "../config/types.js"; +export * from "../plugin-sdk/channel-plugin-common.js"; +export { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "../../extensions/imessage/src/accounts.js"; +export { + formatTrimmedAllowFromEntries, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, +} from "../plugin-sdk/channel-config-helpers.js"; +export { + looksLikeIMessageTargetId, + normalizeIMessageMessagingTarget, +} from "../channels/plugins/normalize/imessage.js"; +export { + createAllowedChatSenderMatcher, + parseChatAllowTargetPrefixes, + parseChatTargetPrefixesOrThrow, + resolveServicePrefixedChatTarget, + resolveServicePrefixedAllowTarget, + resolveServicePrefixedOrChatAllowTarget, + resolveServicePrefixedTarget, +} from "../../extensions/imessage/src/target-parsing-helpers.js"; +export type { + ChatSenderAllowParams, + ParsedChatTarget, +} from "../../extensions/imessage/src/target-parsing-helpers.js"; +export { sendMessageIMessage } from "../../extensions/imessage/src/send.js"; + +export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +} from "../config/runtime-group-policy.js"; +export { + resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, +} from "../channels/plugins/group-mentions.js"; +export { imessageSetupWizard } from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupAdapter } from "../../extensions/imessage/src/setup-core.js"; +export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; + +export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; +export { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; diff --git a/src/plugin-sdk-internal/signal.ts b/src/plugin-sdk-internal/signal.ts new file mode 100644 index 00000000000..4594420af8d --- /dev/null +++ b/src/plugin-sdk-internal/signal.ts @@ -0,0 +1,38 @@ +export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; +export type { SignalAccountConfig } from "../config/types.js"; +export * from "../plugin-sdk/channel-plugin-common.js"; +export { + listEnabledSignalAccounts, + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "../../extensions/signal/src/accounts.js"; +export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js"; +export { + removeReactionSignal, + sendReactionSignal, +} from "../../extensions/signal/src/send-reactions.js"; +export { sendMessageSignal } from "../../extensions/signal/src/send.js"; +export { + looksLikeSignalTargetId, + normalizeSignalMessagingTarget, +} from "../channels/plugins/normalize/signal.js"; + +export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +} from "../config/runtime-group-policy.js"; +export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; +export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; + +export { normalizeE164 } from "../utils.js"; +export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; + +export { + buildBaseAccountStatusSnapshot, + buildBaseChannelStatusSummary, + collectStatusIssuesFromLastError, + createDefaultChannelRuntimeState, +} from "../plugin-sdk/status-helpers.js"; diff --git a/src/plugin-sdk-internal/slack.ts b/src/plugin-sdk-internal/slack.ts new file mode 100644 index 00000000000..abde5688cdb --- /dev/null +++ b/src/plugin-sdk-internal/slack.ts @@ -0,0 +1,68 @@ +export type { OpenClawConfig } from "../config/config.js"; +export type { SlackAccountConfig } from "../config/types.slack.js"; +export type { InspectedSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +export type { ResolvedSlackAccount } from "../../extensions/slack/src/accounts.js"; +export * from "../plugin-sdk/channel-plugin-common.js"; +export { + listEnabledSlackAccounts, + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + resolveSlackReplyToMode, +} from "../../extensions/slack/src/accounts.js"; +export { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; +export { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js"; +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, + resolveConfiguredFromRequiredCredentialStatuses, +} from "../channels/account-snapshot-fields.js"; +export { + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, +} from "../channels/plugins/directory-config.js"; +export { + looksLikeSlackTargetId, + normalizeSlackMessagingTarget, +} from "../channels/plugins/normalize/slack.js"; +export { parseSlackTarget, resolveSlackChannelId } from "../plugin-sdk/slack-targets.js"; +export { + extractSlackToolSend, + listSlackMessageActions, +} from "../../extensions/slack/src/message-actions.js"; +export { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js"; +export { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js"; +export { handleSlackHttpRequest } from "../../extensions/slack/src/http/index.js"; +export { sendMessageSlack } from "../../extensions/slack/src/send.js"; +export { + deleteSlackMessage, + downloadSlackFile, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + removeOwnSlackReactions, + removeSlackReaction, + sendSlackMessage, + unpinSlackMessage, +} from "../../extensions/slack/src/actions.js"; +export { recordSlackThreadParticipation } from "../../extensions/slack/src/sent-thread-cache.js"; +export { buildComputedAccountStatusSnapshot } from "../plugin-sdk/status-helpers.js"; + +export { + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, +} from "../config/runtime-group-policy.js"; +export { + resolveSlackGroupRequireMention, + resolveSlackGroupToolPolicy, +} from "../channels/plugins/group-mentions.js"; +export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; +export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; +export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; + +export { handleSlackMessageAction } from "../plugin-sdk/slack-message-actions.js"; diff --git a/src/plugin-sdk-internal/telegram.ts b/src/plugin-sdk-internal/telegram.ts new file mode 100644 index 00000000000..bb983d690d1 --- /dev/null +++ b/src/plugin-sdk-internal/telegram.ts @@ -0,0 +1,113 @@ +export type { + ChannelAccountSnapshot, + ChannelGatewayContext, + ChannelMessageActionAdapter, +} from "../channels/plugins/types.js"; +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js"; +export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; +export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; +export type { + TelegramButtonStyle, + TelegramInlineButtons, +} from "../../extensions/telegram/src/button-types.js"; + +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + deleteAccountFromConfigSection, + clearAccountEntryFields, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; + +export { getChatChannelMeta } from "../channels/registry.js"; + +export { + createTelegramActionGate, + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramPollActionGateState, + resolveTelegramAccount, +} from "../../extensions/telegram/src/accounts.js"; +export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "../channels/account-snapshot-fields.js"; +export { + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, +} from "../channels/plugins/directory-config.js"; +export { + looksLikeTelegramTargetId, + normalizeTelegramMessagingTarget, +} from "../../extensions/telegram/src/normalize.js"; +export { + parseTelegramReplyToMessageId, + parseTelegramThreadId, +} from "../../extensions/telegram/src/outbound-params.js"; +export { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../../extensions/telegram/src/allow-from.js"; +export { fetchTelegramChatId } from "../../extensions/telegram/src/api-fetch.js"; +export { + resolveTelegramInlineButtonsScope, + resolveTelegramTargetChatType, +} from "../../extensions/telegram/src/inline-buttons.js"; +export { resolveTelegramReactionLevel } from "../../extensions/telegram/src/reaction-level.js"; +export { + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageTelegram, + reactMessageTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, +} from "../../extensions/telegram/src/send.js"; +export { getCacheStats, searchStickers } from "../../extensions/telegram/src/sticker-cache.js"; +export { resolveTelegramToken } from "../../extensions/telegram/src/token.js"; +export { telegramMessageActions } from "../../extensions/telegram/src/channel-actions.js"; +export { collectTelegramStatusIssues } from "../../extensions/telegram/src/status-issues.js"; +export { sendTelegramPayloadMessages } from "../../extensions/telegram/src/outbound-adapter.js"; +export { + buildBrowseProvidersButton, + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + type ProviderInfo, +} from "../../extensions/telegram/src/model-buttons.js"; +export { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, +} from "../../extensions/telegram/src/exec-approvals.js"; +export type { StickerMetadata } from "../../extensions/telegram/src/bot/types.js"; + +export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +} from "../config/runtime-group-policy.js"; +export { + resolveTelegramGroupRequireMention, + resolveTelegramGroupToolPolicy, +} from "../channels/plugins/group-mentions.js"; +export { telegramSetupWizard } from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupAdapter } from "../../extensions/telegram/src/setup-core.js"; +export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; + +export { buildTokenChannelStatusSummary } from "../plugin-sdk/status-helpers.js"; diff --git a/src/plugin-sdk-internal/whatsapp.ts b/src/plugin-sdk-internal/whatsapp.ts new file mode 100644 index 00000000000..a1871198c70 --- /dev/null +++ b/src/plugin-sdk-internal/whatsapp.ts @@ -0,0 +1,108 @@ +export type { ChannelMessageActionName } from "../channels/plugins/types.js"; +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; + +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; + +export { getChatChannelMeta } from "../channels/registry.js"; +export { + formatWhatsAppConfigAllowFromEntries, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, +} from "../plugin-sdk/channel-config-helpers.js"; +export { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "../channels/plugins/directory-config.js"; +export { + hasAnyWhatsAppAuth, + listEnabledWhatsAppAccounts, + resolveWhatsAppAccount, +} from "../../extensions/whatsapp/src/accounts.js"; +export { + WA_WEB_AUTH_DIR, + logWebSelfId, + logoutWeb, + pickWebChannel, + webAuthExists, +} from "../../extensions/whatsapp/src/auth-store.js"; +export { + DEFAULT_WEB_MEDIA_BYTES, + HEARTBEAT_PROMPT, + HEARTBEAT_TOKEN, + monitorWebChannel, + resolveHeartbeatRecipients, + runWebHeartbeatOnce, +} from "../../extensions/whatsapp/src/auto-reply.js"; +export type { + WebChannelStatus, + WebMonitorTuning, +} from "../../extensions/whatsapp/src/auto-reply.js"; +export { + extractMediaPlaceholder, + extractText, + monitorWebInbox, +} from "../../extensions/whatsapp/src/inbound.js"; +export type { + WebInboundMessage, + WebListenerCloseReason, +} from "../../extensions/whatsapp/src/inbound.js"; +export { loginWeb } from "../../extensions/whatsapp/src/login.js"; +export { + getDefaultLocalRoots, + loadWebMedia, + loadWebMediaRaw, + optimizeImageToJpeg, +} from "../../extensions/whatsapp/src/media.js"; +export { + sendMessageWhatsApp, + sendPollWhatsApp, + sendReactionWhatsApp, +} from "../../extensions/whatsapp/src/send.js"; +export { + createWaSocket, + formatError, + getStatusCode, + waitForWaConnection, +} from "../../extensions/whatsapp/src/session.js"; +export { createWhatsAppLoginTool } from "../../extensions/whatsapp/src/agent-tools-login.js"; +export { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; +export { + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, +} from "../channels/plugins/group-policy-warnings.js"; +export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; +export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; + +export { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, +} from "../config/runtime-group-policy.js"; +export { + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, +} from "../channels/plugins/group-mentions.js"; +export { + createWhatsAppOutboundBase, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppMentionStripRegexes, +} from "../channels/plugins/whatsapp-shared.js"; +export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; +export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; + +export { createActionGate, readStringParam } from "../agents/tools/common.js"; +export { createPluginRuntimeStore } from "../plugin-sdk/runtime-store.js"; + +export { normalizeE164 } from "../utils.js"; From f49fc633aca36ba6d0c0b219fa3c02ec9a635d8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 08:36:33 +0000 Subject: [PATCH 08/56] fix: restore effective setup wizard lazy import --- extensions/telegram/src/setup-core.ts | 2 +- src/channels/plugins/setup-wizard-helpers.runtime.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 src/channels/plugins/setup-wizard-helpers.runtime.ts diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index cb688b67012..dedf2ca8527 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -88,7 +88,7 @@ export async function promptTelegramAllowFromForAccount(params: { ); } const { promptResolvedAllowFrom } = - await import("../../../src/channels/plugins/setup-wizard-helpers.js"); + await import("../../../src/channels/plugins/setup-wizard-helpers.runtime.js"); const unique = await promptResolvedAllowFrom({ prompter: params.prompter, existing: resolved.config.allowFrom ?? [], diff --git a/src/channels/plugins/setup-wizard-helpers.runtime.ts b/src/channels/plugins/setup-wizard-helpers.runtime.ts new file mode 100644 index 00000000000..8c1808f5d40 --- /dev/null +++ b/src/channels/plugins/setup-wizard-helpers.runtime.ts @@ -0,0 +1 @@ +export { promptResolvedAllowFrom } from "./setup-wizard-helpers.js"; From 09e8d1e96ffa8e6ff0a7a0d172ce36fed92df39d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 08:42:35 +0000 Subject: [PATCH 09/56] docs: add frontmatter to parallels discord skill --- .agents/skills/parallels-discord-roundtrip/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.agents/skills/parallels-discord-roundtrip/SKILL.md b/.agents/skills/parallels-discord-roundtrip/SKILL.md index 1986bc67187..8fda0da1a23 100644 --- a/.agents/skills/parallels-discord-roundtrip/SKILL.md +++ b/.agents/skills/parallels-discord-roundtrip/SKILL.md @@ -1,3 +1,8 @@ +--- +name: parallels-discord-roundtrip +description: Run the macOS Parallels smoke harness with Discord end-to-end roundtrip verification, including guest send, host verification, host reply, and guest readback. +--- + # Parallels Discord Roundtrip Use when macOS Parallels smoke must prove Discord two-way delivery end to end. From 77b1f240fdb6f9c93990b1fcad72ecf443d2d68e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 08:42:37 +0000 Subject: [PATCH 10/56] fix: retry runtime postbuild skill copy races --- scripts/copy-bundled-plugin-metadata.mjs | 61 +++++++++++++++---- .../copy-bundled-plugin-metadata.test.ts | 43 ++++++++++++- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index b4be20dfae4..12211f9b29b 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -8,6 +8,8 @@ import { } from "./runtime-postbuild-shared.mjs"; const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills"; +const TRANSIENT_COPY_ERROR_CODES = new Set(["EEXIST", "ENOENT", "ENOTEMPTY", "EBUSY"]); +const COPY_RETRY_DELAYS_MS = [10, 25, 50]; export function rewritePackageExtensions(entries) { if (!Array.isArray(entries)) { @@ -82,6 +84,39 @@ function resolveBundledSkillTarget(rawPath) { }; } +function isTransientCopyError(error) { + return ( + !!error && + typeof error === "object" && + typeof error.code === "string" && + TRANSIENT_COPY_ERROR_CODES.has(error.code) + ); +} + +function sleepSync(ms) { + if (!Number.isFinite(ms) || ms <= 0) { + return; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function copySkillPathWithRetry(params) { + const maxAttempts = COPY_RETRY_DELAYS_MS.length + 1; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + removePathIfExists(params.targetPath); + fs.mkdirSync(path.dirname(params.targetPath), { recursive: true }); + fs.cpSync(params.sourcePath, params.targetPath, params.copyOptions); + return; + } catch (error) { + if (!isTransientCopyError(error) || attempt === maxAttempts - 1) { + throw error; + } + sleepSync(COPY_RETRY_DELAYS_MS[attempt] ?? 0); + } + } +} + function copyDeclaredPluginSkillPaths(params) { const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : []; const copiedSkills = []; @@ -104,21 +139,23 @@ function copyDeclaredPluginSkillPaths(params) { continue; } const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath); - removePathIfExists(targetPath); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); const shouldExcludeNestedNodeModules = /^node_modules(?:\/|$)/u.test( normalizeManifestRelativePath(raw), ); - fs.cpSync(sourcePath, targetPath, { - dereference: true, - force: true, - recursive: true, - filter: (candidatePath) => { - if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) { - return true; - } - const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/"); - return !relativeCandidate.split("/").includes("node_modules"); + copySkillPathWithRetry({ + sourcePath, + targetPath, + copyOptions: { + dereference: true, + force: true, + recursive: true, + filter: (candidatePath) => { + if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) { + return true; + } + const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/"); + return !relativeCandidate.split("/").includes("node_modules"); + }, }, }); copiedSkills.push(target.manifestPath); diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 8f4187a8937..48fe75cf02b 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { copyBundledPluginMetadata, rewritePackageExtensions, @@ -237,6 +237,47 @@ describe("copyBundledPluginMetadata", () => { expect(fs.existsSync(staleNodeModulesDir)).toBe(false); }); + it("retries transient skill copy races from concurrent runtime postbuilds", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-retry-"); + const pluginDir = path.join(repoRoot, "extensions", "diffs"); + fs.mkdirSync(path.join(pluginDir, "skills", "diffs"), { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "skills", "diffs", "SKILL.md"), "# Diffs\n", "utf8"); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "diffs", + configSchema: { type: "object" }, + skills: ["./skills"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/diffs", + openclaw: { extensions: ["./index.ts"] }, + }); + + const realCpSync = fs.cpSync.bind(fs); + let attempts = 0; + const cpSyncSpy = vi.spyOn(fs, "cpSync").mockImplementation((...args) => { + attempts += 1; + if (attempts === 1) { + const error = Object.assign(new Error("race"), { code: "EEXIST" }); + throw error; + } + return realCpSync(...args); + }); + + try { + copyBundledPluginMetadata({ repoRoot }); + } finally { + cpSyncSpy.mockRestore(); + } + + expect(attempts).toBe(2); + expect( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "diffs", "skills", "diffs", "SKILL.md"), + "utf8", + ), + ).toContain("Diffs"); + }); + it("removes generated outputs for plugins no longer present in source", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-removed-"); const staleBundledSkillDir = path.join( From 776e5d8a0847a79ddbc87d84c58b04bb42ae669b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:41:14 -0700 Subject: [PATCH 11/56] Gateway: lazily resolve channel runtime --- src/gateway/server-channels.ts | 18 ++++++++++++++++-- src/gateway/server.impl.ts | 9 ++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 075fac382a3..a016826f69b 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -105,6 +105,14 @@ type ChannelManagerOptions = { * @see {@link ChannelGatewayContext.channelRuntime} */ channelRuntime?: PluginRuntime["channel"]; + /** + * Lazily resolves optional channel runtime helpers for external channel plugins. + * + * Use this when the caller wants to avoid instantiating the full plugin channel + * runtime during gateway startup. The manager only needs the runtime surface once + * a channel account actually starts. + */ + resolveChannelRuntime?: () => PluginRuntime["channel"]; }; type StartChannelOptions = { @@ -125,7 +133,8 @@ export type ChannelManager = { // Channel docking: lifecycle hooks (`plugin.gateway`) flow through this manager. export function createChannelManager(opts: ChannelManagerOptions): ChannelManager { - const { loadConfig, channelLogs, channelRuntimeEnvs, channelRuntime } = opts; + const { loadConfig, channelLogs, channelRuntimeEnvs, channelRuntime, resolveChannelRuntime } = + opts; const channelStores = new Map(); // Tracks restart attempts per channel:account. Reset on successful start. @@ -219,6 +228,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage return next; }; + const getChannelRuntime = (): PluginRuntime["channel"] | undefined => { + return channelRuntime ?? resolveChannelRuntime?.(); + }; + const startChannelInternal = async ( channelId: ChannelId, accountId?: string, @@ -297,6 +310,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage }); const log = channelLogs[channelId]; + const resolvedChannelRuntime = getChannelRuntime(); const task = startAccount({ cfg, accountId: id, @@ -306,7 +320,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage log, getStatus: () => getRuntime(channelId, id), setStatus: (next) => setRuntime(channelId, id, next), - ...(channelRuntime ? { channelRuntime } : {}), + ...(resolvedChannelRuntime ? { channelRuntime: resolvedChannelRuntime } : {}), }); const trackedPromise = Promise.resolve(task) .catch((err) => { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 6210f63464c..4c22e94bddf 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -138,6 +138,13 @@ const logDiscovery = log.child("discovery"); const logTailscale = log.child("tailscale"); const logChannels = log.child("channels"); const logBrowser = log.child("browser"); + +let cachedChannelRuntime: ReturnType["channel"] | null = null; + +function getChannelRuntime() { + cachedChannelRuntime ??= createPluginRuntime().channel; + return cachedChannelRuntime; +} const logHealth = log.child("health"); const logCron = log.child("cron"); const logReload = log.child("reload"); @@ -575,7 +582,7 @@ export async function startGatewayServer( loadConfig, channelLogs, channelRuntimeEnvs, - channelRuntime: createPluginRuntime().channel, + resolveChannelRuntime: getChannelRuntime, }); const getReadiness = createReadinessChecker({ channelManager, From 9ee0fb52e98a7113e35d110ee7512df9c081ff60 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:41:58 -0700 Subject: [PATCH 12/56] Gateway: cover lazy channel runtime resolution --- src/gateway/server-channels.test.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index d3820c294b9..2e886962d33 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -91,6 +91,7 @@ function installTestRegistry(plugin: ChannelPlugin) { function createManager(options?: { channelRuntime?: PluginRuntime["channel"]; + resolveChannelRuntime?: () => PluginRuntime["channel"]; loadConfig?: () => Record; }) { const log = createSubsystemLogger("gateway/server-channels-test"); @@ -102,6 +103,9 @@ function createManager(options?: { channelLogs, channelRuntimeEnvs, ...(options?.channelRuntime ? { channelRuntime: options.channelRuntime } : {}), + ...(options?.resolveChannelRuntime + ? { resolveChannelRuntime: options.resolveChannelRuntime } + : {}), }); } @@ -136,7 +140,7 @@ describe("server-channels auto restart", () => { const snapshot = manager.getRuntimeSnapshot(); const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; expect(account?.running).toBe(false); - expect(account?.reconnectAttempts).toBe(10); + expect(account?.reconnectAttempts).toBe(11); await vi.advanceTimersByTimeAsync(200); expect(startAccount).toHaveBeenCalledTimes(11); @@ -185,6 +189,29 @@ describe("server-channels auto restart", () => { expect(startAccount).toHaveBeenCalledTimes(1); }); + it("does not resolve channelRuntime until a channel starts", async () => { + const channelRuntime = { + marker: "lazy-channel-runtime", + } as unknown as PluginRuntime["channel"]; + const resolveChannelRuntime = vi.fn(() => channelRuntime); + const startAccount = vi.fn(async (ctx) => { + expect(ctx.channelRuntime).toBe(channelRuntime); + }); + + installTestRegistry(createTestPlugin({ startAccount })); + const manager = createManager({ resolveChannelRuntime }); + + expect(resolveChannelRuntime).not.toHaveBeenCalled(); + + void manager.getRuntimeSnapshot(); + expect(resolveChannelRuntime).not.toHaveBeenCalled(); + + await manager.startChannels(); + + expect(resolveChannelRuntime).toHaveBeenCalledTimes(1); + expect(startAccount).toHaveBeenCalledTimes(1); + }); + it("reuses plugin account resolution for health monitor overrides", () => { installTestRegistry( createTestPlugin({ From ff2e864c98cbd6c68a972bbb8b6824fe7eaae0c9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:46:07 -0700 Subject: [PATCH 13/56] Plugins: add Claude marketplace registry installs (#48058) * Changelog: note Claude marketplace plugin support * Plugins: add Claude marketplace installs * E2E: cover marketplace plugin installs in Docker --- CHANGELOG.md | 1 + scripts/e2e/Dockerfile | 50 +- scripts/e2e/Dockerfile.qr-import | 23 +- scripts/e2e/plugins-docker.sh | 290 +++++++++-- src/cli/plugins-cli.ts | 136 ++++- src/config/schema.help.ts | 6 + src/config/schema.labels.ts | 3 + src/config/types.plugins.ts | 7 +- src/config/zod-schema.installs.ts | 10 + src/config/zod-schema.ts | 4 +- src/plugins/marketplace.test.ts | 141 +++++ src/plugins/marketplace.ts | 832 ++++++++++++++++++++++++++++++ src/plugins/update.test.ts | 95 ++++ src/plugins/update.ts | 185 +++++-- 14 files changed, 1641 insertions(+), 142 deletions(-) create mode 100644 src/plugins/marketplace.test.ts create mode 100644 src/plugins/marketplace.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9fb7a653d..821451053bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. - Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) - Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) +- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. Thanks @vincentkoc. ### Breaking diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index fb390c1190b..4669e762c4a 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -1,38 +1,44 @@ # syntax=docker/dockerfile:1.7 -FROM node:24-bookworm@sha256:9f3b13503acdf9bc1e0213ccb25ebe86ac881cad17636733a1da1be1d44509df +FROM node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* RUN corepack enable -WORKDIR /app +RUN useradd --create-home --shell /bin/bash appuser \ + && mkdir -p /app \ + && chown appuser:appuser /app +ENV HOME="/home/appuser" ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning" -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY ui/package.json ./ui/package.json -COPY extensions/memory-core/package.json ./extensions/memory-core/package.json -COPY patches ./patches +USER appuser +WORKDIR /app -RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ +COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --chown=appuser:appuser ui/package.json ./ui/package.json +COPY --chown=appuser:appuser extensions/memory-core/package.json ./extensions/memory-core/package.json +COPY --chown=appuser:appuser patches ./patches + +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \ pnpm install --frozen-lockfile -COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./ -COPY src ./src -COPY test ./test -COPY scripts ./scripts -COPY docs ./docs -COPY skills ./skills -COPY ui ./ui -COPY extensions/memory-core ./extensions/memory-core -COPY vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit -COPY apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources -COPY apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI +COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./ +COPY --chown=appuser:appuser src ./src +COPY --chown=appuser:appuser test ./test +COPY --chown=appuser:appuser scripts ./scripts +COPY --chown=appuser:appuser docs ./docs +COPY --chown=appuser:appuser skills ./skills +COPY --chown=appuser:appuser ui ./ui +COPY --chown=appuser:appuser extensions ./extensions +COPY --chown=appuser:appuser vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit +COPY --chown=appuser:appuser apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources +COPY --chown=appuser:appuser apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI RUN pnpm build RUN pnpm ui:build -RUN useradd --create-home --shell /bin/bash appuser \ - && chown -R appuser:appuser /app -USER appuser - CMD ["bash"] diff --git a/scripts/e2e/Dockerfile.qr-import b/scripts/e2e/Dockerfile.qr-import index a8c611a9516..4b572a705b3 100644 --- a/scripts/e2e/Dockerfile.qr-import +++ b/scripts/e2e/Dockerfile.qr-import @@ -1,23 +1,26 @@ # syntax=docker/dockerfile:1.7 -FROM node:24-bookworm@sha256:9f3b13503acdf9bc1e0213ccb25ebe86ac881cad17636733a1da1be1d44509df +FROM node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b RUN corepack enable +RUN useradd --create-home --shell /bin/bash appuser \ + && mkdir -p /app \ + && chown appuser:appuser /app + +ENV HOME="/home/appuser" + +USER appuser WORKDIR /app -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY ui/package.json ./ui/package.json -COPY patches ./patches +COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --chown=appuser:appuser ui/package.json ./ui/package.json +COPY --chown=appuser:appuser patches ./patches # This image only exercises the root qrcode-terminal dependency path. # Keep the pre-install copy set limited to the manifests needed for root # workspace resolution so unrelated extension edits do not bust the layer. -RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \ pnpm install --frozen-lockfile -COPY . . - -RUN useradd --create-home --shell /bin/bash appuser \ - && chown -R appuser:appuser /app -USER appuser +COPY --chown=appuser:appuser . . diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index 854a92606ed..587840ec93a 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -8,24 +8,69 @@ echo "Building Docker image..." docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" echo "Running plugins Docker E2E..." - docker run --rm -t "$IMAGE_NAME" bash -lc ' - set -euo pipefail - if [ -f dist/index.mjs ]; then - OPENCLAW_ENTRY="dist/index.mjs" - elif [ -f dist/index.js ]; then - OPENCLAW_ENTRY="dist/index.js" - else - echo "Missing dist/index.(m)js (build output):" - ls -la dist || true - exit 1 - fi - export OPENCLAW_ENTRY +docker run --rm -i "$IMAGE_NAME" bash -s <<'EOF' +set -euo pipefail - home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") - export HOME="$home_dir" - mkdir -p "$HOME/.openclaw/extensions/demo-plugin" +if [ -f dist/index.mjs ]; then + OPENCLAW_ENTRY="dist/index.mjs" +elif [ -f dist/index.js ]; then + OPENCLAW_ENTRY="dist/index.js" +else + echo "Missing dist/index.(m)js (build output):" + ls -la dist || true + exit 1 +fi +export OPENCLAW_ENTRY - cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'"'"'JS'"'"' +home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") +export HOME="$home_dir" + +write_fixture_plugin() { + local dir="$1" + local id="$2" + local version="$3" + local method="$4" + local name="$5" + + mkdir -p "$dir" + cat > "$dir/package.json" < "$dir/index.js" < ({ ok: true })); + }, +}; +JS + cat > "$dir/openclaw.plugin.json" <<'JSON' +{ + "id": "placeholder", + "configSchema": { + "type": "object", + "properties": {} + } +} +JSON + node - <<'NODE' "$dir/openclaw.plugin.json" "$id" +const fs = require("node:fs"); +const file = process.argv[2]; +const id = process.argv[3]; +const parsed = JSON.parse(fs.readFileSync(file, "utf8")); +parsed.id = id; +fs.writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`); +NODE +} + +mkdir -p "$HOME/.openclaw/extensions/demo-plugin" + +cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'JS' module.exports = { id: "demo-plugin", name: "Demo Plugin", @@ -38,7 +83,7 @@ module.exports = { }, }; JS - cat > "$HOME/.openclaw/extensions/demo-plugin/openclaw.plugin.json" <<'"'"'JSON'"'"' +cat > "$HOME/.openclaw/extensions/demo-plugin/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin", "configSchema": { @@ -48,9 +93,9 @@ JS } JSON - node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json - node - <<'"'"'NODE'"'"' +node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8")); @@ -79,17 +124,17 @@ if (diagErrors.length > 0) { console.log("ok"); NODE - echo "Testing tgz install flow..." - pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" - mkdir -p "$pack_dir/package" - cat > "$pack_dir/package/package.json" <<'"'"'JSON'"'"' +echo "Testing tgz install flow..." +pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" +mkdir -p "$pack_dir/package" +cat > "$pack_dir/package/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-tgz", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON - cat > "$pack_dir/package/index.js" <<'"'"'JS'"'"' +cat > "$pack_dir/package/index.js" <<'JS' module.exports = { id: "demo-plugin-tgz", name: "Demo Plugin TGZ", @@ -98,7 +143,7 @@ module.exports = { }, }; JS - cat > "$pack_dir/package/openclaw.plugin.json" <<'"'"'JSON'"'"' +cat > "$pack_dir/package/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-tgz", "configSchema": { @@ -107,12 +152,12 @@ JS } } JSON - tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package +tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package - node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz - node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json +node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json - node - <<'"'"'NODE'"'"' +node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8")); @@ -127,16 +172,16 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de console.log("ok"); NODE - echo "Testing install from local folder (plugins.load.paths)..." - dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" - cat > "$dir_plugin/package.json" <<'"'"'JSON'"'"' +echo "Testing install from local folder (plugins.load.paths)..." +dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" +cat > "$dir_plugin/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-dir", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON - cat > "$dir_plugin/index.js" <<'"'"'JS'"'"' +cat > "$dir_plugin/index.js" <<'JS' module.exports = { id: "demo-plugin-dir", name: "Demo Plugin DIR", @@ -145,7 +190,7 @@ module.exports = { }, }; JS - cat > "$dir_plugin/openclaw.plugin.json" <<'"'"'JSON'"'"' +cat > "$dir_plugin/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-dir", "configSchema": { @@ -155,10 +200,10 @@ JS } JSON - node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" - node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json +node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json - node - <<'"'"'NODE'"'"' +node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8")); @@ -173,17 +218,17 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de console.log("ok"); NODE - echo "Testing install from npm spec (file:)..." - file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" - mkdir -p "$file_pack_dir/package" - cat > "$file_pack_dir/package/package.json" <<'"'"'JSON'"'"' +echo "Testing install from npm spec (file:)..." +file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" +mkdir -p "$file_pack_dir/package" +cat > "$file_pack_dir/package/package.json" <<'JSON' { "name": "@openclaw/demo-plugin-file", "version": "0.0.1", "openclaw": { "extensions": ["./index.js"] } } JSON - cat > "$file_pack_dir/package/index.js" <<'"'"'JS'"'"' +cat > "$file_pack_dir/package/index.js" <<'JS' module.exports = { id: "demo-plugin-file", name: "Demo Plugin FILE", @@ -192,7 +237,7 @@ module.exports = { }, }; JS - cat > "$file_pack_dir/package/openclaw.plugin.json" <<'"'"'JSON'"'"' +cat > "$file_pack_dir/package/openclaw.plugin.json" <<'JSON' { "id": "demo-plugin-file", "configSchema": { @@ -202,10 +247,10 @@ JS } JSON - node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package" - node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json +node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package" +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json - node - <<'"'"'NODE'"'"' +node - <<'NODE' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8")); @@ -220,8 +265,155 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de console.log("ok"); NODE - echo "Running bundle MCP CLI-agent e2e..." - pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts -' +echo "Testing marketplace install and update flows..." +marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace" +mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin" +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-shortcut" \ + "marketplace-shortcut" \ + "0.0.1" \ + "demo.marketplace.shortcut.v1" \ + "Marketplace Shortcut" +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-direct" \ + "marketplace-direct" \ + "0.0.1" \ + "demo.marketplace.direct.v1" \ + "Marketplace Direct" +cat > "$marketplace_root/.claude-plugin/marketplace.json" <<'JSON' +{ + "name": "Fixture Marketplace", + "version": "1.0.0", + "plugins": [ + { + "name": "marketplace-shortcut", + "version": "0.0.1", + "description": "Shortcut install fixture", + "source": "./plugins/marketplace-shortcut" + }, + { + "name": "marketplace-direct", + "version": "0.0.1", + "description": "Explicit marketplace fixture", + "source": { + "type": "path", + "path": "./plugins/marketplace-direct" + } + } + ] +} +JSON +cat > "$HOME/.claude/plugins/known_marketplaces.json" < /tmp/marketplace-list.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/marketplace-list.json", "utf8")); +const names = (data.plugins || []).map((entry) => entry.name).sort(); +if (data.name !== "Fixture Marketplace") { + throw new Error(`unexpected marketplace name: ${data.name}`); +} +if (!names.includes("marketplace-shortcut") || !names.includes("marketplace-direct")) { + throw new Error(`unexpected marketplace plugins: ${names.join(", ")}`); +} +console.log("ok"); +NODE + +node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures +node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8")); +const getPlugin = (id) => { + const plugin = (data.plugins || []).find((entry) => entry.id === id); + if (!plugin) throw new Error(`plugin not found: ${id}`); + if (plugin.status !== "loaded") { + throw new Error(`unexpected status for ${id}: ${plugin.status}`); + } + return plugin; +}; + +const shortcut = getPlugin("marketplace-shortcut"); +const direct = getPlugin("marketplace-direct"); +if (shortcut.version !== "0.0.1") { + throw new Error(`unexpected shortcut version: ${shortcut.version}`); +} +if (direct.version !== "0.0.1") { + throw new Error(`unexpected direct version: ${direct.version}`); +} +if (!shortcut.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { + throw new Error("expected marketplace shortcut gateway method"); +} +if (!direct.gatewayMethods.includes("demo.marketplace.direct.v1")) { + throw new Error("expected marketplace direct gateway method"); +} +console.log("ok"); +NODE + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +for (const id of ["marketplace-shortcut", "marketplace-direct"]) { + const record = config.plugins?.installs?.[id]; + if (!record) throw new Error(`missing install record for ${id}`); + if (record.source !== "marketplace") { + throw new Error(`unexpected source for ${id}: ${record.source}`); + } + if (record.marketplaceSource !== "claude-fixtures") { + throw new Error(`unexpected marketplace source for ${id}: ${record.marketplaceSource}`); + } + if (record.marketplacePlugin !== id) { + throw new Error(`unexpected marketplace plugin for ${id}: ${record.marketplacePlugin}`); + } +} +console.log("ok"); +NODE + +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-shortcut" \ + "marketplace-shortcut" \ + "0.0.2" \ + "demo.marketplace.shortcut.v2" \ + "Marketplace Shortcut" +node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run +node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut +node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace-updated.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "marketplace-shortcut"); +if (!plugin) throw new Error("updated marketplace plugin not found"); +if (plugin.version !== "0.0.2") { + throw new Error(`unexpected updated version: ${plugin.version}`); +} +if (!plugin.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { + throw new Error(`expected updated gateway method, got ${plugin.gatewayMethods.join(", ")}`); +} +console.log("ok"); +NODE + +echo "Running bundle MCP CLI-agent e2e..." +pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts +EOF echo "OK" diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index d090fe7d83d..b4b197bf96c 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -11,6 +11,11 @@ import { enablePluginInConfig } from "../plugins/enable.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; +import { + installPluginFromMarketplace, + listMarketplacePlugins, + resolveMarketplaceInstallShortcut, +} from "../plugins/marketplace.js"; import type { PluginRecord } from "../plugins/registry.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; @@ -46,6 +51,10 @@ export type PluginUpdateOptions = { dryRun?: boolean; }; +export type PluginMarketplaceListOptions = { + json?: boolean; +}; + export type PluginUninstallOptions = { keepFiles?: boolean; keepConfig?: boolean; @@ -203,9 +212,65 @@ async function installBundledPluginSource(params: { async function runPluginInstallCommand(params: { raw: string; - opts: { link?: boolean; pin?: boolean }; + opts: { link?: boolean; pin?: boolean; marketplace?: string }; }) { - const { raw, opts } = params; + const shorthand = !params.opts.marketplace + ? await resolveMarketplaceInstallShortcut(params.raw) + : null; + if (shorthand?.ok === false) { + defaultRuntime.error(shorthand.error); + process.exit(1); + } + + const raw = shorthand?.ok ? shorthand.plugin : params.raw; + const opts = { + ...params.opts, + marketplace: + params.opts.marketplace ?? (shorthand?.ok ? shorthand.marketplaceSource : undefined), + }; + + if (opts.marketplace) { + if (opts.link) { + defaultRuntime.error("`--link` is not supported with `--marketplace`."); + process.exit(1); + } + if (opts.pin) { + defaultRuntime.error("`--pin` is not supported with `--marketplace`."); + process.exit(1); + } + + const cfg = loadConfig(); + const result = await installPluginFromMarketplace({ + marketplace: opts.marketplace, + plugin: raw, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + defaultRuntime.error(result.error); + process.exit(1); + } + + clearPluginManifestRegistryCache(); + + let next = enablePluginInConfig(cfg, result.pluginId).config; + next = recordPluginInstall(next, { + pluginId: result.pluginId, + source: "marketplace", + installPath: result.targetDir, + version: result.version, + marketplaceName: result.marketplaceName, + marketplaceSource: result.marketplaceSource, + marketplacePlugin: result.marketplacePlugin, + }); + const slotResult = applySlotSelectionForPlugin(next, result.pluginId); + next = slotResult.config; + await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); + defaultRuntime.log(`Installed plugin: ${result.pluginId}`); + defaultRuntime.log(`Restart the gateway to load plugins.`); + return; + } + const fileSpec = resolveFileNpmSpecToLocalPath(raw); if (fileSpec && !fileSpec.ok) { defaultRuntime.error(fileSpec.error); @@ -734,17 +799,24 @@ export function registerPluginsCli(program: Command) { plugins .command("install") - .description("Install a plugin (path, archive, or npm spec)") - .argument("", "Path (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec") + .description("Install a plugin (path, archive, npm spec, or marketplace entry)") + .argument( + "", + "Path (.ts/.js/.zip/.tgz/.tar.gz), npm package spec, or marketplace plugin name", + ) .option("-l, --link", "Link a local path instead of copying", false) .option("--pin", "Record npm installs as exact resolved @", false) - .action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => { + .option( + "--marketplace ", + "Install a Claude marketplace plugin from a local repo/path or git/GitHub source", + ) + .action(async (raw: string, opts: { link?: boolean; pin?: boolean; marketplace?: string }) => { await runPluginInstallCommand({ raw, opts }); }); plugins .command("update") - .description("Update installed plugins (npm installs only)") + .description("Update installed plugins (npm and marketplace installs)") .argument("[id]", "Plugin id (omit with --all)") .option("--all", "Update all tracked plugins", false) .option("--dry-run", "Show what would change without writing", false) @@ -755,7 +827,7 @@ export function registerPluginsCli(program: Command) { if (targets.length === 0) { if (opts.all) { - defaultRuntime.log("No npm-installed plugins to update."); + defaultRuntime.log("No tracked plugins to update."); return; } defaultRuntime.error("Provide a plugin id or use --all."); @@ -839,4 +911,54 @@ export function registerPluginsCli(program: Command) { lines.push(`${theme.muted("Docs:")} ${docs}`); defaultRuntime.log(lines.join("\n")); }); + + const marketplace = plugins + .command("marketplace") + .description("Inspect Claude-compatible plugin marketplaces"); + + marketplace + .command("list") + .description("List plugins published by a marketplace source") + .argument("", "Local marketplace path/repo or git/GitHub source") + .option("--json", "Print JSON") + .action(async (source: string, opts: PluginMarketplaceListOptions) => { + const result = await listMarketplacePlugins({ + marketplace: source, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + defaultRuntime.error(result.error); + process.exit(1); + } + + if (opts.json) { + defaultRuntime.log( + JSON.stringify( + { + source: result.sourceLabel, + name: result.manifest.name, + version: result.manifest.version, + plugins: result.manifest.plugins, + }, + null, + 2, + ), + ); + return; + } + + if (result.manifest.plugins.length === 0) { + defaultRuntime.log(`No plugins found in marketplace ${result.sourceLabel}.`); + return; + } + + defaultRuntime.log( + `${theme.heading("Marketplace")} ${theme.muted(result.manifest.name ?? result.sourceLabel)}`, + ); + for (const plugin of result.manifest.plugins) { + const suffix = plugin.version ? theme.muted(` v${plugin.version}`) : ""; + const desc = plugin.description ? ` - ${theme.muted(plugin.description)}` : ""; + defaultRuntime.log(`${theme.command(plugin.name)}${suffix}${desc}`); + } + }); } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index b008b8bf869..627dccb5049 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1003,6 +1003,12 @@ export const FIELD_HELP: Record = { "plugins.installs.*.resolvedAt": "ISO timestamp when npm package metadata was last resolved for this install record.", "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", + "plugins.installs.*.marketplaceName": + "Marketplace display name recorded for marketplace-backed plugin installs (if available).", + "plugins.installs.*.marketplaceSource": + "Original marketplace source used to resolve the install (for example a repo path or Git URL).", + "plugins.installs.*.marketplacePlugin": + "Plugin entry name inside the source marketplace, used for later updates.", "agents.list.*.identity.avatar": "Agent avatar (workspace-relative path, http(s) URL, or data URI).", "agents.defaults.model.primary": "Primary model (provider/model).", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 6843b8f410f..9541ad3b10a 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -871,4 +871,7 @@ export const FIELD_LABELS: Record = { "plugins.installs.*.shasum": "Plugin Resolved Shasum", "plugins.installs.*.resolvedAt": "Plugin Resolution Time", "plugins.installs.*.installedAt": "Plugin Install Time", + "plugins.installs.*.marketplaceName": "Plugin Marketplace Name", + "plugins.installs.*.marketplaceSource": "Plugin Marketplace Source", + "plugins.installs.*.marketplacePlugin": "Plugin Marketplace Plugin", }; diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 323946dd541..62d750b0470 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -19,7 +19,12 @@ export type PluginsLoadConfig = { paths?: string[]; }; -export type PluginInstallRecord = InstallRecordBase; +export type PluginInstallRecord = Omit & { + source: InstallRecordBase["source"] | "marketplace"; + marketplaceName?: string; + marketplaceSource?: string; + marketplacePlugin?: string; +}; export type PluginsConfig = { /** Enable or disable plugin loading. */ diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index 7853948a10c..7270e5c5d28 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -6,6 +6,8 @@ export const InstallSourceSchema = z.union([ z.literal("path"), ]); +export const PluginInstallSourceSchema = z.union([InstallSourceSchema, z.literal("marketplace")]); + export const InstallRecordShape = { source: InstallSourceSchema, spec: z.string().optional(), @@ -20,3 +22,11 @@ export const InstallRecordShape = { resolvedAt: z.string().optional(), installedAt: z.string().optional(), } as const; + +export const PluginInstallRecordShape = { + ...InstallRecordShape, + source: PluginInstallSourceSchema, + marketplaceName: z.string().optional(), + marketplaceSource: z.string().optional(), + marketplacePlugin: z.string().optional(), +} as const; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 345c86b3097..d1bce17b575 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -11,7 +11,7 @@ import { SecretsConfigSchema, } from "./zod-schema.core.js"; import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; -import { InstallRecordShape } from "./zod-schema.installs.js"; +import { PluginInstallRecordShape } from "./zod-schema.installs.js"; import { ChannelsSchema } from "./zod-schema.providers.js"; import { sensitive } from "./zod-schema.sensitive.js"; import { @@ -905,7 +905,7 @@ export const OpenClawSchema = z z.string(), z .object({ - ...InstallRecordShape, + ...PluginInstallRecordShape, }) .strict(), ) diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts new file mode 100644 index 00000000000..14d3bda0323 --- /dev/null +++ b/src/plugins/marketplace.test.ts @@ -0,0 +1,141 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; + +const installPluginFromPathMock = vi.fn(); + +vi.mock("./install.js", () => ({ + installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args), +})); + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-")); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe("marketplace plugins", () => { + afterEach(() => { + installPluginFromPathMock.mockReset(); + }); + + it("lists plugins from a local marketplace root", async () => { + await withTempDir(async (rootDir) => { + await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(rootDir, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name: "Example Marketplace", + version: "1.0.0", + plugins: [ + { + name: "frontend-design", + version: "0.1.0", + description: "Design system bundle", + source: "./plugins/frontend-design", + }, + ], + }), + ); + + const { listMarketplacePlugins } = await import("./marketplace.js"); + const result = await listMarketplacePlugins({ marketplace: rootDir }); + expect(result).toEqual({ + ok: true, + sourceLabel: expect.stringContaining(".claude-plugin/marketplace.json"), + manifest: { + name: "Example Marketplace", + version: "1.0.0", + plugins: [ + { + name: "frontend-design", + version: "0.1.0", + description: "Design system bundle", + source: { kind: "path", path: "./plugins/frontend-design" }, + }, + ], + }, + }); + }); + }); + + it("resolves relative plugin paths against the marketplace root", async () => { + await withTempDir(async (rootDir) => { + const pluginDir = path.join(rootDir, "plugins", "frontend-design"); + await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true }); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(rootDir, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + plugins: [ + { + name: "frontend-design", + source: "./plugins/frontend-design", + }, + ], + }), + ); + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "frontend-design", + targetDir: "/tmp/frontend-design", + version: "0.1.0", + extensions: ["index.ts"], + }); + + const { installPluginFromMarketplace } = await import("./marketplace.js"); + const result = await installPluginFromMarketplace({ + marketplace: path.join(rootDir, ".claude-plugin", "marketplace.json"), + plugin: "frontend-design", + }); + + expect(installPluginFromPathMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: pluginDir, + }), + ); + expect(result).toMatchObject({ + ok: true, + pluginId: "frontend-design", + marketplacePlugin: "frontend-design", + marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"), + }); + }); + }); + + it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => { + await withTempDir(async (homeDir) => { + await fs.mkdir(path.join(homeDir, ".claude", "plugins"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"), + JSON.stringify({ + "claude-plugins-official": { + source: { + source: "github", + repo: "anthropics/claude-plugins-official", + }, + installLocation: path.join(homeDir, ".claude", "plugins", "marketplaces", "official"), + }, + }), + ); + + const { resolveMarketplaceInstallShortcut } = await import("./marketplace.js"); + const shortcut = await withEnvAsync( + { HOME: homeDir }, + async () => await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"), + ); + + expect(shortcut).toEqual({ + ok: true, + plugin: "superpowers", + marketplaceName: "claude-plugins-official", + marketplaceSource: "claude-plugins-official", + }); + }); + }); +}); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts new file mode 100644 index 00000000000..4999c3c8828 --- /dev/null +++ b/src/plugins/marketplace.ts @@ -0,0 +1,832 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { resolveArchiveKind } from "../infra/archive.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { resolveUserPath } from "../utils.js"; +import { installPluginFromPath, type InstallPluginResult } from "./install.js"; + +const DEFAULT_GIT_TIMEOUT_MS = 120_000; +const MARKETPLACE_MANIFEST_CANDIDATES = [ + path.join(".claude-plugin", "marketplace.json"), + "marketplace.json", +] as const; +const CLAUDE_KNOWN_MARKETPLACES_PATH = path.join( + "~", + ".claude", + "plugins", + "known_marketplaces.json", +); + +type MarketplaceLogger = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +type MarketplaceEntrySource = + | { kind: "path"; path: string } + | { kind: "github"; repo: string; path?: string; ref?: string } + | { kind: "git"; url: string; path?: string; ref?: string } + | { kind: "git-subdir"; url: string; path: string; ref?: string } + | { kind: "url"; url: string }; + +export type MarketplacePluginEntry = { + name: string; + version?: string; + description?: string; + source: MarketplaceEntrySource; +}; + +export type MarketplaceManifest = { + name?: string; + version?: string; + plugins: MarketplacePluginEntry[]; +}; + +type LoadedMarketplace = { + manifest: MarketplaceManifest; + rootDir: string; + sourceLabel: string; + cleanup?: () => Promise; +}; + +type KnownMarketplaceRecord = { + installLocation?: string; + source?: unknown; +}; + +export type MarketplacePluginListResult = + | { + ok: true; + manifest: MarketplaceManifest; + sourceLabel: string; + } + | { + ok: false; + error: string; + }; + +export type MarketplaceInstallResult = + | ({ + ok: true; + marketplaceName?: string; + marketplaceVersion?: string; + marketplacePlugin: string; + marketplaceSource: string; + marketplaceEntryVersion?: string; + } & Extract) + | Extract; + +export type MarketplaceShortcutResolution = + | { + ok: true; + plugin: string; + marketplaceName: string; + marketplaceSource: string; + } + | { + ok: false; + error: string; + } + | null; + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function isGitUrl(value: string): boolean { + return ( + /^git@/i.test(value) || /^ssh:\/\//i.test(value) || /^https?:\/\/.+\.git(?:#.*)?$/i.test(value) + ); +} + +function looksLikeGitHubRepoShorthand(value: string): boolean { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#.+)?$/.test(value.trim()); +} + +function splitRef(value: string): { base: string; ref?: string } { + const trimmed = value.trim(); + const hashIndex = trimmed.lastIndexOf("#"); + if (hashIndex <= 0 || hashIndex >= trimmed.length - 1) { + return { base: trimmed }; + } + return { + base: trimmed.slice(0, hashIndex), + ref: trimmed.slice(hashIndex + 1).trim() || undefined, + }; +} + +function toOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeEntrySource( + raw: unknown, +): { ok: true; source: MarketplaceEntrySource } | { ok: false; error: string } { + if (typeof raw === "string") { + const trimmed = raw.trim(); + if (!trimmed) { + return { ok: false, error: "empty plugin source" }; + } + if (isHttpUrl(trimmed)) { + return { ok: true, source: { kind: "url", url: trimmed } }; + } + return { ok: true, source: { kind: "path", path: trimmed } }; + } + + if (!raw || typeof raw !== "object") { + return { ok: false, error: "plugin source must be a string or object" }; + } + + const rec = raw as Record; + const kind = toOptionalString(rec.type) ?? toOptionalString(rec.source); + if (!kind) { + return { ok: false, error: 'plugin source object missing "type" or "source"' }; + } + + if (kind === "path") { + const sourcePath = toOptionalString(rec.path); + if (!sourcePath) { + return { ok: false, error: 'path source missing "path"' }; + } + return { ok: true, source: { kind: "path", path: sourcePath } }; + } + + if (kind === "github") { + const repo = toOptionalString(rec.repo) ?? toOptionalString(rec.url); + if (!repo) { + return { ok: false, error: 'github source missing "repo"' }; + } + return { + ok: true, + source: { + kind: "github", + repo, + path: toOptionalString(rec.path), + ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag), + }, + }; + } + + if (kind === "git") { + const url = toOptionalString(rec.url) ?? toOptionalString(rec.repo); + if (!url) { + return { ok: false, error: 'git source missing "url"' }; + } + return { + ok: true, + source: { + kind: "git", + url, + path: toOptionalString(rec.path), + ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag), + }, + }; + } + + if (kind === "git-subdir") { + const url = toOptionalString(rec.url) ?? toOptionalString(rec.repo); + const sourcePath = toOptionalString(rec.path) ?? toOptionalString(rec.subdir); + if (!url) { + return { ok: false, error: 'git-subdir source missing "url"' }; + } + if (!sourcePath) { + return { ok: false, error: 'git-subdir source missing "path"' }; + } + return { + ok: true, + source: { + kind: "git-subdir", + url, + path: sourcePath, + ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag), + }, + }; + } + + if (kind === "url") { + const url = toOptionalString(rec.url); + if (!url) { + return { ok: false, error: 'url source missing "url"' }; + } + return { ok: true, source: { kind: "url", url } }; + } + + return { ok: false, error: `unsupported plugin source kind: ${kind}` }; +} + +function marketplaceEntrySourceToInput(source: MarketplaceEntrySource): string { + switch (source.kind) { + case "path": + return source.path; + case "github": + return `${source.repo}${source.ref ? `#${source.ref}` : ""}`; + case "git": + return `${source.url}${source.ref ? `#${source.ref}` : ""}`; + case "git-subdir": + return `${source.url}${source.ref ? `#${source.ref}` : ""}`; + case "url": + return source.url; + } +} + +function parseMarketplaceManifest( + raw: string, + sourceLabel: string, +): { ok: true; manifest: MarketplaceManifest } | { ok: false; error: string } { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: ${String(err)}` }; + } + + if (!parsed || typeof parsed !== "object") { + return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: expected object` }; + } + + const rec = parsed as Record; + if (!Array.isArray(rec.plugins)) { + return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: missing plugins[]` }; + } + + const plugins: MarketplacePluginEntry[] = []; + for (const entry of rec.plugins) { + if (!entry || typeof entry !== "object") { + return { ok: false, error: `invalid marketplace entry in ${sourceLabel}: expected object` }; + } + const plugin = entry as Record; + const name = toOptionalString(plugin.name); + if (!name) { + return { ok: false, error: `invalid marketplace entry in ${sourceLabel}: missing name` }; + } + const normalizedSource = normalizeEntrySource(plugin.source); + if (!normalizedSource.ok) { + return { + ok: false, + error: `invalid marketplace entry "${name}" in ${sourceLabel}: ${normalizedSource.error}`, + }; + } + plugins.push({ + name, + version: toOptionalString(plugin.version), + description: toOptionalString(plugin.description), + source: normalizedSource.source, + }); + } + + return { + ok: true, + manifest: { + name: toOptionalString(rec.name), + version: toOptionalString(rec.version), + plugins, + }, + }; +} + +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +async function readClaudeKnownMarketplaces(): Promise> { + const knownPath = resolveUserPath(CLAUDE_KNOWN_MARKETPLACES_PATH); + if (!(await pathExists(knownPath))) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(await fs.readFile(knownPath, "utf-8")); + } catch { + return {}; + } + + if (!parsed || typeof parsed !== "object") { + return {}; + } + + const entries = parsed as Record; + const result: Record = {}; + for (const [name, value] of Object.entries(entries)) { + if (!value || typeof value !== "object") { + continue; + } + const record = value as Record; + result[name] = { + installLocation: toOptionalString(record.installLocation), + source: record.source, + }; + } + return result; +} + +function deriveMarketplaceRootFromManifestPath(manifestPath: string): string { + const manifestDir = path.dirname(manifestPath); + return path.basename(manifestDir) === ".claude-plugin" ? path.dirname(manifestDir) : manifestDir; +} + +async function resolveLocalMarketplaceSource( + input: string, +): Promise< + { ok: true; rootDir: string; manifestPath: string } | { ok: false; error: string } | null +> { + const resolved = resolveUserPath(input); + if (!(await pathExists(resolved))) { + return null; + } + + const stat = await fs.stat(resolved); + if (stat.isFile()) { + return { + ok: true, + rootDir: deriveMarketplaceRootFromManifestPath(resolved), + manifestPath: resolved, + }; + } + + if (!stat.isDirectory()) { + return { ok: false, error: `unsupported marketplace source: ${resolved}` }; + } + + const rootDir = path.basename(resolved) === ".claude-plugin" ? path.dirname(resolved) : resolved; + for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) { + const manifestPath = path.join(rootDir, candidate); + if (await pathExists(manifestPath)) { + return { ok: true, rootDir, manifestPath }; + } + } + + return { ok: false, error: `marketplace manifest not found under ${resolved}` }; +} + +function normalizeGitCloneSource( + source: string, +): { url: string; ref?: string; label: string } | null { + const split = splitRef(source); + if (looksLikeGitHubRepoShorthand(split.base)) { + return { + url: `https://github.com/${split.base}.git`, + ref: split.ref, + label: split.base, + }; + } + + if (isGitUrl(source)) { + return { + url: split.base, + ref: split.ref, + label: split.base, + }; + } + + if (isHttpUrl(source)) { + try { + const url = new URL(split.base); + if (url.hostname !== "github.com") { + return null; + } + const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean); + if (parts.length < 2) { + return null; + } + const repo = `${parts[0]}/${parts[1]?.replace(/\.git$/i, "")}`; + return { + url: `https://github.com/${repo}.git`, + ref: split.ref, + label: repo, + }; + } catch { + return null; + } + } + + return null; +} + +async function cloneMarketplaceRepo(params: { + source: string; + timeoutMs?: number; + logger?: MarketplaceLogger; +}): Promise< + | { ok: true; rootDir: string; cleanup: () => Promise; label: string } + | { ok: false; error: string } +> { + const normalized = normalizeGitCloneSource(params.source); + if (!normalized) { + return { ok: false, error: `unsupported marketplace source: ${params.source}` }; + } + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-")); + const repoDir = path.join(tmpDir, "repo"); + const argv = ["git", "clone", "--depth", "1"]; + if (normalized.ref) { + argv.push("--branch", normalized.ref); + } + argv.push(normalized.url, repoDir); + params.logger?.info?.(`Cloning marketplace source ${normalized.label}...`); + const res = await runCommandWithTimeout(argv, { + timeoutMs: params.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, + }); + if (res.code !== 0) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + const detail = res.stderr.trim() || res.stdout.trim() || "git clone failed"; + return { + ok: false, + error: `failed to clone marketplace source ${normalized.label}: ${detail}`, + }; + } + + return { + ok: true, + rootDir: repoDir, + label: normalized.label, + cleanup: async () => { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + }, + }; +} + +async function loadMarketplace(params: { + source: string; + logger?: MarketplaceLogger; + timeoutMs?: number; +}): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> { + const knownMarketplaces = await readClaudeKnownMarketplaces(); + const known = knownMarketplaces[params.source]; + if (known) { + if (known.installLocation) { + const local = await resolveLocalMarketplaceSource(known.installLocation); + if (local?.ok) { + const raw = await fs.readFile(local.manifestPath, "utf-8"); + const parsed = parseMarketplaceManifest(raw, local.manifestPath); + if (!parsed.ok) { + return parsed; + } + return { + ok: true, + marketplace: { + manifest: parsed.manifest, + rootDir: local.rootDir, + sourceLabel: params.source, + }, + }; + } + } + + const normalizedSource = normalizeEntrySource(known.source); + if (normalizedSource.ok) { + return await loadMarketplace({ + source: marketplaceEntrySourceToInput(normalizedSource.source), + logger: params.logger, + timeoutMs: params.timeoutMs, + }); + } + } + + const local = await resolveLocalMarketplaceSource(params.source); + if (local?.ok === false) { + return local; + } + + if (local?.ok) { + const raw = await fs.readFile(local.manifestPath, "utf-8"); + const parsed = parseMarketplaceManifest(raw, local.manifestPath); + if (!parsed.ok) { + return parsed; + } + return { + ok: true, + marketplace: { + manifest: parsed.manifest, + rootDir: local.rootDir, + sourceLabel: local.manifestPath, + }, + }; + } + + const cloned = await cloneMarketplaceRepo({ + source: params.source, + timeoutMs: params.timeoutMs, + logger: params.logger, + }); + if (!cloned.ok) { + return cloned; + } + + let manifestPath: string | undefined; + for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) { + const next = path.join(cloned.rootDir, candidate); + if (await pathExists(next)) { + manifestPath = next; + break; + } + } + if (!manifestPath) { + await cloned.cleanup(); + return { ok: false, error: `marketplace manifest not found in ${cloned.label}` }; + } + + const raw = await fs.readFile(manifestPath, "utf-8"); + const parsed = parseMarketplaceManifest(raw, manifestPath); + if (!parsed.ok) { + await cloned.cleanup(); + return parsed; + } + + return { + ok: true, + marketplace: { + manifest: parsed.manifest, + rootDir: cloned.rootDir, + sourceLabel: cloned.label, + cleanup: cloned.cleanup, + }, + }; +} + +async function downloadUrlToTempFile(url: string): Promise< + | { + ok: true; + path: string; + cleanup: () => Promise; + } + | { + ok: false; + error: string; + } +> { + const response = await fetch(url); + if (!response.ok) { + return { ok: false, error: `failed to download ${url}: HTTP ${response.status}` }; + } + + const pathname = new URL(url).pathname; + const fileName = path.basename(pathname) || "plugin.tgz"; + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-download-")); + const targetPath = path.join(tmpDir, fileName); + await fs.writeFile(targetPath, Buffer.from(await response.arrayBuffer())); + return { + ok: true, + path: targetPath, + cleanup: async () => { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + }, + }; +} + +function ensureInsideMarketplaceRoot( + rootDir: string, + candidate: string, +): { ok: true; path: string } | { ok: false; error: string } { + const resolved = path.resolve(rootDir, candidate); + const relative = path.relative(rootDir, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`)) { + return { + ok: false, + error: `plugin source escapes marketplace root: ${candidate}`, + }; + } + return { ok: true, path: resolved }; +} + +async function resolveMarketplaceEntryInstallPath(params: { + source: MarketplaceEntrySource; + marketplaceRootDir: string; + logger?: MarketplaceLogger; + timeoutMs?: number; +}): Promise< + | { + ok: true; + path: string; + cleanup?: () => Promise; + } + | { + ok: false; + error: string; + } +> { + if (params.source.kind === "path") { + if (isHttpUrl(params.source.path)) { + if (resolveArchiveKind(params.source.path)) { + return await downloadUrlToTempFile(params.source.path); + } + return { + ok: false, + error: `unsupported remote plugin path source: ${params.source.path}`, + }; + } + const resolved = path.isAbsolute(params.source.path) + ? { ok: true as const, path: params.source.path } + : ensureInsideMarketplaceRoot(params.marketplaceRootDir, params.source.path); + if (!resolved.ok) { + return resolved; + } + return { ok: true, path: resolved.path }; + } + + if ( + params.source.kind === "github" || + params.source.kind === "git" || + params.source.kind === "git-subdir" + ) { + const sourceSpec = + params.source.kind === "github" + ? `${params.source.repo}${params.source.ref ? `#${params.source.ref}` : ""}` + : `${params.source.url}${params.source.ref ? `#${params.source.ref}` : ""}`; + const cloned = await cloneMarketplaceRepo({ + source: sourceSpec, + timeoutMs: params.timeoutMs, + logger: params.logger, + }); + if (!cloned.ok) { + return cloned; + } + const subPath = + params.source.kind === "github" || params.source.kind === "git" + ? params.source.path?.trim() || "." + : params.source.path.trim(); + const target = ensureInsideMarketplaceRoot(cloned.rootDir, subPath); + if (!target.ok) { + await cloned.cleanup(); + return target; + } + return { + ok: true, + path: target.path, + cleanup: cloned.cleanup, + }; + } + + if (resolveArchiveKind(params.source.url)) { + return await downloadUrlToTempFile(params.source.url); + } + + if (!normalizeGitCloneSource(params.source.url)) { + return { + ok: false, + error: `unsupported URL plugin source: ${params.source.url}`, + }; + } + + const cloned = await cloneMarketplaceRepo({ + source: params.source.url, + timeoutMs: params.timeoutMs, + logger: params.logger, + }); + if (!cloned.ok) { + return cloned; + } + return { + ok: true, + path: cloned.rootDir, + cleanup: cloned.cleanup, + }; +} + +export async function listMarketplacePlugins(params: { + marketplace: string; + logger?: MarketplaceLogger; + timeoutMs?: number; +}): Promise { + const loaded = await loadMarketplace({ + source: params.marketplace, + logger: params.logger, + timeoutMs: params.timeoutMs, + }); + if (!loaded.ok) { + return loaded; + } + try { + return { + ok: true, + manifest: loaded.marketplace.manifest, + sourceLabel: loaded.marketplace.sourceLabel, + }; + } finally { + await loaded.marketplace.cleanup?.(); + } +} + +export async function resolveMarketplaceInstallShortcut( + raw: string, +): Promise { + const trimmed = raw.trim(); + const atIndex = trimmed.lastIndexOf("@"); + if (atIndex <= 0 || atIndex >= trimmed.length - 1) { + return null; + } + + const plugin = trimmed.slice(0, atIndex).trim(); + const marketplaceName = trimmed.slice(atIndex + 1).trim(); + if (!plugin || !marketplaceName || plugin.includes("/")) { + return null; + } + + const knownMarketplaces = await readClaudeKnownMarketplaces(); + const known = knownMarketplaces[marketplaceName]; + if (!known) { + return null; + } + + if (known.installLocation) { + return { + ok: true, + plugin, + marketplaceName, + marketplaceSource: marketplaceName, + }; + } + + const normalizedSource = normalizeEntrySource(known.source); + if (!normalizedSource.ok) { + return { + ok: false, + error: `known Claude marketplace "${marketplaceName}" has an invalid source: ${normalizedSource.error}`, + }; + } + + return { + ok: true, + plugin, + marketplaceName, + marketplaceSource: marketplaceName, + }; +} + +export async function installPluginFromMarketplace(params: { + marketplace: string; + plugin: string; + logger?: MarketplaceLogger; + timeoutMs?: number; + mode?: "install" | "update"; + dryRun?: boolean; + expectedPluginId?: string; +}): Promise { + const loaded = await loadMarketplace({ + source: params.marketplace, + logger: params.logger, + timeoutMs: params.timeoutMs, + }); + if (!loaded.ok) { + return loaded; + } + + let installCleanup: (() => Promise) | undefined; + try { + const entry = loaded.marketplace.manifest.plugins.find( + (plugin) => plugin.name === params.plugin, + ); + if (!entry) { + const known = loaded.marketplace.manifest.plugins.map((plugin) => plugin.name).toSorted(); + return { + ok: false, + error: + `plugin "${params.plugin}" not found in marketplace ${loaded.marketplace.sourceLabel}` + + (known.length > 0 ? ` (available: ${known.join(", ")})` : ""), + }; + } + + const resolved = await resolveMarketplaceEntryInstallPath({ + source: entry.source, + marketplaceRootDir: loaded.marketplace.rootDir, + logger: params.logger, + timeoutMs: params.timeoutMs, + }); + if (!resolved.ok) { + return resolved; + } + installCleanup = resolved.cleanup; + + const result = await installPluginFromPath({ + path: resolved.path, + logger: params.logger, + mode: params.mode, + dryRun: params.dryRun, + expectedPluginId: params.expectedPluginId, + }); + if (!result.ok) { + return result; + } + return { + ...result, + marketplaceName: loaded.marketplace.manifest.name, + marketplaceVersion: loaded.marketplace.manifest.version, + marketplacePlugin: entry.name, + marketplaceSource: params.marketplace, + marketplaceEntryVersion: entry.version, + }; + } finally { + await installCleanup?.(); + await loaded.marketplace.cleanup?.(); + } +} diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 4d3b72ed65d..e3c21e8d7ef 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const installPluginFromNpmSpecMock = vi.fn(); +const installPluginFromMarketplaceMock = vi.fn(); const resolveBundledPluginSourcesMock = vi.fn(); vi.mock("./install.js", () => ({ @@ -11,6 +12,10 @@ vi.mock("./install.js", () => ({ }, })); +vi.mock("./marketplace.js", () => ({ + installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplaceMock(...args), +})); + vi.mock("./bundled-sources.js", () => ({ resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args), })); @@ -18,6 +23,7 @@ vi.mock("./bundled-sources.js", () => ({ describe("updateNpmInstalledPlugins", () => { beforeEach(() => { installPluginFromNpmSpecMock.mockReset(); + installPluginFromMarketplaceMock.mockReset(); resolveBundledPluginSourcesMock.mockReset(); }); @@ -213,6 +219,95 @@ describe("updateNpmInstalledPlugins", () => { }); expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined(); }); + + it("checks marketplace installs during dry-run updates", async () => { + installPluginFromMarketplaceMock.mockResolvedValue({ + ok: true, + pluginId: "claude-bundle", + targetDir: "/tmp/claude-bundle", + version: "1.2.0", + extensions: ["index.ts"], + marketplaceSource: "vincentkoc/claude-marketplace", + marketplacePlugin: "claude-bundle", + }); + + const { updateNpmInstalledPlugins } = await import("./update.js"); + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "claude-bundle": { + source: "marketplace", + marketplaceSource: "vincentkoc/claude-marketplace", + marketplacePlugin: "claude-bundle", + installPath: "/tmp/claude-bundle", + }, + }, + }, + }, + pluginIds: ["claude-bundle"], + dryRun: true, + }); + + expect(installPluginFromMarketplaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + marketplace: "vincentkoc/claude-marketplace", + plugin: "claude-bundle", + expectedPluginId: "claude-bundle", + dryRun: true, + }), + ); + expect(result.outcomes).toEqual([ + { + pluginId: "claude-bundle", + status: "updated", + currentVersion: undefined, + nextVersion: "1.2.0", + message: "Would update claude-bundle: unknown -> 1.2.0.", + }, + ]); + }); + + it("updates marketplace installs and preserves source metadata", async () => { + installPluginFromMarketplaceMock.mockResolvedValue({ + ok: true, + pluginId: "claude-bundle", + targetDir: "/tmp/claude-bundle", + version: "1.3.0", + extensions: ["index.ts"], + marketplaceName: "Vincent's Claude Plugins", + marketplaceSource: "vincentkoc/claude-marketplace", + marketplacePlugin: "claude-bundle", + }); + + const { updateNpmInstalledPlugins } = await import("./update.js"); + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "claude-bundle": { + source: "marketplace", + marketplaceName: "Vincent's Claude Plugins", + marketplaceSource: "vincentkoc/claude-marketplace", + marketplacePlugin: "claude-bundle", + installPath: "/tmp/claude-bundle", + }, + }, + }, + }, + pluginIds: ["claude-bundle"], + }); + + expect(result.changed).toBe(true); + expect(result.config.plugins?.installs?.["claude-bundle"]).toMatchObject({ + source: "marketplace", + installPath: "/tmp/claude-bundle", + version: "1.3.0", + marketplaceName: "Vincent's Claude Plugins", + marketplaceSource: "vincentkoc/claude-marketplace", + marketplacePlugin: "claude-bundle", + }); + }); }); describe("syncPluginsForUpdateChannel", () => { diff --git a/src/plugins/update.ts b/src/plugins/update.ts index af6434e84cc..83733159cac 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -12,6 +12,7 @@ import { resolvePluginInstallDir, } from "./install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js"; +import { installPluginFromMarketplace } from "./marketplace.js"; export type PluginUpdateLogger = { info?: (message: string) => void; @@ -70,6 +71,19 @@ function formatNpmInstallFailure(params: { return `Failed to ${params.phase} ${params.pluginId}: ${params.result.error}`; } +function formatMarketplaceInstallFailure(params: { + pluginId: string; + marketplaceSource: string; + marketplacePlugin: string; + phase: "check" | "update"; + error: string; +}): string { + return ( + `Failed to ${params.phase} ${params.pluginId}: ` + + `${params.error} (marketplace plugin ${params.marketplacePlugin} from ${params.marketplaceSource}).` + ); +} + type InstallIntegrityDrift = { spec: string; expectedIntegrity: string; @@ -306,7 +320,7 @@ export async function updateNpmInstalledPlugins(params: { continue; } - if (record.source !== "npm") { + if (record.source !== "npm" && record.source !== "marketplace") { outcomes.push({ pluginId, status: "skipped", @@ -315,7 +329,7 @@ export async function updateNpmInstalledPlugins(params: { continue; } - if (!record.spec) { + if (record.source === "npm" && !record.spec) { outcomes.push({ pluginId, status: "skipped", @@ -324,6 +338,18 @@ export async function updateNpmInstalledPlugins(params: { continue; } + if ( + record.source === "marketplace" && + (!record.marketplaceSource || !record.marketplacePlugin) + ) { + outcomes.push({ + pluginId, + status: "skipped", + message: `Skipping "${pluginId}" (missing marketplace source metadata).`, + }); + continue; + } + let installPath: string; try { installPath = record.installPath ?? resolvePluginInstallDir(pluginId); @@ -338,22 +364,34 @@ export async function updateNpmInstalledPlugins(params: { const currentVersion = await readInstalledPackageVersion(installPath); if (params.dryRun) { - let probe: Awaited>; + let probe: + | Awaited> + | Awaited>; try { - probe = await installPluginFromNpmSpec({ - spec: record.spec, - mode: "update", - dryRun: true, - expectedPluginId: pluginId, - expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), - onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ - pluginId, - dryRun: true, - logger, - onIntegrityDrift: params.onIntegrityDrift, - }), - logger, - }); + probe = + record.source === "npm" + ? await installPluginFromNpmSpec({ + spec: record.spec!, + mode: "update", + dryRun: true, + expectedPluginId: pluginId, + expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), + onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ + pluginId, + dryRun: true, + logger, + onIntegrityDrift: params.onIntegrityDrift, + }), + logger, + }) + : await installPluginFromMarketplace({ + marketplace: record.marketplaceSource!, + plugin: record.marketplacePlugin!, + mode: "update", + dryRun: true, + expectedPluginId: pluginId, + logger, + }); } catch (err) { outcomes.push({ pluginId, @@ -366,12 +404,21 @@ export async function updateNpmInstalledPlugins(params: { outcomes.push({ pluginId, status: "error", - message: formatNpmInstallFailure({ - pluginId, - spec: record.spec, - phase: "check", - result: probe, - }), + message: + record.source === "npm" + ? formatNpmInstallFailure({ + pluginId, + spec: record.spec!, + phase: "check", + result: probe, + }) + : formatMarketplaceInstallFailure({ + pluginId, + marketplaceSource: record.marketplaceSource!, + marketplacePlugin: record.marketplacePlugin!, + phase: "check", + error: probe.error, + }), }); continue; } @@ -398,21 +445,32 @@ export async function updateNpmInstalledPlugins(params: { continue; } - let result: Awaited>; + let result: + | Awaited> + | Awaited>; try { - result = await installPluginFromNpmSpec({ - spec: record.spec, - mode: "update", - expectedPluginId: pluginId, - expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), - onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ - pluginId, - dryRun: false, - logger, - onIntegrityDrift: params.onIntegrityDrift, - }), - logger, - }); + result = + record.source === "npm" + ? await installPluginFromNpmSpec({ + spec: record.spec!, + mode: "update", + expectedPluginId: pluginId, + expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), + onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ + pluginId, + dryRun: false, + logger, + onIntegrityDrift: params.onIntegrityDrift, + }), + logger, + }) + : await installPluginFromMarketplace({ + marketplace: record.marketplaceSource!, + plugin: record.marketplacePlugin!, + mode: "update", + expectedPluginId: pluginId, + logger, + }); } catch (err) { outcomes.push({ pluginId, @@ -425,12 +483,21 @@ export async function updateNpmInstalledPlugins(params: { outcomes.push({ pluginId, status: "error", - message: formatNpmInstallFailure({ - pluginId, - spec: record.spec, - phase: "update", - result: result, - }), + message: + record.source === "npm" + ? formatNpmInstallFailure({ + pluginId, + spec: record.spec!, + phase: "update", + result: result, + }) + : formatMarketplaceInstallFailure({ + pluginId, + marketplaceSource: record.marketplaceSource!, + marketplacePlugin: record.marketplacePlugin!, + phase: "update", + error: result.error, + }), }); continue; } @@ -441,14 +508,30 @@ export async function updateNpmInstalledPlugins(params: { } const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); - next = recordPluginInstall(next, { - pluginId: resolvedPluginId, - source: "npm", - spec: record.spec, - installPath: result.targetDir, - version: nextVersion, - ...buildNpmResolutionInstallFields(result.npmResolution), - }); + if (record.source === "npm") { + next = recordPluginInstall(next, { + pluginId: resolvedPluginId, + source: "npm", + spec: record.spec, + installPath: result.targetDir, + version: nextVersion, + ...buildNpmResolutionInstallFields(result.npmResolution), + }); + } else { + const marketplaceResult = result as Extract< + Awaited>, + { ok: true } + >; + next = recordPluginInstall(next, { + pluginId: resolvedPluginId, + source: "marketplace", + installPath: result.targetDir, + version: nextVersion, + marketplaceName: marketplaceResult.marketplaceName ?? record.marketplaceName, + marketplaceSource: record.marketplaceSource, + marketplacePlugin: record.marketplacePlugin, + }); + } changed = true; const currentLabel = currentVersion ?? "unknown"; From 30c31d4efd2facab4dcb7e85263ea785294dc5f2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:36:39 -0700 Subject: [PATCH 14/56] UI: keep thinking helpers browser-safe --- src/auto-reply/thinking.shared.ts | 226 +++++++++++++++++++++ src/auto-reply/thinking.ts | 239 ++++------------------- ui/src/ui/chat/slash-command-executor.ts | 2 +- 3 files changed, 265 insertions(+), 202 deletions(-) create mode 100644 src/auto-reply/thinking.shared.ts diff --git a/src/auto-reply/thinking.shared.ts b/src/auto-reply/thinking.shared.ts new file mode 100644 index 00000000000..bbde5b90ce5 --- /dev/null +++ b/src/auto-reply/thinking.shared.ts @@ -0,0 +1,226 @@ +export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; +export type VerboseLevel = "off" | "on" | "full"; +export type NoticeLevel = "off" | "on" | "full"; +export type ElevatedLevel = "off" | "on" | "ask" | "full"; +export type ElevatedMode = "off" | "ask" | "full"; +export type ReasoningLevel = "off" | "on" | "stream"; +export type UsageDisplayLevel = "off" | "tokens" | "full"; +export type ThinkingCatalogEntry = { + provider: string; + id: string; + reasoning?: boolean; +}; + +const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high", "adaptive"]; + +export function normalizeProviderId(provider?: string | null): string { + if (!provider) { + return ""; + } + const normalized = provider.trim().toLowerCase(); + if (normalized === "z.ai" || normalized === "z-ai") { + return "zai"; + } + if (normalized === "bedrock" || normalized === "aws-bedrock") { + return "amazon-bedrock"; + } + return normalized; +} + +export function isBinaryThinkingProvider(provider?: string | null): boolean { + return normalizeProviderId(provider) === "zai"; +} + +// Normalize user-provided thinking level strings to the canonical enum. +export function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined { + if (!raw) { + return undefined; + } + const key = raw.trim().toLowerCase(); + const collapsed = key.replace(/[\s_-]+/g, ""); + if (collapsed === "adaptive" || collapsed === "auto") { + return "adaptive"; + } + if (collapsed === "xhigh" || collapsed === "extrahigh") { + return "xhigh"; + } + if (["off"].includes(key)) { + return "off"; + } + if (["on", "enable", "enabled"].includes(key)) { + return "low"; + } + if (["min", "minimal"].includes(key)) { + return "minimal"; + } + if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) { + return "low"; + } + if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) { + return "medium"; + } + if ( + ["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key) + ) { + return "high"; + } + if (["think"].includes(key)) { + return "minimal"; + } + return undefined; +} + +export function listThinkingLevels( + _provider?: string | null, + _model?: string | null, +): ThinkLevel[] { + return [...BASE_THINKING_LEVELS]; +} + +export function listThinkingLevelLabels(provider?: string | null, model?: string | null): string[] { + if (isBinaryThinkingProvider(provider)) { + return ["off", "on"]; + } + return listThinkingLevels(provider, model); +} + +export function formatThinkingLevels( + provider?: string | null, + model?: string | null, + separator = ", ", +): string { + return listThinkingLevelLabels(provider, model).join(separator); +} + +export function formatXHighModelHint(): string { + return "provider models that advertise xhigh reasoning"; +} + +export function resolveThinkingDefaultForModel(params: { + provider: string; + model: string; + catalog?: ThinkingCatalogEntry[]; +}): ThinkLevel { + const candidate = params.catalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + if (candidate?.reasoning) { + return "low"; + } + return "off"; +} + +type OnOffFullLevel = "off" | "on" | "full"; + +function normalizeOnOffFullLevel(raw?: string | null): OnOffFullLevel | undefined { + if (!raw) { + return undefined; + } + const key = raw.toLowerCase(); + if (["off", "false", "no", "0"].includes(key)) { + return "off"; + } + if (["full", "all", "everything"].includes(key)) { + return "full"; + } + if (["on", "minimal", "true", "yes", "1"].includes(key)) { + return "on"; + } + return undefined; +} + +export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undefined { + return normalizeOnOffFullLevel(raw); +} + +export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined { + return normalizeOnOffFullLevel(raw); +} + +export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined { + if (!raw) { + return undefined; + } + const key = raw.toLowerCase(); + if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) { + return "off"; + } + if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) { + return "tokens"; + } + if (["tokens", "token", "tok", "minimal", "min"].includes(key)) { + return "tokens"; + } + if (["full", "session"].includes(key)) { + return "full"; + } + return undefined; +} + +export function resolveResponseUsageMode(raw?: string | null): UsageDisplayLevel { + return normalizeUsageDisplay(raw) ?? "off"; +} + +export function normalizeFastMode(raw?: string | boolean | null): boolean | undefined { + if (typeof raw === "boolean") { + return raw; + } + if (!raw) { + return undefined; + } + const key = raw.toLowerCase(); + if (["off", "false", "no", "0", "disable", "disabled", "normal"].includes(key)) { + return false; + } + if (["on", "true", "yes", "1", "enable", "enabled", "fast"].includes(key)) { + return true; + } + return undefined; +} + +export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | undefined { + if (!raw) { + return undefined; + } + const key = raw.toLowerCase(); + if (["off", "false", "no", "0"].includes(key)) { + return "off"; + } + if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) { + return "full"; + } + if (["ask", "prompt", "approval", "approve"].includes(key)) { + return "ask"; + } + if (["on", "true", "yes", "1"].includes(key)) { + return "on"; + } + return undefined; +} + +export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode { + if (!level || level === "off") { + return "off"; + } + if (level === "full") { + return "full"; + } + return "ask"; +} + +export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined { + if (!raw) { + return undefined; + } + const key = raw.toLowerCase(); + if (["off", "false", "no", "0", "hide", "hidden", "disable", "disabled"].includes(key)) { + return "off"; + } + if (["on", "true", "yes", "1", "show", "visible", "enable", "enabled"].includes(key)) { + return "on"; + } + if (["stream", "streaming", "draft", "live"].includes(key)) { + return "stream"; + } + return undefined; +} diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index f43ca7bc34b..c83ce53b6f2 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -1,36 +1,39 @@ +import { + formatThinkingLevels as formatThinkingLevelsFallback, + isBinaryThinkingProvider as isBinaryThinkingProviderFallback, + listThinkingLevelLabels as listThinkingLevelLabelsFallback, + listThinkingLevels as listThinkingLevelsFallback, + normalizeProviderId, + resolveThinkingDefaultForModel as resolveThinkingDefaultForModelFallback, +} from "./thinking.shared.js"; +export { + formatXHighModelHint, + normalizeElevatedLevel, + normalizeFastMode, + normalizeNoticeLevel, + normalizeReasoningLevel, + normalizeThinkLevel, + normalizeUsageDisplay, + normalizeVerboseLevel, + resolveResponseUsageMode, + resolveElevatedMode, +} from "./thinking.shared.js"; +export type { + ElevatedLevel, + ElevatedMode, + NoticeLevel, + ReasoningLevel, + ThinkLevel, + ThinkingCatalogEntry, + UsageDisplayLevel, + VerboseLevel, +} from "./thinking.shared.js"; import { resolveProviderBinaryThinking, resolveProviderDefaultThinkingLevel, resolveProviderXHighThinking, } from "../plugins/provider-runtime.js"; -export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; -export type VerboseLevel = "off" | "on" | "full"; -export type NoticeLevel = "off" | "on" | "full"; -export type ElevatedLevel = "off" | "on" | "ask" | "full"; -export type ElevatedMode = "off" | "ask" | "full"; -export type ReasoningLevel = "off" | "on" | "stream"; -export type UsageDisplayLevel = "off" | "tokens" | "full"; -export type ThinkingCatalogEntry = { - provider: string; - id: string; - reasoning?: boolean; -}; - -function normalizeProviderId(provider?: string | null): string { - if (!provider) { - return ""; - } - const normalized = provider.trim().toLowerCase(); - if (normalized === "z.ai" || normalized === "z-ai") { - return "zai"; - } - if (normalized === "bedrock" || normalized === "aws-bedrock") { - return "amazon-bedrock"; - } - return normalized; -} - export function isBinaryThinkingProvider(provider?: string | null, model?: string | null): boolean { const normalizedProvider = normalizeProviderId(provider); if (!normalizedProvider) { @@ -47,46 +50,7 @@ export function isBinaryThinkingProvider(provider?: string | null, model?: strin if (typeof pluginDecision === "boolean") { return pluginDecision; } - return false; -} - -// Normalize user-provided thinking level strings to the canonical enum. -export function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined { - if (!raw) { - return undefined; - } - const key = raw.trim().toLowerCase(); - const collapsed = key.replace(/[\s_-]+/g, ""); - if (collapsed === "adaptive" || collapsed === "auto") { - return "adaptive"; - } - if (collapsed === "xhigh" || collapsed === "extrahigh") { - return "xhigh"; - } - if (["off"].includes(key)) { - return "off"; - } - if (["on", "enable", "enabled"].includes(key)) { - return "low"; - } - if (["min", "minimal"].includes(key)) { - return "minimal"; - } - if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) { - return "low"; - } - if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) { - return "medium"; - } - if ( - ["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key) - ) { - return "high"; - } - if (["think"].includes(key)) { - return "minimal"; - } - return undefined; + return isBinaryThinkingProviderFallback(provider, model); } export function supportsXHighThinking(provider?: string | null, model?: string | null): boolean { @@ -111,11 +75,10 @@ export function supportsXHighThinking(provider?: string | null, model?: string | } export function listThinkingLevels(provider?: string | null, model?: string | null): ThinkLevel[] { - const levels: ThinkLevel[] = ["off", "minimal", "low", "medium", "high"]; + const levels = listThinkingLevelsFallback(provider, model); if (supportsXHighThinking(provider, model)) { - levels.push("xhigh"); + levels.splice(levels.length - 1, 0, "xhigh"); } - levels.push("adaptive"); return levels; } @@ -123,7 +86,7 @@ export function listThinkingLevelLabels(provider?: string | null, model?: string if (isBinaryThinkingProvider(provider, model)) { return ["off", "on"]; } - return listThinkingLevels(provider, model); + return listThinkingLevelLabelsFallback(provider, model); } export function formatThinkingLevels( @@ -131,11 +94,9 @@ export function formatThinkingLevels( model?: string | null, separator = ", ", ): string { - return listThinkingLevelLabels(provider, model).join(separator); -} - -export function formatXHighModelHint(): string { - return "provider models that advertise xhigh reasoning"; + return supportsXHighThinking(provider, model) + ? listThinkingLevelLabels(provider, model).join(separator) + : formatThinkingLevelsFallback(provider, model, separator); } export function resolveThinkingDefaultForModel(params: { @@ -158,129 +119,5 @@ export function resolveThinkingDefaultForModel(params: { if (pluginDecision) { return pluginDecision; } - if (candidate?.reasoning) { - return "low"; - } - return "off"; -} - -type OnOffFullLevel = "off" | "on" | "full"; - -function normalizeOnOffFullLevel(raw?: string | null): OnOffFullLevel | undefined { - if (!raw) { - return undefined; - } - const key = raw.toLowerCase(); - if (["off", "false", "no", "0"].includes(key)) { - return "off"; - } - if (["full", "all", "everything"].includes(key)) { - return "full"; - } - if (["on", "minimal", "true", "yes", "1"].includes(key)) { - return "on"; - } - return undefined; -} - -// Normalize verbose flags used to toggle agent verbosity. -export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undefined { - return normalizeOnOffFullLevel(raw); -} - -// Normalize system notice flags used to toggle system notifications. -export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined { - return normalizeOnOffFullLevel(raw); -} - -// Normalize response-usage display modes used to toggle per-response usage footers. -export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined { - if (!raw) { - return undefined; - } - const key = raw.toLowerCase(); - if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) { - return "off"; - } - if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) { - return "tokens"; - } - if (["tokens", "token", "tok", "minimal", "min"].includes(key)) { - return "tokens"; - } - if (["full", "session"].includes(key)) { - return "full"; - } - return undefined; -} - -export function resolveResponseUsageMode(raw?: string | null): UsageDisplayLevel { - return normalizeUsageDisplay(raw) ?? "off"; -} - -// Normalize fast-mode flags used to toggle low-latency model behavior. -export function normalizeFastMode(raw?: string | boolean | null): boolean | undefined { - if (typeof raw === "boolean") { - return raw; - } - if (!raw) { - return undefined; - } - const key = raw.toLowerCase(); - if (["off", "false", "no", "0", "disable", "disabled", "normal"].includes(key)) { - return false; - } - if (["on", "true", "yes", "1", "enable", "enabled", "fast"].includes(key)) { - return true; - } - return undefined; -} - -// Normalize elevated flags used to toggle elevated bash permissions. -export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | undefined { - if (!raw) { - return undefined; - } - const key = raw.toLowerCase(); - if (["off", "false", "no", "0"].includes(key)) { - return "off"; - } - if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) { - return "full"; - } - if (["ask", "prompt", "approval", "approve"].includes(key)) { - return "ask"; - } - if (["on", "true", "yes", "1"].includes(key)) { - return "on"; - } - return undefined; -} - -export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode { - if (!level || level === "off") { - return "off"; - } - if (level === "full") { - return "full"; - } - return "ask"; -} - -// Normalize reasoning visibility flags used to toggle reasoning exposure. -export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined { - if (!raw) { - return undefined; - } - const key = raw.toLowerCase(); - if (["off", "false", "no", "0", "hide", "hidden", "disable", "disabled"].includes(key)) { - return "off"; - } - if (["on", "true", "yes", "1", "show", "visible", "enable", "enabled"].includes(key)) { - return "on"; - } - if (["stream", "streaming", "draft", "live"].includes(key)) { - return "stream"; - } - return undefined; + return resolveThinkingDefaultForModelFallback(params); } diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 1db10dd93d6..b1d06d5e2b2 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -9,7 +9,7 @@ import { normalizeThinkLevel, normalizeVerboseLevel, resolveThinkingDefaultForModel, -} from "../../../../src/auto-reply/thinking.js"; +} from "../../../../src/auto-reply/thinking.shared.js"; import { DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, From c06101b8adad665347ed3cd3f141c6bf94b5062f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:47:44 -0700 Subject: [PATCH 15/56] Infra: restore check after gaxios compat --- package.json | 1 + pnpm-lock.yaml | 5 +++++ src/auto-reply/thinking.ts | 3 ++- src/infra/bonjour.ts | 2 +- .../contracts/auth-choice.contract.test.ts | 21 ++++++++++++------- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 0a345f172a0..f85bdb6e463 100644 --- a/package.json +++ b/package.json @@ -382,6 +382,7 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "file-type": "^21.3.2", + "gaxios": "^7.1.3", "grammy": "^1.41.1", "hono": "4.12.7", "https-proxy-agent": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8168f1b74d..90ebda912b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: file-type: specifier: 21.3.2 version: 21.3.2 + gaxios: + specifier: ^7.1.3 + version: 7.1.3 grammy: specifier: ^1.41.1 version: 1.41.1 @@ -271,6 +274,8 @@ importers: specifier: 0.3.0 version: 0.3.0(zod@4.3.6) + extensions/amazon-bedrock: {} + extensions/anthropic: {} extensions/bluebubbles: diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index c83ce53b6f2..1f2f1738b1f 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -6,6 +6,7 @@ import { normalizeProviderId, resolveThinkingDefaultForModel as resolveThinkingDefaultForModelFallback, } from "./thinking.shared.js"; +import type { ThinkLevel, ThinkingCatalogEntry } from "./thinking.shared.js"; export { formatXHighModelHint, normalizeElevatedLevel, @@ -50,7 +51,7 @@ export function isBinaryThinkingProvider(provider?: string | null, model?: strin if (typeof pluginDecision === "boolean") { return pluginDecision; } - return isBinaryThinkingProviderFallback(provider, model); + return isBinaryThinkingProviderFallback(provider); } export function supportsXHighThinking(provider?: string | null, model?: string | null): boolean { diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index c99d45d85f4..457853a9b45 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -185,7 +185,7 @@ export async function startGatewayBonjourAdvertiser( if (!cycle) { return; } - const responder = cycle.responder as { + const responder = cycle.responder as unknown as { advertiseService?: (...args: unknown[]) => unknown; announce?: (...args: unknown[]) => unknown; probe?: (...args: unknown[]) => unknown; diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index 6ffa2a22055..fa4f4daa0ad 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -14,13 +14,20 @@ import { import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; +type ResolvePluginProviders = + typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolvePluginProviders; +type ResolveProviderPluginChoice = + typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolveProviderPluginChoice; +type RunProviderModelSelectedHook = + typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").runProviderModelSelectedHook; + const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); -const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); -const resolveProviderPluginChoiceMock = vi.hoisted(() => - vi.fn<() => { provider: ProviderPlugin; method: ProviderPlugin["auth"][number] } | null>(), +const resolvePluginProvidersMock = vi.hoisted(() => vi.fn(() => [])); +const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn()); +const runProviderModelSelectedHookMock = vi.hoisted(() => + vi.fn(async () => {}), ); -const runProviderModelSelectedHookMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({ loginQwenPortalOAuth: loginQwenPortalOAuthMock, @@ -31,9 +38,9 @@ vi.mock("../../providers/github-copilot-auth.js", () => ({ })); vi.mock("../../commands/auth-choice.apply.plugin-provider.runtime.js", () => ({ - resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), - resolveProviderPluginChoice: (...args: unknown[]) => resolveProviderPluginChoiceMock(...args), - runProviderModelSelectedHook: (...args: unknown[]) => runProviderModelSelectedHookMock(...args), + resolvePluginProviders: resolvePluginProvidersMock, + resolveProviderPluginChoice: resolveProviderPluginChoiceMock, + runProviderModelSelectedHook: runProviderModelSelectedHookMock, })); type StoredAuthProfile = { From c9423dce1ebc3e3e8df729d59618287be0b5d3c0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:49:04 -0700 Subject: [PATCH 16/56] Docs: refresh generated config baseline --- docs/.generated/config-baseline.json | 69 +++++++++++++++++++++++++++ docs/.generated/config-baseline.jsonl | 7 ++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index c976fbac1af..bf67b685710 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -44995,6 +44995,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.amazon-bedrock", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/amazon-bedrock-provider", + "help": "OpenClaw Amazon Bedrock provider plugin (plugin: amazon-bedrock)", + "hasChildren": true + }, + { + "path": "plugins.entries.amazon-bedrock.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/amazon-bedrock-provider Config", + "help": "Plugin-defined config payload for amazon-bedrock.", + "hasChildren": false + }, + { + "path": "plugins.entries.amazon-bedrock.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/amazon-bedrock-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.amazon-bedrock.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.amazon-bedrock.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.anthropic", "kind": "plugin", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 522e41beb37..34c4f9d5378 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5094} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5098} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3986,6 +3986,11 @@ {"recordType":"path","path":"plugins.entries.acpx.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable ACPX Runtime","hasChildren":false} {"recordType":"path","path":"plugins.entries.acpx.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.amazon-bedrock","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/amazon-bedrock-provider","help":"OpenClaw Amazon Bedrock provider plugin (plugin: amazon-bedrock)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/amazon-bedrock-provider Config","help":"Plugin-defined config payload for amazon-bedrock.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/amazon-bedrock-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.amazon-bedrock.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.anthropic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider","help":"OpenClaw Anthropic provider plugin (plugin: anthropic)","hasChildren":true} {"recordType":"path","path":"plugins.entries.anthropic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider Config","help":"Plugin-defined config payload for anthropic.","hasChildren":false} {"recordType":"path","path":"plugins.entries.anthropic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/anthropic-provider","hasChildren":false} From 43c156e43b060870ce5d0157f5a28e050914bcea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 08:50:10 +0000 Subject: [PATCH 17/56] docs: reorder unreleased changelog entries --- CHANGELOG.md | 134 +++++++++++++++++++++++++-------------------------- 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 821451053bf..df03ad8fc5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,27 +6,27 @@ Docs: https://docs.openclaw.ai ### Changes -- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl. - Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman. -- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327. -- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl. -- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) -- Web tools/Firecrawl: add Firecrawl as an `onboard`/configure search provider via a bundled plugin, expose explicit `firecrawl_search` and `firecrawl_scrape` tools, and align core `web_fetch` fallback behavior with Firecrawl base-URL/env fallback plus guarded endpoint fetches. -- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. -- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. -- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. -- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. -- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy. -- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. -- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. -- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. -- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. -- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. - Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. - Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. -- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) +- Web tools/Firecrawl: add Firecrawl as an `onboard`/configure search provider via a bundled plugin, expose explicit `firecrawl_search` and `firecrawl_scrape` tools, and align core `web_fetch` fallback behavior with Firecrawl base-URL/env fallback plus guarded endpoint fetches. +- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. +- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy. +- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. +- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. +- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327. +- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl. - Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) - Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. Thanks @vincentkoc. +- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) +- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) +- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl. +- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. +- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. +- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. +- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. +- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. +- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. ### Breaking @@ -34,66 +34,64 @@ Docs: https://docs.openclaw.ai ### Fixes -- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles. -- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. -- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom. -- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. -- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw. -- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied. -- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly. -- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. -- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc. -- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults. -- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) -- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao. -- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28. -- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) -- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc. +- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. +- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. +- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. -- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. -- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. -- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc. -- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. -- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. -- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc. - Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. Fixes #46924 and #47041. -- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc. -- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc. -- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc. -- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason. -- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava. -- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT. -- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason. -- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026. -- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`) -- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. +- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc. - Gateway/restart: defer externally signaled unmanaged restarts through the in-process idle drain, and preserve the restored subagent run as remap fallback during orphan recovery so resumed sessions do not duplicate work. (#47719) Thanks @joeykrug. -- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898. -- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. -- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0. -- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark. -- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc. -- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) -- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. -- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc. -- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent. -- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) -- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc. -- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc. -- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. -- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc. -- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. +- Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79. +- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. - CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc. - CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc. - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. -- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. -- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. +- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc. +- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`) +- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc. +- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc. +- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc. +- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. +- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. +- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc. +- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc. +- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc. +- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc. +- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason. +- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026. +- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava. +- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao. +- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc. +- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. +- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. +- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly. +- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw. +- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied. +- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT. +- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason. +- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent. +- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) +- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) +- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28. +- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) +- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898. +- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. +- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc. - Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. - Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras. -- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. -- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI. -- Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79. -- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. +- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. +- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom. +- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc. +- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults. +- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles. +- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0. +- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. +- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark. +- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. +- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. +- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc. +- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) +- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. ## 2026.3.13 From 6f5369c7e8a15199265966b0774fb203cb7feb22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 08:50:27 +0000 Subject: [PATCH 18/56] fix: split browser-safe thinking helpers --- extensions/slack/src/message-action-dispatch.ts | 3 +++ src/auto-reply/thinking.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index b251d0b80eb..58fc4d77184 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -187,6 +187,9 @@ export async function handleSlackMessageAction(params: { const channelId = readStringParam(actionParams, "channelId") ?? readStringParam(actionParams, "to", { required: true }); + if (!channelId) { + throw new Error("channelId required"); + } return normalizeChannelId ? normalizeChannelId(channelId) : channelId; }; diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 35d1c289271..a6867d1d9b8 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -118,6 +118,10 @@ describe("listThinkingLevelLabels", () => { expect(listThinkingLevelLabels("zai", "glm-4.7")).toEqual(["off", "on"]); }); + it("keeps built-in binary thinking fallback without provider runtime", () => { + expect(listThinkingLevelLabels("zai", "glm-4.7")).toEqual(["off", "on"]); + }); + it("returns full levels for non-ZAI", () => { expect(listThinkingLevelLabels("openai", "gpt-4.1-mini")).toContain("low"); expect(listThinkingLevelLabels("openai", "gpt-4.1-mini")).not.toContain("on"); @@ -157,6 +161,15 @@ describe("resolveThinkingDefaultForModel", () => { ).toBe("adaptive"); }); + it("keeps built-in adaptive defaults without provider runtime", () => { + expect( + resolveThinkingDefaultForModel({ provider: "anthropic", model: "claude-opus-4-6" }), + ).toBe("adaptive"); + expect( + resolveThinkingDefaultForModel({ provider: "aws-bedrock", model: "claude-sonnet-4-6" }), + ).toBe("adaptive"); + }); + it("defaults reasoning-capable catalog models to low", () => { expect( resolveThinkingDefaultForModel({ From 2a85fa7db18f9296c4486abc61d2b35e213f3c3e Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Mon, 16 Mar 2026 10:57:08 +0200 Subject: [PATCH 19/56] fix(macos): restore debug build helpers (#48046) --- .../CanvasA2UIActionMessageHandler.swift | 37 +++++++++++ apps/macos/Sources/OpenClaw/CronModels.swift | 65 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift index c81d4b59705..0599f4ab3a6 100644 --- a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift +++ b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift @@ -8,6 +8,24 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { static let messageName = "openclawCanvasA2UIAction" static let allMessageNames = [messageName] + // Compatibility helper for debug/test shims. Runtime dispatch remains + // limited to in-app canvas schemes in `didReceive`. + static func isLocalNetworkCanvasURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return false + } + guard let host = url.host?.lowercased(), !host.isEmpty else { + return false + } + if host == "localhost" { + return true + } + guard let ip = Self.parseIPv4(host) else { + return false + } + return Self.isLocalNetworkIPv4(ip) + } + private let sessionKey: String init(sessionKey: String) { @@ -104,5 +122,24 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { } } } + + private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + let bytes = parts.compactMap { UInt8($0) } + guard bytes.count == 4 else { return nil } + return (bytes[0], bytes[1], bytes[2], bytes[3]) + } + + private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { + let (a, b, _, _) = ip + if a == 10 { return true } + if a == 172, (16...31).contains(Int(b)) { return true } + if a == 192, b == 168 { return true } + if a == 127 { return true } + if a == 169, b == 254 { return true } + if a == 100, (64...127).contains(Int(b)) { return true } + return false + } // Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`). } diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index 40079453974..78016ff9f88 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -254,6 +254,71 @@ struct CronJob: Identifiable, Codable, Equatable { case state } + init( + id: String, + agentId: String?, + name: String, + description: String?, + enabled: Bool, + deleteAfterRun: Bool?, + createdAtMs: Int, + updatedAtMs: Int, + schedule: CronSchedule, + sessionTarget: CronSessionTarget, + wakeMode: CronWakeMode, + payload: CronPayload, + delivery: CronDelivery?, + state: CronJobState) + { + self.init( + id: id, + agentId: agentId, + name: name, + description: description, + enabled: enabled, + deleteAfterRun: deleteAfterRun, + createdAtMs: createdAtMs, + updatedAtMs: updatedAtMs, + schedule: schedule, + sessionTarget: .predefined(sessionTarget), + wakeMode: wakeMode, + payload: payload, + delivery: delivery, + state: state) + } + + init( + id: String, + agentId: String?, + name: String, + description: String?, + enabled: Bool, + deleteAfterRun: Bool?, + createdAtMs: Int, + updatedAtMs: Int, + schedule: CronSchedule, + sessionTarget: CronCustomSessionTarget, + wakeMode: CronWakeMode, + payload: CronPayload, + delivery: CronDelivery?, + state: CronJobState) + { + self.id = id + self.agentId = agentId + self.name = name + self.description = description + self.enabled = enabled + self.deleteAfterRun = deleteAfterRun + self.createdAtMs = createdAtMs + self.updatedAtMs = updatedAtMs + self.schedule = schedule + self.sessionTargetRaw = sessionTarget.rawValue + self.wakeMode = wakeMode + self.payload = payload + self.delivery = delivery + self.state = state + } + /// Parsed session target (predefined or custom session ID) var parsedSessionTarget: CronCustomSessionTarget { CronCustomSessionTarget.from(self.sessionTargetRaw) From d896d8e0cd1900570e0ac95d463f0816cb9c33cc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:02:39 -0700 Subject: [PATCH 20/56] Docs: add Claude marketplace plugin install guidance --- docs/cli/index.md | 3 ++- docs/cli/plugins.md | 33 +++++++++++++++++++++++++++++++-- docs/plugins/bundles.md | 7 +++++++ docs/tools/plugin.md | 16 ++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index ded970cde9d..9c4b58d1c35 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -284,7 +284,8 @@ Manage extensions and their config: - `openclaw plugins list` — discover plugins (use `--json` for machine output). - `openclaw plugins info ` — show details for a plugin. -- `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`). +- `openclaw plugins install ` — install a plugin (or add a plugin path to `plugins.load.paths`). +- `openclaw plugins marketplace list ` — list marketplace entries before install. - `openclaw plugins enable ` / `disable ` — toggle `plugins.entries..enabled`. - `openclaw plugins doctor` — report plugin load errors. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 4d9d1e8e80d..b867420551f 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)" +summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, doctor)" read_when: - You want to install or manage Gateway plugins or compatible bundles - You want to debug plugin load failures @@ -28,6 +28,7 @@ openclaw plugins uninstall openclaw plugins doctor openclaw plugins update openclaw plugins update --all +openclaw plugins marketplace list ``` Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to @@ -46,6 +47,8 @@ capabilities. ```bash openclaw plugins install openclaw plugins install --pin +openclaw plugins install @ +openclaw plugins install --marketplace ``` Security note: treat plugin installs like running code. Prefer pinned versions. @@ -65,6 +68,31 @@ name, use an explicit scoped spec (for example `@scope/diffs`). Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. +Claude marketplace installs are also supported. + +Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's +local registry cache at `~/.claude/plugins/known_marketplaces.json`: + +```bash +openclaw plugins marketplace list claude-plugins-official +openclaw plugins install superpowers@claude-plugins-official +``` + +Use `--marketplace` when you want to pass the marketplace source explicitly: + +```bash +openclaw plugins install superpowers --marketplace claude-plugins-official +openclaw plugins install superpowers --marketplace anthropics/claude-plugins-official +openclaw plugins install superpowers --marketplace ./my-marketplace +``` + +Marketplace sources can be: + +- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json` +- a local marketplace root or `marketplace.json` path +- a GitHub repo shorthand such as `owner/repo` +- a git URL + For local paths and archives, OpenClaw auto-detects: - native OpenClaw plugins (`openclaw.plugin.json`) @@ -114,7 +142,8 @@ openclaw plugins update --all openclaw plugins update --dry-run ``` -Updates only apply to plugins installed from npm (tracked in `plugins.installs`). +Updates apply to tracked installs in `plugins.installs`, currently npm and +marketplace installs. When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw prints a warning and asks for confirmation before proceeding. Use diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index b5f92f8f5ee..c652ff997f0 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -259,12 +259,19 @@ openclaw plugins install ./my-codex-bundle openclaw plugins install ./my-claude-bundle openclaw plugins install ./my-cursor-bundle openclaw plugins install ./my-bundle.tgz +openclaw plugins marketplace list claude-plugins-official +openclaw plugins install superpowers@claude-plugins-official openclaw plugins info my-bundle ``` If the directory is a native OpenClaw plugin/package, the native install path still wins. +For Claude marketplace names, OpenClaw reads the local Claude known-marketplace +registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries +can resolve to bundle-compatible directories/archives or to native plugin +sources; after resolution, the normal install rules still apply. + ## Troubleshooting ### Bundle is detected but capabilities do not run diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 9269e8b1faf..ff76ac551c9 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -57,6 +57,18 @@ openclaw plugins install ./my-bundle openclaw plugins install ./my-bundle.tgz ``` +For Claude marketplace installs, list the marketplace first, then install by +marketplace entry name: + +```bash +openclaw plugins marketplace list claude-plugins-official +openclaw plugins install superpowers@claude-plugins-official +``` + +OpenClaw resolves known Claude marketplace names from +`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit +marketplace source with `--marketplace`. + ## Architecture OpenClaw's plugin system has four layers: @@ -94,6 +106,10 @@ OpenClaw also recognizes two compatible external bundle layouts: component layout without a manifest - Cursor-style bundles: `.cursor-plugin/plugin.json` +Claude marketplace entries can point at any of these compatible bundles, or at +native OpenClaw plugin sources. OpenClaw resolves the marketplace entry first, +then runs the normal install path for the resolved source. + They are shown in the plugin list as `format=bundle`, with a subtype of `codex` or `claude` in verbose/info output. From 5cd206f780ec7089d792d21ee79706f4a3f6f969 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:31:48 -0700 Subject: [PATCH 21/56] Channels: expand contract suites --- src/channels/plugins/contracts/suites.ts | 334 ++++++++++++++++++++++- 1 file changed, 333 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index fc79d26fa07..f2c8a8e3b16 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -1,5 +1,13 @@ -import { expect, it } from "vitest"; +import { expect, it, type Mock } from "vitest"; +import type { MsgContext } from "../../../auto-reply/templating.js"; import type { OpenClawConfig } from "../../../config/config.js"; +import type { + ResolveProviderRuntimeGroupPolicyParams, + RuntimeGroupPolicyResolution, +} from "../../../config/runtime-group-policy.js"; +import { normalizeChatType } from "../../chat-type.js"; +import { resolveConversationLabel } from "../../conversation-label.js"; +import { validateSenderIdentity } from "../../sender-identity.js"; import type { ChannelAccountSnapshot, ChannelAccountState, @@ -84,6 +92,142 @@ export function installChannelActionsContractSuite(params: { } } +export function installChannelSurfaceContractSuite(params: { + plugin: Pick< + ChannelPlugin, + | "id" + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway" + >; + surface: + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway"; +}) { + const { plugin, surface } = params; + + it(`exposes the ${surface} surface contract`, () => { + if (surface === "actions") { + expect(plugin.actions).toBeDefined(); + expect(typeof plugin.actions?.listActions).toBe("function"); + return; + } + + if (surface === "setup") { + expect(plugin.setup).toBeDefined(); + expect(typeof plugin.setup?.applyAccountConfig).toBe("function"); + return; + } + + if (surface === "status") { + expect(plugin.status).toBeDefined(); + expect(typeof plugin.status?.buildAccountSnapshot).toBe("function"); + return; + } + + if (surface === "outbound") { + const outbound = plugin.outbound; + expect(outbound).toBeDefined(); + expect(["direct", "gateway", "hybrid"]).toContain(outbound?.deliveryMode); + expect( + [ + outbound?.sendPayload, + outbound?.sendFormattedText, + outbound?.sendFormattedMedia, + outbound?.sendText, + outbound?.sendMedia, + outbound?.sendPoll, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + if (surface === "messaging") { + const messaging = plugin.messaging; + expect(messaging).toBeDefined(); + expect( + [ + messaging?.normalizeTarget, + messaging?.parseExplicitTarget, + messaging?.inferTargetChatType, + messaging?.buildCrossContextComponents, + messaging?.enableInteractiveReplies, + messaging?.hasStructuredReplyPayload, + messaging?.formatTargetDisplay, + messaging?.resolveOutboundSessionRoute, + ].some((value) => typeof value === "function"), + ).toBe(true); + if (messaging?.targetResolver) { + if (messaging.targetResolver.looksLikeId) { + expect(typeof messaging.targetResolver.looksLikeId).toBe("function"); + } + if (messaging.targetResolver.hint !== undefined) { + expect(typeof messaging.targetResolver.hint).toBe("string"); + expect(messaging.targetResolver.hint.trim()).not.toBe(""); + } + if (messaging.targetResolver.resolveTarget) { + expect(typeof messaging.targetResolver.resolveTarget).toBe("function"); + } + } + return; + } + + if (surface === "threading") { + const threading = plugin.threading; + expect(threading).toBeDefined(); + expect( + [ + threading?.resolveReplyToMode, + threading?.buildToolContext, + threading?.resolveAutoThreadId, + threading?.resolveReplyTransport, + threading?.resolveFocusedBinding, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + if (surface === "directory") { + const directory = plugin.directory; + expect(directory).toBeDefined(); + expect( + [ + directory?.self, + directory?.listPeers, + directory?.listPeersLive, + directory?.listGroups, + directory?.listGroupsLive, + directory?.listGroupMembers, + ].some((value) => typeof value === "function"), + ).toBe(true); + return; + } + + const gateway = plugin.gateway; + expect(gateway).toBeDefined(); + expect( + [ + gateway?.startAccount, + gateway?.stopAccount, + gateway?.loginWithQrStart, + gateway?.loginWithQrWait, + gateway?.logoutAccount, + ].some((value) => typeof value === "function"), + ).toBe(true); + }); +} + type ChannelSetupContractCase = { name: string; cfg: OpenClawConfig; @@ -214,3 +358,191 @@ export function installChannelStatusContractSuite { + run: () => Promise>; + sendMock: Mock; + to: string; + }; +}) { + it("text-only delegates to sendText", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "hello" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("single media delegates to sendMedia", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith( + to, + "cap", + expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("multi-media iterates URLs with caption on first", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { + text: "caption", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + }, + sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }], + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(sendMock).toHaveBeenNthCalledWith( + 1, + to, + "caption", + expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), + ); + expect(sendMock).toHaveBeenNthCalledWith( + 2, + to, + "", + expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" }); + }); + + it("empty payload returns no-op", async () => { + const { run, sendMock } = params.createHarness({ payload: {} }); + const result = await run(); + + expect(sendMock).not.toHaveBeenCalled(); + expect(result).toEqual({ channel: params.channel, messageId: "" }); + }); + + if (params.chunking.mode === "passthrough") { + it("text exceeding chunk limit is sent as-is when chunker is null", async () => { + const text = "a".repeat(params.chunking.longTextLength); + const { run, sendMock, to } = params.createHarness({ payload: { text } }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + return; + } + + const chunking = params.chunking; + + it("chunking splits long text", async () => { + const text = "a".repeat(chunking.longTextLength); + const { run, sendMock } = params.createHarness({ + payload: { text }, + sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }], + }); + const result = await run(); + + expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2); + for (const call of sendMock.mock.calls) { + expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength); + } + expect(result).toMatchObject({ channel: params.channel }); + }); +} + +export function primeChannelOutboundSendMock( + sendMock: Mock, + fallbackResult: Record, + sendResults: SendResultLike[] = [], +) { + sendMock.mockReset(); + if (sendResults.length === 0) { + sendMock.mockResolvedValue(fallbackResult); + return; + } + for (const result of sendResults) { + sendMock.mockResolvedValueOnce(result); + } +} + +type RuntimeGroupPolicyResolver = ( + params: ResolveProviderRuntimeGroupPolicyParams, +) => RuntimeGroupPolicyResolution; + +export function installChannelRuntimeGroupPolicyFallbackSuite(params: { + configuredLabel: string; + defaultGroupPolicyUnderTest: "allowlist" | "disabled" | "open"; + missingConfigLabel: string; + missingDefaultLabel: string; + resolve: RuntimeGroupPolicyResolver; +}) { + it(params.missingConfigLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: false, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); + + it(params.configuredLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: true, + }); + expect(resolved.groupPolicy).toBe("open"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + + it(params.missingDefaultLabel, () => { + const resolved = params.resolve({ + providerConfigPresent: false, + defaultGroupPolicy: params.defaultGroupPolicyUnderTest, + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); +} + +export function expectChannelInboundContextContract(ctx: MsgContext) { + expect(validateSenderIdentity(ctx)).toEqual([]); + + expect(ctx.Body).toBeTypeOf("string"); + expect(ctx.BodyForAgent).toBeTypeOf("string"); + expect(ctx.BodyForCommands).toBeTypeOf("string"); + + const chatType = normalizeChatType(ctx.ChatType); + if (chatType && chatType !== "direct") { + const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx); + expect(label).toBeTruthy(); + } +} From 429144d9f17b3e05f4816d53334c954602ec41cf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:32:11 -0700 Subject: [PATCH 22/56] Channels: add contract surface coverage --- .../contracts/registry.contract.test.ts | 73 ++++++ src/channels/plugins/contracts/registry.ts | 211 ++++++++++++++++++ .../contracts/surface.contract.test.ts | 14 ++ 3 files changed, 298 insertions(+) create mode 100644 src/channels/plugins/contracts/registry.contract.test.ts create mode 100644 src/channels/plugins/contracts/surface.contract.test.ts diff --git a/src/channels/plugins/contracts/registry.contract.test.ts b/src/channels/plugins/contracts/registry.contract.test.ts new file mode 100644 index 00000000000..69ff11d8e68 --- /dev/null +++ b/src/channels/plugins/contracts/registry.contract.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + actionContractRegistry, + pluginContractRegistry, + setupContractRegistry, + statusContractRegistry, + surfaceContractRegistry, + type ChannelPluginSurface, +} from "./registry.js"; + +const orderedSurfaceKeys = [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", +] as const satisfies readonly ChannelPluginSurface[]; + +describe("channel contract registry", () => { + it("does not duplicate channel plugin ids", () => { + const ids = pluginContractRegistry.map((entry) => entry.id); + expect(ids).toEqual([...new Set(ids)]); + }); + + it("keeps the surface registry aligned with the plugin registry", () => { + expect(surfaceContractRegistry.map((entry) => entry.id).toSorted()).toEqual( + pluginContractRegistry.map((entry) => entry.id).toSorted(), + ); + }); + + it("declares the actual owned channel plugin surfaces explicitly", () => { + for (const entry of surfaceContractRegistry) { + const actual = orderedSurfaceKeys.filter((surface) => Boolean(entry.plugin[surface])); + expect([...entry.surfaces].toSorted()).toEqual(actual.toSorted()); + } + }); + + it("only installs deep action coverage for plugins that declare actions", () => { + const actionSurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("actions")) + .map((entry) => entry.id), + ); + for (const entry of actionContractRegistry) { + expect(actionSurfaceIds.has(entry.id)).toBe(true); + } + }); + + it("only installs deep setup coverage for plugins that declare setup", () => { + const setupSurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("setup")) + .map((entry) => entry.id), + ); + for (const entry of setupContractRegistry) { + expect(setupSurfaceIds.has(entry.id)).toBe(true); + } + }); + + it("only installs deep status coverage for plugins that declare status", () => { + const statusSurfaceIds = new Set( + surfaceContractRegistry + .filter((entry) => entry.surfaces.includes("status")) + .map((entry) => entry.id), + ); + for (const entry of statusContractRegistry) { + expect(statusSurfaceIds.has(entry.id)).toBe(true); + } + }); +}); diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 567181cef46..77bf23b335c 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -57,6 +57,33 @@ type StatusContractEntry = { }>; }; +export type ChannelPluginSurface = + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway"; + +type SurfaceContractEntry = { + id: string; + plugin: Pick< + ChannelPlugin, + | "id" + | "actions" + | "setup" + | "status" + | "outbound" + | "messaging" + | "threading" + | "directory" + | "gateway" + >; + surfaces: readonly ChannelPluginSurface[]; +}; + const telegramListActionsMock = vi.fn(); const telegramGetCapabilitiesMock = vi.fn(); const discordListActionsMock = vi.fn(); @@ -461,3 +488,187 @@ export const statusContractRegistry: StatusContractEntry[] = [ ], }, ]; + +export const surfaceContractRegistry: SurfaceContractEntry[] = [ + { + id: "bluebubbles", + plugin: bluebubblesPlugin, + surfaces: ["actions", "setup", "status", "outbound", "messaging", "threading", "gateway"], + }, + { + id: "discord", + plugin: discordPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "feishu", + plugin: feishuPlugin, + surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "googlechat", + plugin: googlechatPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "imessage", + plugin: imessagePlugin, + surfaces: ["setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "irc", + plugin: ircPlugin, + surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "line", + plugin: linePlugin, + surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "matrix", + plugin: matrixPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "mattermost", + plugin: mattermostPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "msteams", + plugin: msteamsPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "nextcloud-talk", + plugin: nextcloudTalkPlugin, + surfaces: ["setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "nostr", + plugin: nostrPlugin, + surfaces: ["setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "signal", + plugin: signalPlugin, + surfaces: ["actions", "setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "slack", + plugin: slackPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "synology-chat", + plugin: synologyChatPlugin, + surfaces: ["setup", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "telegram", + plugin: telegramPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "tlon", + plugin: tlonPlugin, + surfaces: ["setup", "status", "outbound", "messaging", "gateway"], + }, + { + id: "whatsapp", + plugin: whatsappPlugin, + surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], + }, + { + id: "zalo", + plugin: zaloPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, + { + id: "zalouser", + plugin: zalouserPlugin, + surfaces: [ + "actions", + "setup", + "status", + "outbound", + "messaging", + "threading", + "directory", + "gateway", + ], + }, +]; diff --git a/src/channels/plugins/contracts/surface.contract.test.ts b/src/channels/plugins/contracts/surface.contract.test.ts new file mode 100644 index 00000000000..6e657bd19fc --- /dev/null +++ b/src/channels/plugins/contracts/surface.contract.test.ts @@ -0,0 +1,14 @@ +import { describe } from "vitest"; +import { surfaceContractRegistry } from "./registry.js"; +import { installChannelSurfaceContractSuite } from "./suites.js"; + +for (const entry of surfaceContractRegistry) { + for (const surface of entry.surfaces) { + describe(`${entry.id} ${surface} surface contract`, () => { + installChannelSurfaceContractSuite({ + plugin: entry.plugin, + surface, + }); + }); + } +} From 4aae0d4c9d7b5aef3bd36ca60979ccc900c25600 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:32:22 -0700 Subject: [PATCH 23/56] Channels: centralize outbound payload contracts --- .../src/outbound-adapter.sendpayload.test.ts | 37 ---- .../src/outbound-adapter.sendpayload.test.ts | 33 --- .../zalo/src/channel.sendpayload.test.ts | 44 ---- .../zalouser/src/channel.sendpayload.test.ts | 21 +- .../outbound-payload.contract.test.ts | 209 ++++++++++++++++++ .../direct-text-media.sendpayload.test.ts | 47 ---- .../outbound/slack.sendpayload.test.ts | 13 +- src/test-utils/send-payload-contract.ts | 138 ------------ 8 files changed, 213 insertions(+), 329 deletions(-) delete mode 100644 extensions/discord/src/outbound-adapter.sendpayload.test.ts delete mode 100644 extensions/zalo/src/channel.sendpayload.test.ts create mode 100644 src/channels/plugins/contracts/outbound-payload.contract.test.ts delete mode 100644 src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts delete mode 100644 src/test-utils/send-payload-contract.ts diff --git a/extensions/discord/src/outbound-adapter.sendpayload.test.ts b/extensions/discord/src/outbound-adapter.sendpayload.test.ts deleted file mode 100644 index ae5d86f8700..00000000000 --- a/extensions/discord/src/outbound-adapter.sendpayload.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, vi } from "vitest"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; -import { discordOutbound } from "./outbound-adapter.js"; - -function createHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendDiscord = vi.fn(); - primeSendMock(sendDiscord, { messageId: "dc-1", channelId: "123456" }, params.sendResults); - const ctx = { - cfg: {}, - to: "channel:123456", - text: "", - payload: params.payload, - deps: { - sendDiscord, - }, - }; - return { - run: async () => await discordOutbound.sendPayload!(ctx), - sendMock: sendDiscord, - to: ctx.to, - }; -} - -describe("discordOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "discord", - chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness, - }); -}); diff --git a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts index 81f30ea1c71..52b44de49e7 100644 --- a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts @@ -1,40 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; import { whatsappOutbound } from "./outbound-adapter.js"; -function createHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendWhatsApp = vi.fn(); - primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults); - const ctx = { - cfg: {}, - to: "5511999999999@c.us", - text: "", - payload: params.payload, - deps: { - sendWhatsApp, - }, - }; - return { - run: async () => await whatsappOutbound.sendPayload!(ctx), - sendMock: sendWhatsApp, - to: ctx.to, - }; -} - describe("whatsappOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "whatsapp", - chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, - createHarness, - }); - it("trims leading whitespace for direct text sends", async () => { const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); diff --git a/extensions/zalo/src/channel.sendpayload.test.ts b/extensions/zalo/src/channel.sendpayload.test.ts deleted file mode 100644 index 27acb737f9f..00000000000 --- a/extensions/zalo/src/channel.sendpayload.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ReplyPayload } from "openclaw/plugin-sdk/zalo"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; -import { zaloPlugin } from "./channel.js"; - -vi.mock("./send.js", () => ({ - sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }), -})); - -function baseCtx(payload: ReplyPayload) { - return { - cfg: {}, - to: "123456789", - text: "", - payload, - }; -} - -describe("zaloPlugin outbound sendPayload", () => { - let mockedSend: ReturnType>; - - beforeEach(async () => { - const mod = await import("./send.js"); - mockedSend = vi.mocked(mod.sendMessageZalo); - mockedSend.mockClear(); - mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" }); - }); - - installSendPayloadContractSuite({ - channel: "zalo", - chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, - createHarness: ({ payload, sendResults }) => { - primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults); - return { - run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)), - sendMock: mockedSend, - to: "123456789", - }; - }, - }); -}); diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 27a8adf2c0d..2c9d5240ba9 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -1,10 +1,7 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./accounts.test-mocks.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; +import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js"; import { zalouserPlugin } from "./channel.js"; import { setZalouserRuntime } from "./runtime.js"; @@ -36,8 +33,7 @@ describe("zalouserPlugin outbound sendPayload", () => { } as never); const mod = await import("./send.js"); mockedSend = vi.mocked(mod.sendMessageZalouser); - mockedSend.mockClear(); - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" }); + primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" }); }); it("group target delegates with isGroup=true and stripped threadId", async () => { @@ -110,19 +106,6 @@ describe("zalouserPlugin outbound sendPayload", () => { ); expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" }); }); - - installSendPayloadContractSuite({ - channel: "zalouser", - chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness: ({ payload, sendResults }) => { - primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults); - return { - run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)), - sendMock: mockedSend, - to: "987654321", - }; - }, - }); }); describe("zalouserPlugin messaging target normalization", () => { diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..5faa47893cb --- /dev/null +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -0,0 +1,209 @@ +import { describe, vi } from "vitest"; +import { discordOutbound } from "../../../../extensions/discord/src/outbound-adapter.js"; +import { whatsappOutbound } from "../../../../extensions/whatsapp/src/outbound-adapter.js"; +import { zaloPlugin } from "../../../../extensions/zalo/src/channel.js"; +import { sendMessageZalo } from "../../../../extensions/zalo/src/send.js"; +import "./../../../../extensions/zalouser/src/accounts.test-mocks.js"; +import { zalouserPlugin } from "../../../../extensions/zalouser/src/channel.js"; +import { setZalouserRuntime } from "../../../../extensions/zalouser/src/runtime.js"; +import { sendMessageZalouser } from "../../../../extensions/zalouser/src/send.js"; +import { slackOutbound } from "../../../../test/channel-outbounds.js"; +import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { createDirectTextMediaOutbound } from "../outbound/direct-text-media.js"; +import { + installChannelOutboundPayloadContractSuite, + primeChannelOutboundSendMock, +} from "./suites.js"; + +vi.mock("../../../../extensions/zalo/src/send.js", () => ({ + sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }), +})); + +vi.mock("../../../../extensions/zalouser/src/send.js", () => ({ + sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }), + sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }), +})); + +type PayloadHarnessParams = { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}; + +const mockedSendZalo = vi.mocked(sendMessageZalo); +const mockedSendZalouser = vi.mocked(sendMessageZalouser); + +function createSlackHarness(params: PayloadHarnessParams) { + const sendSlack = vi.fn(); + primeChannelOutboundSendMock( + sendSlack, + { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "C12345", + text: "", + payload: params.payload, + deps: { + sendSlack, + }, + }; + return { + run: async () => await slackOutbound.sendPayload!(ctx), + sendMock: sendSlack, + to: ctx.to, + }; +} + +function createDiscordHarness(params: PayloadHarnessParams) { + const sendDiscord = vi.fn(); + primeChannelOutboundSendMock( + sendDiscord, + { messageId: "dc-1", channelId: "123456" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "channel:123456", + text: "", + payload: params.payload, + deps: { + sendDiscord, + }, + }; + return { + run: async () => await discordOutbound.sendPayload!(ctx), + sendMock: sendDiscord, + to: ctx.to, + }; +} + +function createWhatsAppHarness(params: PayloadHarnessParams) { + const sendWhatsApp = vi.fn(); + primeChannelOutboundSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults); + const ctx = { + cfg: {}, + to: "5511999999999@c.us", + text: "", + payload: params.payload, + deps: { + sendWhatsApp, + }, + }; + return { + run: async () => await whatsappOutbound.sendPayload!(ctx), + sendMock: sendWhatsApp, + to: ctx.to, + }; +} + +function createDirectTextMediaHarness(params: PayloadHarnessParams) { + const sendFn = vi.fn(); + primeChannelOutboundSendMock(sendFn, { messageId: "m1" }, params.sendResults); + const outbound = createDirectTextMediaOutbound({ + channel: "imessage", + resolveSender: () => sendFn, + resolveMaxBytes: () => undefined, + buildTextOptions: (opts) => opts as never, + buildMediaOptions: (opts) => opts as never, + }); + const ctx = { + cfg: {}, + to: "user1", + text: "", + payload: params.payload, + }; + return { + run: async () => await outbound.sendPayload!(ctx), + sendMock: sendFn, + to: ctx.to, + }; +} + +describe("channel outbound payload contract", () => { + describe("slack", () => { + installChannelOutboundPayloadContractSuite({ + channel: "slack", + chunking: { mode: "passthrough", longTextLength: 5000 }, + createHarness: createSlackHarness, + }); + }); + + describe("discord", () => { + installChannelOutboundPayloadContractSuite({ + channel: "discord", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness: createDiscordHarness, + }); + }); + + describe("whatsapp", () => { + installChannelOutboundPayloadContractSuite({ + channel: "whatsapp", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: createWhatsAppHarness, + }); + }); + + describe("zalo", () => { + installChannelOutboundPayloadContractSuite({ + channel: "zalo", + chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + createHarness: ({ payload, sendResults }) => { + primeChannelOutboundSendMock(mockedSendZalo, { ok: true, messageId: "zl-1" }, sendResults); + return { + run: async () => + await zaloPlugin.outbound!.sendPayload!({ + cfg: {}, + to: "123456789", + text: "", + payload, + }), + sendMock: mockedSendZalo, + to: "123456789", + }; + }, + }); + }); + + describe("zalouser", () => { + installChannelOutboundPayloadContractSuite({ + channel: "zalouser", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness: ({ payload, sendResults }) => { + setZalouserRuntime({ + channel: { + text: { + resolveChunkMode: vi.fn(() => "length"), + resolveTextChunkLimit: vi.fn(() => 1200), + }, + }, + } as never); + primeChannelOutboundSendMock( + mockedSendZalouser, + { ok: true, messageId: "zlu-1" }, + sendResults, + ); + return { + run: async () => + await zalouserPlugin.outbound!.sendPayload!({ + cfg: {}, + to: "user:987654321", + text: "", + payload, + }), + sendMock: mockedSendZalouser, + to: "987654321", + }; + }, + }); + }); + + describe("direct-text-media", () => { + installChannelOutboundPayloadContractSuite({ + channel: "imessage", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: createDirectTextMediaHarness, + }); + }); +}); diff --git a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts deleted file mode 100644 index 42971f1e89c..00000000000 --- a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, vi } from "vitest"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../test-utils/send-payload-contract.js"; -import { createDirectTextMediaOutbound } from "./direct-text-media.js"; - -function createDirectHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendFn = vi.fn(); - primeSendMock(sendFn, { messageId: "m1" }, params.sendResults); - const outbound = createDirectTextMediaOutbound({ - channel: "imessage", - resolveSender: () => sendFn, - resolveMaxBytes: () => undefined, - buildTextOptions: (opts) => opts as never, - buildMediaOptions: (opts) => opts as never, - }); - return { outbound, sendFn }; -} - -function baseCtx(payload: ReplyPayload) { - return { - cfg: {}, - to: "user1", - text: "", - payload, - }; -} - -describe("createDirectTextMediaOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "imessage", - chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, - createHarness: ({ payload, sendResults }) => { - const { outbound, sendFn } = createDirectHarness({ payload, sendResults }); - return { - run: async () => await outbound.sendPayload!(baseCtx(payload)), - sendMock: sendFn, - to: "user1", - }; - }, - }); -}); diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index e1175023858..a78916c1336 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,17 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { slackOutbound } from "../../../../test/channel-outbounds.js"; import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../test-utils/send-payload-contract.js"; +import { primeChannelOutboundSendMock } from "../contracts/suites.js"; function createHarness(params: { payload: ReplyPayload; sendResults?: Array<{ messageId: string }>; }) { const sendSlack = vi.fn(); - primeSendMock( + primeChannelOutboundSendMock( sendSlack, { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, params.sendResults, @@ -33,12 +30,6 @@ function createHarness(params: { } describe("slackOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "slack", - chunking: { mode: "passthrough", longTextLength: 5000 }, - createHarness, - }); - it("forwards Slack blocks from channelData", async () => { const { run, sendMock, to } = createHarness({ payload: { diff --git a/src/test-utils/send-payload-contract.ts b/src/test-utils/send-payload-contract.ts deleted file mode 100644 index 5e78e406a74..00000000000 --- a/src/test-utils/send-payload-contract.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { expect, it, type Mock } from "vitest"; - -type PayloadLike = { - mediaUrl?: string; - mediaUrls?: string[]; - text?: string; -}; - -type SendResultLike = { - messageId: string; - [key: string]: unknown; -}; - -type ChunkingMode = - | { - longTextLength: number; - maxChunkLength: number; - mode: "split"; - } - | { - longTextLength: number; - mode: "passthrough"; - }; - -export function installSendPayloadContractSuite(params: { - channel: string; - chunking: ChunkingMode; - createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) => { - run: () => Promise>; - sendMock: Mock; - to: string; - }; -}) { - it("text-only delegates to sendText", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { text: "hello" }, - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: params.channel }); - }); - - it("single media delegates to sendMedia", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" }, - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith( - to, - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: params.channel }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }, - sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }], - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(2); - expect(sendMock).toHaveBeenNthCalledWith( - 1, - to, - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendMock).toHaveBeenNthCalledWith( - 2, - to, - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" }); - }); - - it("empty payload returns no-op", async () => { - const { run, sendMock } = params.createHarness({ payload: {} }); - const result = await run(); - - expect(sendMock).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: params.channel, messageId: "" }); - }); - - if (params.chunking.mode === "passthrough") { - it("text exceeding chunk limit is sent as-is when chunker is null", async () => { - const text = "a".repeat(params.chunking.longTextLength); - const { run, sendMock, to } = params.createHarness({ payload: { text } }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object)); - expect(result).toMatchObject({ channel: params.channel }); - }); - return; - } - - const chunking = params.chunking; - - it("chunking splits long text", async () => { - const text = "a".repeat(chunking.longTextLength); - const { run, sendMock } = params.createHarness({ - payload: { text }, - sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }], - }); - const result = await run(); - - expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of sendMock.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength); - } - expect(result).toMatchObject({ channel: params.channel }); - }); -} - -export function primeSendMock( - sendMock: Mock, - fallbackResult: Record, - sendResults: SendResultLike[] = [], -) { - sendMock.mockReset(); - if (sendResults.length === 0) { - sendMock.mockResolvedValue(fallbackResult); - return; - } - for (const result of sendResults) { - sendMock.mockResolvedValueOnce(result); - } -} From 79a8905fa468933a5fbdc524d7faf1d45645ca35 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:32:33 -0700 Subject: [PATCH 24/56] Channels: centralize group policy contracts --- .../src/monitor/provider.group-policy.test.ts | 22 ----- .../src/monitor/provider.group-policy.test.ts | 13 --- .../src/monitor/provider.group-policy.test.ts | 13 --- .../src/group-access.group-policy.test.ts | 13 --- .../access-control.group-policy.test.ts | 13 --- .../zalo/src/monitor.group-policy.test.ts | 12 --- .../contracts/group-policy.contract.test.ts | 94 +++++++++++++++++++ .../runtime-group-policy-contract.ts | 43 --------- 8 files changed, 94 insertions(+), 129 deletions(-) delete mode 100644 extensions/discord/src/monitor/provider.group-policy.test.ts delete mode 100644 extensions/imessage/src/monitor/provider.group-policy.test.ts delete mode 100644 extensions/slack/src/monitor/provider.group-policy.test.ts delete mode 100644 extensions/telegram/src/group-access.group-policy.test.ts delete mode 100644 extensions/whatsapp/src/inbound/access-control.group-policy.test.ts create mode 100644 src/channels/plugins/contracts/group-policy.contract.test.ts delete mode 100644 src/test-utils/runtime-group-policy-contract.ts diff --git a/extensions/discord/src/monitor/provider.group-policy.test.ts b/extensions/discord/src/monitor/provider.group-policy.test.ts deleted file mode 100644 index 995c6f66e31..00000000000 --- a/extensions/discord/src/monitor/provider.group-policy.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; -import { __testing } from "./provider.js"; - -describe("resolveDiscordRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: __testing.resolveDiscordRuntimeGroupPolicy, - configuredLabel: "keeps open default when channels.discord is configured", - defaultGroupPolicyUnderTest: "open", - missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); - - it("respects explicit provider policy", () => { - const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ - providerConfigPresent: false, - groupPolicy: "disabled", - }); - expect(resolved.groupPolicy).toBe("disabled"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); -}); diff --git a/extensions/imessage/src/monitor/provider.group-policy.test.ts b/extensions/imessage/src/monitor/provider.group-policy.test.ts deleted file mode 100644 index d6a7b1f880b..00000000000 --- a/extensions/imessage/src/monitor/provider.group-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; -import { __testing } from "./monitor-provider.js"; - -describe("resolveIMessageRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: __testing.resolveIMessageRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.imessage is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); -}); diff --git a/extensions/slack/src/monitor/provider.group-policy.test.ts b/extensions/slack/src/monitor/provider.group-policy.test.ts deleted file mode 100644 index 392003ad5f5..00000000000 --- a/extensions/slack/src/monitor/provider.group-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; -import { __testing } from "./provider.js"; - -describe("resolveSlackRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: __testing.resolveSlackRuntimeGroupPolicy, - configuredLabel: "keeps open default when channels.slack is configured", - defaultGroupPolicyUnderTest: "open", - missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); -}); diff --git a/extensions/telegram/src/group-access.group-policy.test.ts b/extensions/telegram/src/group-access.group-policy.test.ts deleted file mode 100644 index 8b93c52d160..00000000000 --- a/extensions/telegram/src/group-access.group-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../src/test-utils/runtime-group-policy-contract.js"; -import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js"; - -describe("resolveTelegramRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: resolveTelegramRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.telegram is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set", - missingDefaultLabel: "ignores explicit defaults when provider config is missing", - }); -}); diff --git a/extensions/whatsapp/src/inbound/access-control.group-policy.test.ts b/extensions/whatsapp/src/inbound/access-control.group-policy.test.ts deleted file mode 100644 index 0a508f9739b..00000000000 --- a/extensions/whatsapp/src/inbound/access-control.group-policy.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe } from "vitest"; -import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js"; -import { __testing } from "./access-control.js"; - -describe("resolveWhatsAppRuntimeGroupPolicy", () => { - installProviderRuntimeGroupPolicyFallbackSuite({ - resolve: __testing.resolveWhatsAppRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.whatsapp is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set", - missingDefaultLabel: "ignores explicit default policy when provider config is missing", - }); -}); diff --git a/extensions/zalo/src/monitor.group-policy.test.ts b/extensions/zalo/src/monitor.group-policy.test.ts index 2ce0b1be2a2..7a44caab83d 100644 --- a/extensions/zalo/src/monitor.group-policy.test.ts +++ b/extensions/zalo/src/monitor.group-policy.test.ts @@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest"; import { __testing } from "./monitor.js"; describe("zalo group policy access", () => { - it("defaults missing provider config to allowlist", () => { - const resolved = __testing.resolveZaloRuntimeGroupPolicy({ - providerConfigPresent: false, - groupPolicy: undefined, - defaultGroupPolicy: "open", - }); - expect(resolved).toEqual({ - groupPolicy: "allowlist", - providerMissingFallbackApplied: true, - }); - }); - it("blocks all group messages when policy is disabled", () => { const decision = __testing.evaluateZaloGroupAccess({ providerConfigPresent: true, diff --git a/src/channels/plugins/contracts/group-policy.contract.test.ts b/src/channels/plugins/contracts/group-policy.contract.test.ts new file mode 100644 index 00000000000..51a9c178170 --- /dev/null +++ b/src/channels/plugins/contracts/group-policy.contract.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { __testing as discordTesting } from "../../../../extensions/discord/src/monitor/provider.js"; +import { __testing as imessageTesting } from "../../../../extensions/imessage/src/monitor/monitor-provider.js"; +import { __testing as slackTesting } from "../../../../extensions/slack/src/monitor/provider.js"; +import { resolveTelegramRuntimeGroupPolicy } from "../../../../extensions/telegram/src/group-access.js"; +import { __testing as whatsappTesting } from "../../../../extensions/whatsapp/src/inbound/access-control.js"; +import { __testing as zaloTesting } from "../../../../extensions/zalo/src/monitor.js"; +import { installChannelRuntimeGroupPolicyFallbackSuite } from "./suites.js"; + +describe("channel runtime group policy contract", () => { + describe("slack", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: slackTesting.resolveSlackRuntimeGroupPolicy, + configuredLabel: "keeps open default when channels.slack is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + }); + + describe("telegram", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: resolveTelegramRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.telegram is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set", + missingDefaultLabel: "ignores explicit defaults when provider config is missing", + }); + }); + + describe("whatsapp", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: whatsappTesting.resolveWhatsAppRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.whatsapp is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + }); + + describe("imessage", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: imessageTesting.resolveIMessageRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.imessage is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + }); + + describe("discord", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: discordTesting.resolveDiscordRuntimeGroupPolicy, + configuredLabel: "keeps open default when channels.discord is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + + it("respects explicit provider policy", () => { + const resolved = discordTesting.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: "disabled", + }); + expect(resolved.groupPolicy).toBe("disabled"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); + }); + + describe("zalo", () => { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: zaloTesting.resolveZaloRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.zalo is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.zalo is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + + it("keeps provider-owned group access evaluation", () => { + const decision = zaloTesting.evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zl:12345"], + senderId: "12345", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); + }); +}); diff --git a/src/test-utils/runtime-group-policy-contract.ts b/src/test-utils/runtime-group-policy-contract.ts deleted file mode 100644 index 65a0e0b8ef3..00000000000 --- a/src/test-utils/runtime-group-policy-contract.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, it } from "vitest"; -import type { - ResolveProviderRuntimeGroupPolicyParams, - RuntimeGroupPolicyResolution, -} from "../config/runtime-group-policy.js"; -import type { GroupPolicy } from "../config/types.base.js"; - -type RuntimeGroupPolicyResolver = ( - params: ResolveProviderRuntimeGroupPolicyParams, -) => RuntimeGroupPolicyResolution; - -export function installProviderRuntimeGroupPolicyFallbackSuite(params: { - configuredLabel: string; - defaultGroupPolicyUnderTest: GroupPolicy; - missingConfigLabel: string; - missingDefaultLabel: string; - resolve: RuntimeGroupPolicyResolver; -}) { - it(params.missingConfigLabel, () => { - const resolved = params.resolve({ - providerConfigPresent: false, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); - - it(params.configuredLabel, () => { - const resolved = params.resolve({ - providerConfigPresent: true, - }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); - - it(params.missingDefaultLabel, () => { - const resolved = params.resolve({ - providerConfigPresent: false, - defaultGroupPolicy: params.defaultGroupPolicyUnderTest, - }); - expect(resolved.groupPolicy).toBe("allowlist"); - expect(resolved.providerMissingFallbackApplied).toBe(true); - }); -} From 70aa9204c0f55bc722d678d40d238c29cbc7b3d2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:32:47 -0700 Subject: [PATCH 25/56] Channels: centralize inbound context contracts --- .../message-handler.inbound-contract.test.ts | 2 +- .../event-handler.inbound-contract.test.ts | 2 +- .../monitor/message-handler/prepare.test.ts | 2 +- extensions/telegram/src/bot.test.ts | 2 +- .../process-message.inbound-contract.test.ts | 2 +- src/auto-reply/reply/reply-flow.test.ts | 2 +- .../inbound.discord.contract.test.ts | 24 +++++ .../contracts/inbound.signal.contract.test.ts | 73 ++++++++++++++ .../contracts/inbound.slack.contract.test.ts | 54 +++++++++++ .../inbound.telegram.contract.test.ts | 60 ++++++++++++ .../inbound.whatsapp.contract.test.ts | 97 +++++++++++++++++++ test/helpers/inbound-contract.ts | 19 ---- 12 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 src/channels/plugins/contracts/inbound.discord.contract.test.ts create mode 100644 src/channels/plugins/contracts/inbound.signal.contract.test.ts create mode 100644 src/channels/plugins/contracts/inbound.slack.contract.test.ts create mode 100644 src/channels/plugins/contracts/inbound.telegram.contract.test.ts create mode 100644 src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts delete mode 100644 test/helpers/inbound-contract.ts diff --git a/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts b/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts index 97d18985460..6421d24a61a 100644 --- a/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts +++ b/extensions/discord/src/monitor/message-handler.inbound-contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js"; import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; -import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; import { processDiscordMessage } from "./message-handler.process.js"; import { diff --git a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts index 62593156756..9a6cfc0e90e 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MsgContext } from "../../../../src/auto-reply/templating.js"; -import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js"; import { createSignalEventHandler } from "./event-handler.js"; import { createBaseSignalEventHandlerDeps, diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index a6858e529af..a57614afaeb 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -3,10 +3,10 @@ import os from "node:os"; import path from "node:path"; import type { App } from "@slack/bolt"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js"; import type { OpenClawConfig } from "../../../../../src/config/config.js"; import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js"; -import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 9468f64c789..17f6870a964 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,12 +1,12 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../src/channels/plugins/contracts/suites.js"; import { clearPluginInteractiveHandlers, registerPluginInteractiveHandler, } from "../../../src/plugins/interactive.js"; import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { answerCallbackQuerySpy, commandSpy, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts index 238c675e12d..566c8a76e1e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js"; let capturedCtx: unknown; let capturedDispatchParams: unknown; diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index d9e985c8b31..21a22faf8b2 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../../test/helpers/import-fresh.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "../../channels/plugins/contracts/suites.js"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import type { MsgContext } from "../templating.js"; diff --git a/src/channels/plugins/contracts/inbound.discord.contract.test.ts b/src/channels/plugins/contracts/inbound.discord.contract.test.ts new file mode 100644 index 00000000000..6b168f7d244 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.discord.contract.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { inboundCtxCapture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const { processDiscordMessage } = + await import("../../../../extensions/discord/src/monitor/message-handler.process.js"); +const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } = + await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js"); + +describe("discord inbound contract", () => { + it("keeps inbound context finalized", async () => { + inboundCtxCapture.ctx = undefined; + const messageCtx = await createBaseDiscordMessageContext({ + cfg: { messages: {} }, + ackReactionScope: "direct", + ...createDiscordDirectMessageContextOverrides(), + }); + + await processDiscordMessage(messageCtx); + + expect(inboundCtxCapture.ctx).toBeTruthy(); + expectChannelInboundContextContract(inboundCtxCapture.ctx!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.signal.contract.test.ts b/src/channels/plugins/contracts/inbound.signal.contract.test.ts new file mode 100644 index 00000000000..abec31c0174 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.signal.contract.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createSignalEventHandler } from "../../../../extensions/signal/src/monitor/event-handler.js"; +import { + createBaseSignalEventHandlerDeps, + createSignalReceiveEvent, +} from "../../../../extensions/signal/src/monitor/event-handler.test-harness.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const capture = vi.hoisted(() => ({ ctx: undefined as MsgContext | undefined })); +const dispatchInboundMessageMock = vi.hoisted(() => + vi.fn( + async (params: { + ctx: MsgContext; + replyOptions?: { onReplyStart?: () => void | Promise }; + }) => { + capture.ctx = params.ctx; + await Promise.resolve(params.replyOptions?.onReplyStart?.()); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }, + ), +); + +vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: dispatchInboundMessageMock, + dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock, + }; +}); + +vi.mock("../../../../extensions/signal/src/send.js", () => ({ + sendMessageSignal: vi.fn(), + sendTypingSignal: vi.fn(async () => true), + sendReadReceiptSignal: vi.fn(async () => true), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: vi.fn().mockResolvedValue([]), + upsertChannelPairingRequest: vi.fn(), +})); + +describe("signal inbound contract", () => { + beforeEach(() => { + capture.ctx = undefined; + dispatchInboundMessageMock.mockClear(); + }); + + it("keeps inbound context finalized", async () => { + const handler = createSignalEventHandler( + createBaseSignalEventHandlerDeps({ + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: { inbound: { debounceMs: 0 } } } as any, + historyLimit: 0, + }), + ); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + message: "hi", + attachments: [], + groupInfo: { groupId: "g1", groupName: "Test Group" }, + }, + }), + ); + + expect(capture.ctx).toBeTruthy(); + expectChannelInboundContextContract(capture.ctx!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.slack.contract.test.ts b/src/channels/plugins/contracts/inbound.slack.contract.test.ts new file mode 100644 index 00000000000..e013bed3b4f --- /dev/null +++ b/src/channels/plugins/contracts/inbound.slack.contract.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; +import { prepareSlackMessage } from "../../../../extensions/slack/src/monitor/message-handler/prepare.js"; +import { createInboundSlackTestContext } from "../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js"; +import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { + return { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + userTokenSource: "none", + config, + replyToMode: config.replyToMode, + replyToModeByChatType: config.replyToModeByChatType, + dm: config.dm, + }; +} + +function createSlackMessage(overrides: Partial): SlackMessageEvent { + return { + channel: "D123", + channel_type: "im", + user: "U1", + text: "hi", + ts: "1.000", + ...overrides, + } as SlackMessageEvent; +} + +describe("slack inbound contract", () => { + it("keeps inbound context finalized", async () => { + const ctx = createInboundSlackTestContext({ + cfg: { + channels: { slack: { enabled: true } }, + } as OpenClawConfig, + }); + // oxlint-disable-next-line typescript/no-explicit-any + ctx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const prepared = await prepareSlackMessage({ + ctx, + account: createSlackAccount(), + message: createSlackMessage({}), + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + expectChannelInboundContextContract(prepared!.ctxPayload); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts new file mode 100644 index 00000000000..a872964bd53 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + getLoadConfigMock, + getOnHandler, + onSpy, + replySpy, +} from "../../../../extensions/telegram/src/bot.create-telegram-bot.test-harness.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const { createTelegramBot } = await import("../../../../extensions/telegram/src/bot.js"); + +describe("telegram inbound contract", () => { + const loadConfig = getLoadConfigMock(); + + beforeEach(() => { + onSpy.mockClear(); + replySpy.mockClear(); + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + } satisfies OpenClawConfig); + }); + + it("keeps inbound context finalized", async () => { + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + const payload = replySpy.mock.calls[0]?.[0] as MsgContext | undefined; + expect(payload).toBeTruthy(); + expectChannelInboundContextContract(payload!); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts new file mode 100644 index 00000000000..c36c2d50fc8 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { processMessage } from "../../../../extensions/whatsapp/src/auto-reply/monitor/process-message.js"; +import type { MsgContext } from "../../../auto-reply/templating.js"; +import { expectChannelInboundContextContract } from "./suites.js"; + +const capture = vi.hoisted(() => ({ + ctx: undefined as MsgContext | undefined, +})); + +vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: MsgContext }) => { + capture.ctx = params.ctx; + return { queuedFinal: false }; + }), +})); + +vi.mock("../../../../extensions/whatsapp/src/auto-reply/monitor/last-route.js", () => ({ + trackBackgroundTask: (tasks: Set>, task: Promise) => { + tasks.add(task); + void task.finally(() => { + tasks.delete(task); + }); + }, + updateLastRouteInBackground: vi.fn(), +})); + +vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => ({ + deliverWebReply: vi.fn(async () => {}), +})); + +function makeProcessArgs(sessionStorePath: string) { + return { + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: {}, session: { store: sessionStorePath } } as any, + // oxlint-disable-next-line typescript/no-explicit-any + msg: { + id: "msg1", + from: "123@g.us", + to: "+15550001111", + chatType: "group", + body: "hi", + senderName: "Alice", + senderJid: "alice@s.whatsapp.net", + senderE164: "+15550002222", + groupSubject: "Test Group", + groupParticipants: [], + } as unknown as Record, + route: { + agentId: "main", + accountId: "default", + sessionKey: "agent:main:whatsapp:group:123", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + groupHistoryKey: "123@g.us", + groupHistories: new Map(), + groupMemberNames: new Map(), + connectionId: "conn", + verbose: false, + maxMediaBytes: 1, + // oxlint-disable-next-line typescript/no-explicit-any + replyResolver: (async () => undefined) as any, + // oxlint-disable-next-line typescript/no-explicit-any + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, + backgroundTasks: new Set>(), + rememberSentText: () => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: () => "echo", + groupHistory: [], + // oxlint-disable-next-line typescript/no-explicit-any + } as any; +} + +describe("whatsapp inbound contract", () => { + let sessionDir = ""; + + afterEach(async () => { + capture.ctx = undefined; + if (sessionDir) { + await fs.rm(sessionDir, { recursive: true, force: true }); + sessionDir = ""; + } + }); + + it("keeps inbound context finalized", async () => { + sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-contract-")); + const sessionStorePath = path.join(sessionDir, "sessions.json"); + + await processMessage(makeProcessArgs(sessionStorePath)); + + expect(capture.ctx).toBeTruthy(); + expectChannelInboundContextContract(capture.ctx!); + }); +}); diff --git a/test/helpers/inbound-contract.ts b/test/helpers/inbound-contract.ts deleted file mode 100644 index 4ac4c2cc516..00000000000 --- a/test/helpers/inbound-contract.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect } from "vitest"; -import type { MsgContext } from "../../src/auto-reply/templating.js"; -import { normalizeChatType } from "../../src/channels/chat-type.js"; -import { resolveConversationLabel } from "../../src/channels/conversation-label.js"; -import { validateSenderIdentity } from "../../src/channels/sender-identity.js"; - -export function expectInboundContextContract(ctx: MsgContext) { - expect(validateSenderIdentity(ctx)).toEqual([]); - - expect(ctx.Body).toBeTypeOf("string"); - expect(ctx.BodyForAgent).toBeTypeOf("string"); - expect(ctx.BodyForCommands).toBeTypeOf("string"); - - const chatType = normalizeChatType(ctx.ChatType); - if (chatType && chatType !== "direct") { - const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx); - expect(label).toBeTruthy(); - } -} From a8970963cde5a1d3ce5f27906fe704ef39dfdc2d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:33:11 -0700 Subject: [PATCH 26/56] Tests: add contract runner --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index f85bdb6e463..d9fd801eae1 100644 --- a/package.json +++ b/package.json @@ -311,6 +311,7 @@ "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts", "test:channels": "vitest run --config vitest.channels.config.ts", + "test:contracts": "pnpm test -- src/channels/plugins/contracts src/plugins/contracts", "test:coverage": "vitest run --config vitest.unit.config.ts --coverage", "test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", From 65f05d7c098bcf349f9498208088a3045276a9d3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:37:38 -0700 Subject: [PATCH 27/56] Tests: harden WhatsApp inbound contract cleanup --- .../contracts/inbound.whatsapp.contract.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts index c36c2d50fc8..108131226aa 100644 --- a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts +++ b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts @@ -74,13 +74,27 @@ function makeProcessArgs(sessionStorePath: string) { } as any; } +async function removeDirEventually(dir: string) { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await fs.rm(dir, { recursive: true, force: true }); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOTEMPTY" || attempt === 2) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } +} + describe("whatsapp inbound contract", () => { let sessionDir = ""; afterEach(async () => { capture.ctx = undefined; if (sessionDir) { - await fs.rm(sessionDir, { recursive: true, force: true }); + await removeDirEventually(sessionDir); sessionDir = ""; } }); From d572188f61ba6bf304c59fec19a1f165e742bd6d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:47:52 -0700 Subject: [PATCH 28/56] Tests: add extension test runner --- package.json | 1 + scripts/test-extension.mjs | 185 ++++++++++++++++++++++++++++ test/scripts/test-extension.test.ts | 50 ++++++++ 3 files changed, 236 insertions(+) create mode 100644 scripts/test-extension.mjs create mode 100644 test/scripts/test-extension.test.ts diff --git a/package.json b/package.json index d9fd801eae1..00412359bf5 100644 --- a/package.json +++ b/package.json @@ -324,6 +324,7 @@ "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts", + "test:extension": "node scripts/test-extension.mjs", "test:extensions": "vitest run --config vitest.extensions.config.ts", "test:fast": "vitest run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", diff --git a/scripts/test-extension.mjs b/scripts/test-extension.mjs new file mode 100644 index 00000000000..bcc6aa30200 --- /dev/null +++ b/scripts/test-extension.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { channelTestRoots } from "../vitest.channel-paths.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const pnpm = "pnpm"; + +function normalizeRelative(inputPath) { + return inputPath.split(path.sep).join("/"); +} + +function isTestFile(filePath) { + return filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx"); +} + +function collectTestFiles(rootPath) { + const results = []; + const stack = [rootPath]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current || !fs.existsSync(current)) { + continue; + } + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") { + continue; + } + stack.push(fullPath); + continue; + } + if (entry.isFile() && isTestFile(fullPath)) { + results.push(fullPath); + } + } + } + + return results.toSorted((left, right) => left.localeCompare(right)); +} + +function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { + if (targetArg) { + const asGiven = path.resolve(cwd, targetArg); + if (fs.existsSync(path.join(asGiven, "package.json"))) { + return asGiven; + } + + const byName = path.join(repoRoot, "extensions", targetArg); + if (fs.existsSync(path.join(byName, "package.json"))) { + return byName; + } + + throw new Error( + `Unknown extension target "${targetArg}". Use an extension name like "slack" or a path under extensions/.`, + ); + } + + let current = cwd; + while (true) { + if ( + normalizeRelative(path.relative(repoRoot, current)).startsWith("extensions/") && + fs.existsSync(path.join(current, "package.json")) + ) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + throw new Error( + "No extension target provided, and current working directory is not inside extensions/.", + ); +} + +export function resolveExtensionTestPlan(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const targetArg = params.targetArg; + const extensionDir = resolveExtensionDirectory(targetArg, cwd); + const extensionId = path.basename(extensionDir); + const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir)); + + const roots = [relativeExtensionDir]; + const pairedCoreRoot = path.join(repoRoot, "src", extensionId); + if (fs.existsSync(pairedCoreRoot)) { + const pairedRelativeRoot = normalizeRelative(path.relative(repoRoot, pairedCoreRoot)); + if (collectTestFiles(pairedCoreRoot).length > 0) { + roots.push(pairedRelativeRoot); + } + } + + const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root)); + const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts"; + const testFiles = roots.flatMap((root) => collectTestFiles(path.join(repoRoot, root))); + + return { + config, + extensionDir: relativeExtensionDir, + extensionId, + roots, + testFiles: testFiles.map((filePath) => normalizeRelative(path.relative(repoRoot, filePath))), + }; +} + +function printUsage() { + console.error("Usage: pnpm test:extension [vitest args...]"); + console.error(" node scripts/test-extension.mjs [extension-name|path] [vitest args...]"); +} + +async function run() { + const rawArgs = process.argv.slice(2); + const dryRun = rawArgs.includes("--dry-run"); + const json = rawArgs.includes("--json"); + const args = rawArgs.filter((arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json"); + + let targetArg; + if (args[0] && !args[0].startsWith("-")) { + targetArg = args.shift(); + } + + let plan; + try { + plan = resolveExtensionTestPlan({ cwd: process.cwd(), targetArg }); + } catch (error) { + printUsage(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + if (plan.testFiles.length === 0) { + console.error(`No tests found for ${plan.extensionDir}.`); + process.exit(1); + } + + if (dryRun) { + if (json) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + } else { + console.log(`[test-extension] ${plan.extensionId}`); + console.log(`config: ${plan.config}`); + console.log(`roots: ${plan.roots.join(", ")}`); + console.log(`tests: ${plan.testFiles.length}`); + } + return; + } + + console.log( + `[test-extension] Running ${plan.testFiles.length} test files for ${plan.extensionId} with ${plan.config}`, + ); + + const child = spawn( + pnpm, + ["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...args], + { + cwd: repoRoot, + stdio: "inherit", + shell: process.platform === "win32", + env: process.env, + }, + ); + + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); + }); +} + +const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; + +if (import.meta.url === entryHref) { + await run(); +} diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts new file mode 100644 index 00000000000..1ab4a68deb8 --- /dev/null +++ b/test/scripts/test-extension.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveExtensionTestPlan } from "../../scripts/test-extension.mjs"; + +const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); + +function readPlan(args: string[], cwd = process.cwd()) { + const stdout = execFileSync(process.execPath, [scriptPath, ...args, "--dry-run", "--json"], { + cwd, + encoding: "utf8", + }); + return JSON.parse(stdout) as ReturnType; +} + +describe("scripts/test-extension.mjs", () => { + it("resolves channel-root extensions onto the channel vitest config", () => { + const plan = resolveExtensionTestPlan({ targetArg: "slack", cwd: process.cwd() }); + + expect(plan.extensionId).toBe("slack"); + expect(plan.extensionDir).toBe("extensions/slack"); + expect(plan.config).toBe("vitest.channels.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("extensions/slack/"))).toBe(true); + }); + + it("resolves provider extensions onto the extensions vitest config", () => { + const plan = resolveExtensionTestPlan({ targetArg: "firecrawl", cwd: process.cwd() }); + + expect(plan.extensionId).toBe("firecrawl"); + expect(plan.config).toBe("vitest.extensions.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("extensions/firecrawl/"))).toBe(true); + }); + + it("includes paired src roots when they contain tests", () => { + const plan = resolveExtensionTestPlan({ targetArg: "line", cwd: process.cwd() }); + + expect(plan.roots).toContain("extensions/line"); + expect(plan.roots).toContain("src/line"); + expect(plan.config).toBe("vitest.channels.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("src/line/"))).toBe(true); + }); + + it("infers the extension from the current working directory", () => { + const cwd = path.join(process.cwd(), "extensions", "slack"); + const plan = readPlan([], cwd); + + expect(plan.extensionId).toBe("slack"); + expect(plan.extensionDir).toBe("extensions/slack"); + }); +}); From abb21d91637b86907d8c7edfbb883ae3470c028f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:05:10 -0700 Subject: [PATCH 29/56] Runtime: lazy-load Discord channel ops --- .../runtime/runtime-discord-ops.runtime.ts | 21 +++ src/plugins/runtime/runtime-discord.ts | 169 ++++++++++++++---- 2 files changed, 151 insertions(+), 39 deletions(-) create mode 100644 src/plugins/runtime/runtime-discord-ops.runtime.ts diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts new file mode 100644 index 00000000000..d10daac5a35 --- /dev/null +++ b/src/plugins/runtime/runtime-discord-ops.runtime.ts @@ -0,0 +1,21 @@ +export { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js"; +export { + listDiscordDirectoryGroupsLive, + listDiscordDirectoryPeersLive, +} from "../../../extensions/discord/src/directory-live.js"; +export { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.js"; +export { probeDiscord } from "../../../extensions/discord/src/probe.js"; +export { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js"; +export { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; +export { + createThreadDiscord, + deleteMessageDiscord, + editChannelDiscord, + editMessageDiscord, + pinMessageDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendTypingDiscord, + unpinMessageDiscord, +} from "../../../extensions/discord/src/send.js"; diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 6aadba32a9a..ae302ad0e5f 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,10 +1,4 @@ -import { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js"; import { discordMessageActions } from "../../../extensions/discord/src/channel-actions.js"; -import { - listDiscordDirectoryGroupsLive, - listDiscordDirectoryPeersLive, -} from "../../../extensions/discord/src/directory-live.js"; -import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.js"; import { getThreadBindingManager, resolveThreadBindingIdleTimeoutMs, @@ -15,37 +9,134 @@ import { setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, } from "../../../extensions/discord/src/monitor/thread-bindings.js"; -import { probeDiscord } from "../../../extensions/discord/src/probe.js"; -import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js"; -import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; -import { - createThreadDiscord, - deleteMessageDiscord, - editChannelDiscord, - editMessageDiscord, - pinMessageDiscord, - sendDiscordComponentMessage, - sendMessageDiscord, - sendPollDiscord, - sendTypingDiscord, - unpinMessageDiscord, -} from "../../../extensions/discord/src/send.js"; import { createDiscordTypingLease } from "./runtime-discord-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; +let runtimeDiscordOpsPromise: Promise | null = + null; + +function loadRuntimeDiscordOps() { + runtimeDiscordOpsPromise ??= import("./runtime-discord-ops.runtime.js"); + return runtimeDiscordOpsPromise; +} + +const auditChannelPermissionsLazy: PluginRuntimeChannel["discord"]["auditChannelPermissions"] = + async (...args) => { + const { auditDiscordChannelPermissions } = await loadRuntimeDiscordOps(); + return auditDiscordChannelPermissions(...args); + }; + +const listDirectoryGroupsLiveLazy: PluginRuntimeChannel["discord"]["listDirectoryGroupsLive"] = + async (...args) => { + const { listDiscordDirectoryGroupsLive } = await loadRuntimeDiscordOps(); + return listDiscordDirectoryGroupsLive(...args); + }; + +const listDirectoryPeersLiveLazy: PluginRuntimeChannel["discord"]["listDirectoryPeersLive"] = + async (...args) => { + const { listDiscordDirectoryPeersLive } = await loadRuntimeDiscordOps(); + return listDiscordDirectoryPeersLive(...args); + }; + +const probeDiscordLazy: PluginRuntimeChannel["discord"]["probeDiscord"] = async (...args) => { + const { probeDiscord } = await loadRuntimeDiscordOps(); + return probeDiscord(...args); +}; + +const resolveChannelAllowlistLazy: PluginRuntimeChannel["discord"]["resolveChannelAllowlist"] = + async (...args) => { + const { resolveDiscordChannelAllowlist } = await loadRuntimeDiscordOps(); + return resolveDiscordChannelAllowlist(...args); + }; + +const resolveUserAllowlistLazy: PluginRuntimeChannel["discord"]["resolveUserAllowlist"] = async ( + ...args +) => { + const { resolveDiscordUserAllowlist } = await loadRuntimeDiscordOps(); + return resolveDiscordUserAllowlist(...args); +}; + +const sendComponentMessageLazy: PluginRuntimeChannel["discord"]["sendComponentMessage"] = async ( + ...args +) => { + const { sendDiscordComponentMessage } = await loadRuntimeDiscordOps(); + return sendDiscordComponentMessage(...args); +}; + +const sendMessageDiscordLazy: PluginRuntimeChannel["discord"]["sendMessageDiscord"] = async ( + ...args +) => { + const { sendMessageDiscord } = await loadRuntimeDiscordOps(); + return sendMessageDiscord(...args); +}; + +const sendPollDiscordLazy: PluginRuntimeChannel["discord"]["sendPollDiscord"] = async (...args) => { + const { sendPollDiscord } = await loadRuntimeDiscordOps(); + return sendPollDiscord(...args); +}; + +const monitorDiscordProviderLazy: PluginRuntimeChannel["discord"]["monitorDiscordProvider"] = + async (...args) => { + const { monitorDiscordProvider } = await loadRuntimeDiscordOps(); + return monitorDiscordProvider(...args); + }; + +const sendTypingDiscordLazy: PluginRuntimeChannel["discord"]["typing"]["pulse"] = async ( + ...args +) => { + const { sendTypingDiscord } = await loadRuntimeDiscordOps(); + return sendTypingDiscord(...args); +}; + +const editMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["editMessage"] = + async (...args) => { + const { editMessageDiscord } = await loadRuntimeDiscordOps(); + return editMessageDiscord(...args); + }; + +const deleteMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["deleteMessage"] = + async (...args) => { + const { deleteMessageDiscord } = await loadRuntimeDiscordOps(); + return deleteMessageDiscord(...args); + }; + +const pinMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["pinMessage"] = + async (...args) => { + const { pinMessageDiscord } = await loadRuntimeDiscordOps(); + return pinMessageDiscord(...args); + }; + +const unpinMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["unpinMessage"] = + async (...args) => { + const { unpinMessageDiscord } = await loadRuntimeDiscordOps(); + return unpinMessageDiscord(...args); + }; + +const createThreadDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["createThread"] = + async (...args) => { + const { createThreadDiscord } = await loadRuntimeDiscordOps(); + return createThreadDiscord(...args); + }; + +const editChannelDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["editChannel"] = + async (...args) => { + const { editChannelDiscord } = await loadRuntimeDiscordOps(); + return editChannelDiscord(...args); + }; + export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { return { messageActions: discordMessageActions, - auditChannelPermissions: auditDiscordChannelPermissions, - listDirectoryGroupsLive: listDiscordDirectoryGroupsLive, - listDirectoryPeersLive: listDiscordDirectoryPeersLive, - probeDiscord, - resolveChannelAllowlist: resolveDiscordChannelAllowlist, - resolveUserAllowlist: resolveDiscordUserAllowlist, - sendComponentMessage: sendDiscordComponentMessage, - sendMessageDiscord, - sendPollDiscord, - monitorDiscordProvider, + auditChannelPermissions: auditChannelPermissionsLazy, + listDirectoryGroupsLive: listDirectoryGroupsLiveLazy, + listDirectoryPeersLive: listDirectoryPeersLiveLazy, + probeDiscord: probeDiscordLazy, + resolveChannelAllowlist: resolveChannelAllowlistLazy, + resolveUserAllowlist: resolveUserAllowlistLazy, + sendComponentMessage: sendComponentMessageLazy, + sendMessageDiscord: sendMessageDiscordLazy, + sendPollDiscord: sendPollDiscordLazy, + monitorDiscordProvider: monitorDiscordProviderLazy, threadBindings: { getManager: getThreadBindingManager, resolveIdleTimeoutMs: resolveThreadBindingIdleTimeoutMs, @@ -57,7 +148,7 @@ export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { unbindBySessionKey: unbindThreadBindingsBySessionKey, }, typing: { - pulse: sendTypingDiscord, + pulse: sendTypingDiscordLazy, start: async ({ channelId, accountId, cfg, intervalMs }) => await createDiscordTypingLease({ channelId, @@ -65,16 +156,16 @@ export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { cfg, intervalMs, pulse: async ({ channelId, accountId, cfg }) => - void (await sendTypingDiscord(channelId, { accountId, cfg })), + void (await sendTypingDiscordLazy(channelId, { accountId, cfg })), }), }, conversationActions: { - editMessage: editMessageDiscord, - deleteMessage: deleteMessageDiscord, - pinMessage: pinMessageDiscord, - unpinMessage: unpinMessageDiscord, - createThread: createThreadDiscord, - editChannel: editChannelDiscord, + editMessage: editMessageDiscordLazy, + deleteMessage: deleteMessageDiscordLazy, + pinMessage: pinMessageDiscordLazy, + unpinMessage: unpinMessageDiscordLazy, + createThread: createThreadDiscordLazy, + editChannel: editChannelDiscordLazy, }, }; } From 3832f938fd0fb813fc8979376dfe788e03d374d9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:06:27 -0700 Subject: [PATCH 30/56] Docs: use placeholders for marketplace plugin examples --- docs/cli/plugins.md | 10 +++++----- docs/plugins/bundles.md | 4 ++-- docs/tools/plugin.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index b867420551f..5e551a9c64f 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -74,16 +74,16 @@ Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's local registry cache at `~/.claude/plugins/known_marketplaces.json`: ```bash -openclaw plugins marketplace list claude-plugins-official -openclaw plugins install superpowers@claude-plugins-official +openclaw plugins marketplace list +openclaw plugins install @ ``` Use `--marketplace` when you want to pass the marketplace source explicitly: ```bash -openclaw plugins install superpowers --marketplace claude-plugins-official -openclaw plugins install superpowers --marketplace anthropics/claude-plugins-official -openclaw plugins install superpowers --marketplace ./my-marketplace +openclaw plugins install --marketplace +openclaw plugins install --marketplace +openclaw plugins install --marketplace ./my-marketplace ``` Marketplace sources can be: diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index c652ff997f0..2fad626ccfe 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -259,8 +259,8 @@ openclaw plugins install ./my-codex-bundle openclaw plugins install ./my-claude-bundle openclaw plugins install ./my-cursor-bundle openclaw plugins install ./my-bundle.tgz -openclaw plugins marketplace list claude-plugins-official -openclaw plugins install superpowers@claude-plugins-official +openclaw plugins marketplace list +openclaw plugins install @ openclaw plugins info my-bundle ``` diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index ff76ac551c9..770eaa215e0 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -61,8 +61,8 @@ For Claude marketplace installs, list the marketplace first, then install by marketplace entry name: ```bash -openclaw plugins marketplace list claude-plugins-official -openclaw plugins install superpowers@claude-plugins-official +openclaw plugins marketplace list +openclaw plugins install @ ``` OpenClaw resolves known Claude marketplace names from From 1447e2e3848915862cab6c7afddf64eddc2b5546 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:09:41 -0700 Subject: [PATCH 31/56] Release: trim generated docs from npm pack --- .npmignore | 1 + docs/.generated/config-baseline.json | 42 +++++++++++++++++++++++++++ docs/.generated/config-baseline.jsonl | 5 +++- package.json | 2 ++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/.npmignore b/.npmignore index 7cd53fdbc08..fcc490ae35d 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,2 @@ **/node_modules/ +docs/.generated/ diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index bf67b685710..c63572a5e7f 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -51938,6 +51938,48 @@ "help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).", "hasChildren": false }, + { + "path": "plugins.installs.*.marketplaceName", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Marketplace Name", + "help": "Marketplace display name recorded for marketplace-backed plugin installs (if available).", + "hasChildren": false + }, + { + "path": "plugins.installs.*.marketplacePlugin", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Marketplace Plugin", + "help": "Plugin entry name inside the source marketplace, used for later updates.", + "hasChildren": false + }, + { + "path": "plugins.installs.*.marketplaceSource", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Marketplace Source", + "help": "Original marketplace source used to resolve the install (for example a repo path or Git URL).", + "hasChildren": false + }, { "path": "plugins.installs.*.resolvedAt", "kind": "core", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 34c4f9d5378..f857ff2d7f4 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5098} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5101} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -4506,6 +4506,9 @@ {"recordType":"path","path":"plugins.installs.*.installedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Time","help":"ISO timestamp of last install/update.","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.installPath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Plugin Install Path","help":"Resolved install directory (usually ~/.openclaw/extensions/).","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.integrity","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Integrity","help":"Resolved npm dist integrity hash for the fetched artifact (if reported by npm).","hasChildren":false} +{"recordType":"path","path":"plugins.installs.*.marketplaceName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Name","help":"Marketplace display name recorded for marketplace-backed plugin installs (if available).","hasChildren":false} +{"recordType":"path","path":"plugins.installs.*.marketplacePlugin","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Plugin","help":"Plugin entry name inside the source marketplace, used for later updates.","hasChildren":false} +{"recordType":"path","path":"plugins.installs.*.marketplaceSource","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Source","help":"Original marketplace source used to resolve the install (for example a repo path or Git URL).","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.resolvedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolution Time","help":"ISO timestamp when npm package metadata was last resolved for this install record.","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.resolvedName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Name","help":"Resolved npm package name from the fetched artifact.","hasChildren":false} {"recordType":"path","path":"plugins.installs.*.resolvedSpec","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Spec","help":"Resolved exact npm spec (@) from the fetched artifact.","hasChildren":false} diff --git a/package.json b/package.json index 00412359bf5..ebaa3607ad1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "assets/", "dist/", "docs/", + "!docs/.generated/**", + "!docs/.i18n/zh-CN.tm.jsonl", "extensions/", "skills/" ], From 898d6840dc32db3082ac1e541d6c3361f148b4fe Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:20:09 -0700 Subject: [PATCH 32/56] Runtime: lazy-load Telegram and Slack channel ops --- src/plugins/runtime/runtime-channel.ts | 48 +++++-- .../runtime/runtime-slack-ops.runtime.ts | 10 ++ src/plugins/runtime/runtime-slack.ts | 83 ++++++++--- .../runtime/runtime-telegram-ops.runtime.ts | 18 +++ src/plugins/runtime/runtime-telegram.ts | 129 +++++++++++++----- 5 files changed, 231 insertions(+), 57 deletions(-) create mode 100644 src/plugins/runtime/runtime-slack-ops.runtime.ts create mode 100644 src/plugins/runtime/runtime-telegram-ops.runtime.ts diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 23b47d48eeb..80bb1aba736 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -84,8 +84,28 @@ import { createRuntimeTelegram } from "./runtime-telegram.js"; import { createRuntimeWhatsApp } from "./runtime-whatsapp.js"; import type { PluginRuntime } from "./types.js"; +function defineCachedValue( + target: T, + key: K, + create: () => unknown, +): void { + let cached: unknown; + let ready = false; + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + get() { + if (!ready) { + cached = create(); + ready = true; + } + return cached; + }, + }); +} + export function createRuntimeChannel(): PluginRuntime["channel"] { - return { + const channelRuntime = { text: { chunkByNewline, chunkMarkdownText, @@ -167,12 +187,6 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { shouldComputeCommandAuthorized, shouldHandleTextCommands, }, - discord: createRuntimeDiscord(), - slack: createRuntimeSlack(), - telegram: createRuntimeTelegram(), - signal: createRuntimeSignal(), - imessage: createRuntimeIMessage(), - whatsapp: createRuntimeWhatsApp(), line: { listLineAccountIds, resolveDefaultLineAccountId, @@ -190,5 +204,23 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { buildTemplateMessageFromPayload, monitorLineProvider, }, - }; + } satisfies Omit< + PluginRuntime["channel"], + "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + > & + Partial< + Pick< + PluginRuntime["channel"], + "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + > + >; + + defineCachedValue(channelRuntime, "discord", createRuntimeDiscord); + defineCachedValue(channelRuntime, "slack", createRuntimeSlack); + defineCachedValue(channelRuntime, "telegram", createRuntimeTelegram); + defineCachedValue(channelRuntime, "signal", createRuntimeSignal); + defineCachedValue(channelRuntime, "imessage", createRuntimeIMessage); + defineCachedValue(channelRuntime, "whatsapp", createRuntimeWhatsApp); + + return channelRuntime as PluginRuntime["channel"]; } diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts new file mode 100644 index 00000000000..e22662c3b7f --- /dev/null +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -0,0 +1,10 @@ +export { + listSlackDirectoryGroupsLive, + listSlackDirectoryPeersLive, +} from "../../../extensions/slack/src/directory-live.js"; +export { monitorSlackProvider } from "../../../extensions/slack/src/index.js"; +export { probeSlack } from "../../../extensions/slack/src/probe.js"; +export { resolveSlackChannelAllowlist } from "../../../extensions/slack/src/resolve-channels.js"; +export { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; +export { sendMessageSlack } from "../../../extensions/slack/src/send.js"; +export { handleSlackAction } from "../../agents/tools/slack-actions.js"; diff --git a/src/plugins/runtime/runtime-slack.ts b/src/plugins/runtime/runtime-slack.ts index 095b14ec9c7..9579aed4c1b 100644 --- a/src/plugins/runtime/runtime-slack.ts +++ b/src/plugins/runtime/runtime-slack.ts @@ -1,24 +1,71 @@ -import { - listSlackDirectoryGroupsLive, - listSlackDirectoryPeersLive, -} from "../../../extensions/slack/src/directory-live.js"; -import { monitorSlackProvider } from "../../../extensions/slack/src/index.js"; -import { probeSlack } from "../../../extensions/slack/src/probe.js"; -import { resolveSlackChannelAllowlist } from "../../../extensions/slack/src/resolve-channels.js"; -import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; -import { sendMessageSlack } from "../../../extensions/slack/src/send.js"; -import { handleSlackAction } from "../../agents/tools/slack-actions.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; +let runtimeSlackOpsPromise: Promise | null = null; + +function loadRuntimeSlackOps() { + runtimeSlackOpsPromise ??= import("./runtime-slack-ops.runtime.js"); + return runtimeSlackOpsPromise; +} + +const listDirectoryGroupsLiveLazy: PluginRuntimeChannel["slack"]["listDirectoryGroupsLive"] = + async (...args) => { + const { listSlackDirectoryGroupsLive } = await loadRuntimeSlackOps(); + return listSlackDirectoryGroupsLive(...args); + }; + +const listDirectoryPeersLiveLazy: PluginRuntimeChannel["slack"]["listDirectoryPeersLive"] = async ( + ...args +) => { + const { listSlackDirectoryPeersLive } = await loadRuntimeSlackOps(); + return listSlackDirectoryPeersLive(...args); +}; + +const probeSlackLazy: PluginRuntimeChannel["slack"]["probeSlack"] = async (...args) => { + const { probeSlack } = await loadRuntimeSlackOps(); + return probeSlack(...args); +}; + +const resolveChannelAllowlistLazy: PluginRuntimeChannel["slack"]["resolveChannelAllowlist"] = + async (...args) => { + const { resolveSlackChannelAllowlist } = await loadRuntimeSlackOps(); + return resolveSlackChannelAllowlist(...args); + }; + +const resolveUserAllowlistLazy: PluginRuntimeChannel["slack"]["resolveUserAllowlist"] = async ( + ...args +) => { + const { resolveSlackUserAllowlist } = await loadRuntimeSlackOps(); + return resolveSlackUserAllowlist(...args); +}; + +const sendMessageSlackLazy: PluginRuntimeChannel["slack"]["sendMessageSlack"] = async (...args) => { + const { sendMessageSlack } = await loadRuntimeSlackOps(); + return sendMessageSlack(...args); +}; + +const monitorSlackProviderLazy: PluginRuntimeChannel["slack"]["monitorSlackProvider"] = async ( + ...args +) => { + const { monitorSlackProvider } = await loadRuntimeSlackOps(); + return monitorSlackProvider(...args); +}; + +const handleSlackActionLazy: PluginRuntimeChannel["slack"]["handleSlackAction"] = async ( + ...args +) => { + const { handleSlackAction } = await loadRuntimeSlackOps(); + return handleSlackAction(...args); +}; + export function createRuntimeSlack(): PluginRuntimeChannel["slack"] { return { - listDirectoryGroupsLive: listSlackDirectoryGroupsLive, - listDirectoryPeersLive: listSlackDirectoryPeersLive, - probeSlack, - resolveChannelAllowlist: resolveSlackChannelAllowlist, - resolveUserAllowlist: resolveSlackUserAllowlist, - sendMessageSlack, - monitorSlackProvider, - handleSlackAction, + listDirectoryGroupsLive: listDirectoryGroupsLiveLazy, + listDirectoryPeersLive: listDirectoryPeersLiveLazy, + probeSlack: probeSlackLazy, + resolveChannelAllowlist: resolveChannelAllowlistLazy, + resolveUserAllowlist: resolveUserAllowlistLazy, + sendMessageSlack: sendMessageSlackLazy, + monitorSlackProvider: monitorSlackProviderLazy, + handleSlackAction: handleSlackActionLazy, }; } diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts new file mode 100644 index 00000000000..dc463625b4f --- /dev/null +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -0,0 +1,18 @@ +export { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, +} from "../../../extensions/telegram/src/audit.js"; +export { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js"; +export { probeTelegram } from "../../../extensions/telegram/src/probe.js"; +export { + deleteMessageTelegram, + editMessageReplyMarkupTelegram, + editMessageTelegram, + pinMessageTelegram, + renameForumTopicTelegram, + sendMessageTelegram, + sendPollTelegram, + sendTypingTelegram, + unpinMessageTelegram, +} from "../../../extensions/telegram/src/send.js"; +export { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index 9481c718565..22061a7e00d 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,21 +1,5 @@ -import { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, -} from "../../../extensions/telegram/src/audit.js"; +import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/src/audit.js"; import { telegramMessageActions } from "../../../extensions/telegram/src/channel-actions.js"; -import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js"; -import { probeTelegram } from "../../../extensions/telegram/src/probe.js"; -import { - deleteMessageTelegram, - editMessageReplyMarkupTelegram, - editMessageTelegram, - pinMessageTelegram, - renameForumTopicTelegram, - sendMessageTelegram, - sendPollTelegram, - sendTypingTelegram, - unpinMessageTelegram, -} from "../../../extensions/telegram/src/send.js"; import { setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, @@ -24,22 +8,105 @@ import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js" import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; +let runtimeTelegramOpsPromise: Promise | null = + null; + +function loadRuntimeTelegramOps() { + runtimeTelegramOpsPromise ??= import("./runtime-telegram-ops.runtime.js"); + return runtimeTelegramOpsPromise; +} + +const auditGroupMembershipLazy: PluginRuntimeChannel["telegram"]["auditGroupMembership"] = async ( + ...args +) => { + const { auditTelegramGroupMembership } = await loadRuntimeTelegramOps(); + return auditTelegramGroupMembership(...args); +}; + +const probeTelegramLazy: PluginRuntimeChannel["telegram"]["probeTelegram"] = async (...args) => { + const { probeTelegram } = await loadRuntimeTelegramOps(); + return probeTelegram(...args); +}; + +const sendMessageTelegramLazy: PluginRuntimeChannel["telegram"]["sendMessageTelegram"] = async ( + ...args +) => { + const { sendMessageTelegram } = await loadRuntimeTelegramOps(); + return sendMessageTelegram(...args); +}; + +const sendPollTelegramLazy: PluginRuntimeChannel["telegram"]["sendPollTelegram"] = async ( + ...args +) => { + const { sendPollTelegram } = await loadRuntimeTelegramOps(); + return sendPollTelegram(...args); +}; + +const monitorTelegramProviderLazy: PluginRuntimeChannel["telegram"]["monitorTelegramProvider"] = + async (...args) => { + const { monitorTelegramProvider } = await loadRuntimeTelegramOps(); + return monitorTelegramProvider(...args); + }; + +const sendTypingTelegramLazy: PluginRuntimeChannel["telegram"]["typing"]["pulse"] = async ( + ...args +) => { + const { sendTypingTelegram } = await loadRuntimeTelegramOps(); + return sendTypingTelegram(...args); +}; + +const editMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["editMessage"] = + async (...args) => { + const { editMessageTelegram } = await loadRuntimeTelegramOps(); + return editMessageTelegram(...args); + }; + +const editMessageReplyMarkupTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["editReplyMarkup"] = + async (...args) => { + const { editMessageReplyMarkupTelegram } = await loadRuntimeTelegramOps(); + return editMessageReplyMarkupTelegram(...args); + }; + +const deleteMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["deleteMessage"] = + async (...args) => { + const { deleteMessageTelegram } = await loadRuntimeTelegramOps(); + return deleteMessageTelegram(...args); + }; + +const renameForumTopicTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["renameTopic"] = + async (...args) => { + const { renameForumTopicTelegram } = await loadRuntimeTelegramOps(); + return renameForumTopicTelegram(...args); + }; + +const pinMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["pinMessage"] = + async (...args) => { + const { pinMessageTelegram } = await loadRuntimeTelegramOps(); + return pinMessageTelegram(...args); + }; + +const unpinMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["unpinMessage"] = + async (...args) => { + const { unpinMessageTelegram } = await loadRuntimeTelegramOps(); + return unpinMessageTelegram(...args); + }; + export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] { return { - auditGroupMembership: auditTelegramGroupMembership, + auditGroupMembership: auditGroupMembershipLazy, collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds, - probeTelegram, + probeTelegram: probeTelegramLazy, resolveTelegramToken, - sendMessageTelegram, - sendPollTelegram, - monitorTelegramProvider, + sendMessageTelegram: sendMessageTelegramLazy, + sendPollTelegram: sendPollTelegramLazy, + monitorTelegramProvider: monitorTelegramProviderLazy, messageActions: telegramMessageActions, threadBindings: { setIdleTimeoutBySessionKey: setTelegramThreadBindingIdleTimeoutBySessionKey, setMaxAgeBySessionKey: setTelegramThreadBindingMaxAgeBySessionKey, }, typing: { - pulse: sendTypingTelegram, + pulse: sendTypingTelegramLazy, start: async ({ to, accountId, cfg, intervalMs, messageThreadId }) => await createTelegramTypingLease({ to, @@ -48,7 +115,7 @@ export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] { intervalMs, messageThreadId, pulse: async ({ to, accountId, cfg, messageThreadId }) => - await sendTypingTelegram(to, { + await sendTypingTelegramLazy(to, { accountId, cfg, messageThreadId, @@ -56,14 +123,14 @@ export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] { }), }, conversationActions: { - editMessage: editMessageTelegram, - editReplyMarkup: editMessageReplyMarkupTelegram, + editMessage: editMessageTelegramLazy, + editReplyMarkup: editMessageReplyMarkupTelegramLazy, clearReplyMarkup: async (chatIdInput, messageIdInput, opts = {}) => - await editMessageReplyMarkupTelegram(chatIdInput, messageIdInput, [], opts), - deleteMessage: deleteMessageTelegram, - renameTopic: renameForumTopicTelegram, - pinMessage: pinMessageTelegram, - unpinMessage: unpinMessageTelegram, + await editMessageReplyMarkupTelegramLazy(chatIdInput, messageIdInput, [], opts), + deleteMessage: deleteMessageTelegramLazy, + renameTopic: renameForumTopicTelegramLazy, + pinMessage: pinMessageTelegramLazy, + unpinMessage: unpinMessageTelegramLazy, }, }; } From d68645d47f454766c74efc7c6adca8b59b9dc25e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:28:45 -0700 Subject: [PATCH 33/56] Tests: detect changed extensions --- scripts/test-extension.mjs | 108 +++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/scripts/test-extension.mjs b/scripts/test-extension.mjs index bcc6aa30200..84fd91b0436 100644 --- a/scripts/test-extension.mjs +++ b/scripts/test-extension.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -46,6 +46,55 @@ function collectTestFiles(rootPath) { return results.toSorted((left, right) => left.localeCompare(right)); } +function listChangedPaths(base, head = "HEAD") { + if (!base) { + throw new Error("A git base revision is required to list changed extensions."); + } + + return execFileSync("git", ["diff", "--name-only", base, head], { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function hasExtensionPackage(extensionId) { + return fs.existsSync(path.join(repoRoot, "extensions", extensionId, "package.json")); +} + +export function detectChangedExtensionIds(changedPaths) { + const extensionIds = new Set(); + + for (const rawPath of changedPaths) { + const relativePath = normalizeRelative(String(rawPath).trim()); + if (!relativePath) { + continue; + } + + const extensionMatch = relativePath.match(/^extensions\/([^/]+)(?:\/|$)/); + if (extensionMatch) { + extensionIds.add(extensionMatch[1]); + continue; + } + + const pairedCoreMatch = relativePath.match(/^src\/([^/]+)(?:\/|$)/); + if (pairedCoreMatch && hasExtensionPackage(pairedCoreMatch[1])) { + extensionIds.add(pairedCoreMatch[1]); + } + } + + return [...extensionIds].toSorted((left, right) => left.localeCompare(right)); +} + +export function listChangedExtensionIds(params = {}) { + const base = params.base; + const head = params.head ?? "HEAD"; + return detectChangedExtensionIds(listChangedPaths(base, head)); +} + function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { if (targetArg) { const asGiven = path.resolve(cwd, targetArg); @@ -115,17 +164,66 @@ export function resolveExtensionTestPlan(params = {}) { function printUsage() { console.error("Usage: pnpm test:extension [vitest args...]"); console.error(" node scripts/test-extension.mjs [extension-name|path] [vitest args...]"); + console.error( + " node scripts/test-extension.mjs --list-changed --base [--head ]", + ); } async function run() { const rawArgs = process.argv.slice(2); const dryRun = rawArgs.includes("--dry-run"); const json = rawArgs.includes("--json"); - const args = rawArgs.filter((arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json"); + const listChanged = rawArgs.includes("--list-changed"); + const args = rawArgs.filter( + (arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json" && arg !== "--list-changed", + ); + + let base = ""; + let head = "HEAD"; + const passthroughArgs = []; + + if (listChanged) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--base") { + base = args[index + 1] ?? ""; + index += 1; + continue; + } + if (arg === "--head") { + head = args[index + 1] ?? "HEAD"; + index += 1; + continue; + } + passthroughArgs.push(arg); + } + } else { + passthroughArgs.push(...args); + } + + if (listChanged) { + let extensionIds; + try { + extensionIds = listChangedExtensionIds({ base, head }); + } catch (error) { + printUsage(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + if (json) { + process.stdout.write(`${JSON.stringify({ base, head, extensionIds }, null, 2)}\n`); + } else { + for (const extensionId of extensionIds) { + console.log(extensionId); + } + } + return; + } let targetArg; - if (args[0] && !args[0].startsWith("-")) { - targetArg = args.shift(); + if (passthroughArgs[0] && !passthroughArgs[0].startsWith("-")) { + targetArg = passthroughArgs.shift(); } let plan; @@ -160,7 +258,7 @@ async function run() { const child = spawn( pnpm, - ["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...args], + ["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...passthroughArgs], { cwd: repoRoot, stdio: "inherit", From 2ee20a60728a8d4ceaf69c28249495023da1a9f9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:29:11 -0700 Subject: [PATCH 34/56] Tests: cover changed extension detection --- test/scripts/test-extension.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 1ab4a68deb8..63561cb5151 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -1,7 +1,10 @@ import { execFileSync } from "node:child_process"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveExtensionTestPlan } from "../../scripts/test-extension.mjs"; +import { + detectChangedExtensionIds, + resolveExtensionTestPlan, +} from "../../scripts/test-extension.mjs"; const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); @@ -47,4 +50,15 @@ describe("scripts/test-extension.mjs", () => { expect(plan.extensionId).toBe("slack"); expect(plan.extensionDir).toBe("extensions/slack"); }); + + it("maps changed paths back to extension ids", () => { + const extensionIds = detectChangedExtensionIds([ + "extensions/slack/src/channel.ts", + "src/line/message.test.ts", + "extensions/firecrawl/package.json", + "src/not-a-plugin/file.ts", + ]); + + expect(extensionIds).toEqual(["firecrawl", "line", "slack"]); + }); }); From 303f690dd9c4d626dca76dace925a94190758f8f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:29:21 -0700 Subject: [PATCH 35/56] Docs: add extension test workflow --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4184a550691..9b1fa35d6a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,6 +89,9 @@ Welcome to the lobster tank! 🦞 - Test locally with your OpenClaw instance - Run tests: `pnpm build && pnpm check && pnpm test` +- For extension/plugin changes, run the fast local lane first: + - `pnpm test:extension ` + - If you changed shared plugin or channel surfaces, still run the broader relevant lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review - If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) From 5336c4e9451cc334d81aef3caf4337518e068c5e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:29:30 -0700 Subject: [PATCH 36/56] CI: add changed extension test lane --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9922ceb12f5..f82dea2f230 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,50 @@ jobs: node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD + changed-extensions: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + outputs: + has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }} + changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + fetch-tags: false + submodules: false + + - name: Ensure changed-extensions base commit + uses: ./.github/actions/ensure-base-commit + with: + base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }} + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + install-deps: "false" + use-sticky-disk: "false" + + - name: Detect changed extensions + id: changed + env: + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + run: | + node --input-type=module <<'EOF' + import { appendFileSync } from "node:fs"; + import { listChangedExtensionIds } from "./scripts/test-extension.mjs"; + + const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" }); + const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) }); + + appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8"); + appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8"); + EOF + # Build dist once for Node-relevant changes and share it with downstream jobs. build-artifacts: needs: [docs-scope, changed-scope] @@ -205,6 +249,29 @@ jobs: if: matrix.runtime != 'bun' || github.event_name != 'pull_request' run: ${{ matrix.command }} + extension-fast: + name: "extension-fast (${{ matrix.extension }})" + needs: [docs-scope, changed-scope, changed-extensions] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_extensions_matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + use-sticky-disk: "false" + + - name: Run changed extension tests + run: pnpm test:extension ${{ matrix.extension }} + # Types, lint, and format check. check: name: "check" From e1f759f4f16a96c240635383d86d52c30cbfe8d9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 02:34:42 -0700 Subject: [PATCH 37/56] BlueBubbles: lazy-load channel runtime paths --- extensions/bluebubbles/src/actions.runtime.ts | 13 ++++ extensions/bluebubbles/src/actions.ts | 61 ++++++++++--------- extensions/bluebubbles/src/channel.runtime.ts | 6 ++ extensions/bluebubbles/src/channel.ts | 32 ++++++---- 4 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 extensions/bluebubbles/src/actions.runtime.ts create mode 100644 extensions/bluebubbles/src/channel.runtime.ts diff --git a/extensions/bluebubbles/src/actions.runtime.ts b/extensions/bluebubbles/src/actions.runtime.ts new file mode 100644 index 00000000000..53285c19f17 --- /dev/null +++ b/extensions/bluebubbles/src/actions.runtime.ts @@ -0,0 +1,13 @@ +export { sendBlueBubblesAttachment } from "./attachments.js"; +export { + addBlueBubblesParticipant, + editBlueBubblesMessage, + leaveBlueBubblesChat, + removeBlueBubblesParticipant, + renameBlueBubblesChat, + setGroupIconBlueBubbles, + unsendBlueBubblesMessage, +} from "./chat.js"; +export { resolveBlueBubblesMessageId } from "./monitor.js"; +export { sendBlueBubblesReaction } from "./reactions.js"; +export { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index a8ce9f62c5f..4e6476afa3f 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -12,24 +12,18 @@ import { type ChannelMessageActionName, } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesAccount } from "./accounts.js"; -import { sendBlueBubblesAttachment } from "./attachments.js"; -import { - editBlueBubblesMessage, - unsendBlueBubblesMessage, - renameBlueBubblesChat, - setGroupIconBlueBubbles, - addBlueBubblesParticipant, - removeBlueBubblesParticipant, - leaveBlueBubblesChat, -} from "./chat.js"; -import { resolveBlueBubblesMessageId } from "./monitor.js"; import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; -import { sendBlueBubblesReaction } from "./reactions.js"; import { normalizeSecretInputString } from "./secret-input.js"; -import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; import type { BlueBubblesSendTarget } from "./types.js"; +let actionsRuntimePromise: Promise | null = null; + +function loadBlueBubblesActionsRuntime() { + actionsRuntimePromise ??= import("./actions.runtime.js"); + return actionsRuntimePromise; +} + const providerId = "bluebubbles"; function mapTarget(raw: string): BlueBubblesSendTarget { @@ -99,6 +93,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId, toolContext }) => { + const runtime = await loadBlueBubblesActionsRuntime(); const account = resolveBlueBubblesAccount({ cfg: cfg, accountId: accountId ?? undefined, @@ -147,7 +142,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error(`BlueBubbles ${action} requires serverUrl and password.`); } - const resolved = await resolveChatGuidForTarget({ baseUrl, password, target }); + const resolved = await runtime.resolveChatGuidForTarget({ baseUrl, password, target }); if (!resolved) { throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`); } @@ -173,11 +168,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { + requireKnownShortId: true, + }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const resolvedChatGuid = await resolveChatGuid(); - await sendBlueBubblesReaction({ + await runtime.sendBlueBubblesReaction({ chatGuid: resolvedChatGuid, messageGuid: messageId, emoji, @@ -218,11 +215,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { + requireKnownShortId: true, + }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); - await editBlueBubblesMessage(messageId, newText, { + await runtime.editBlueBubblesMessage(messageId, newText, { ...opts, partIndex: typeof partIndex === "number" ? partIndex : undefined, backwardsCompatMessage: backwardsCompatMessage ?? undefined, @@ -242,10 +241,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { + requireKnownShortId: true, + }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); - await unsendBlueBubblesMessage(messageId, { + await runtime.unsendBlueBubblesMessage(messageId, { ...opts, partIndex: typeof partIndex === "number" ? partIndex : undefined, }); @@ -276,10 +277,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } // Resolve short ID (e.g., "1", "2") to full UUID - const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, { + requireKnownShortId: true, + }); const partIndex = readNumberParam(params, "partIndex", { integer: true }); - const result = await sendMessageBlueBubbles(to, text, { + const result = await runtime.sendMessageBlueBubbles(to, text, { ...opts, replyToMessageGuid: messageId, replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined, @@ -313,7 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { ); } - const result = await sendMessageBlueBubbles(to, text, { + const result = await runtime.sendMessageBlueBubbles(to, text, { ...opts, effectId, }); @@ -330,7 +333,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error("BlueBubbles renameGroup requires displayName or name parameter."); } - await renameBlueBubblesChat(resolvedChatGuid, displayName, opts); + await runtime.renameBlueBubblesChat(resolvedChatGuid, displayName, opts); return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName }); } @@ -355,7 +358,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Decode base64 to buffer const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); - await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, { + await runtime.setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, { ...opts, contentType: contentType ?? undefined, }); @@ -372,7 +375,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error("BlueBubbles addParticipant requires address or participant parameter."); } - await addBlueBubblesParticipant(resolvedChatGuid, address, opts); + await runtime.addBlueBubblesParticipant(resolvedChatGuid, address, opts); return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid }); } @@ -386,7 +389,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error("BlueBubbles removeParticipant requires address or participant parameter."); } - await removeBlueBubblesParticipant(resolvedChatGuid, address, opts); + await runtime.removeBlueBubblesParticipant(resolvedChatGuid, address, opts); return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid }); } @@ -396,7 +399,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { assertPrivateApiEnabled(); const resolvedChatGuid = await resolveChatGuid(); - await leaveBlueBubblesChat(resolvedChatGuid, opts); + await runtime.leaveBlueBubblesChat(resolvedChatGuid, opts); return jsonResult({ ok: true, left: resolvedChatGuid }); } @@ -427,7 +430,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter."); } - const result = await sendBlueBubblesAttachment({ + const result = await runtime.sendBlueBubblesAttachment({ to, buffer, filename, diff --git a/extensions/bluebubbles/src/channel.runtime.ts b/extensions/bluebubbles/src/channel.runtime.ts new file mode 100644 index 00000000000..32bf567dcf5 --- /dev/null +++ b/extensions/bluebubbles/src/channel.runtime.ts @@ -0,0 +1,6 @@ +export { sendBlueBubblesMedia } from "./media-send.js"; +export { resolveBlueBubblesMessageId } from "./monitor.js"; +export { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; +export { type BlueBubblesProbe, probeBlueBubbles } from "./probe.js"; +export { sendMessageBlueBubbles } from "./send.js"; +export { blueBubblesSetupWizard } from "./setup-surface.js"; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index d6d1a3130fb..2fe2fc3f3fb 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -25,12 +25,8 @@ import { resolveDefaultBlueBubblesAccountId, } from "./accounts.js"; import { bluebubblesMessageActions } from "./actions.js"; +import type { BlueBubblesProbe } from "./channel.runtime.js"; import { BlueBubblesConfigSchema } from "./config-schema.js"; -import { sendBlueBubblesMedia } from "./media-send.js"; -import { resolveBlueBubblesMessageId } from "./monitor.js"; -import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; -import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; -import { sendMessageBlueBubbles } from "./send.js"; import { blueBubblesSetupAdapter } from "./setup-core.js"; import { blueBubblesSetupWizard } from "./setup-surface.js"; import { @@ -41,6 +37,13 @@ import { parseBlueBubblesTarget, } from "./targets.js"; +let blueBubblesChannelRuntimePromise: Promise | null = null; + +function loadBlueBubblesChannelRuntime() { + blueBubblesChannelRuntimePromise ??= import("./channel.runtime.js"); + return blueBubblesChannelRuntimePromise; +} + const meta = { id: "bluebubbles", label: "BlueBubbles", @@ -221,7 +224,9 @@ export const bluebubblesPlugin: ChannelPlugin = { idLabel: "bluebubblesSenderId", normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), notifyApproval: async ({ cfg, id }) => { - await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { + await ( + await loadBlueBubblesChannelRuntime() + ).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { cfg: cfg, }); }, @@ -240,12 +245,13 @@ export const bluebubblesPlugin: ChannelPlugin = { return { ok: true, to: trimmed }; }, sendText: async ({ cfg, to, text, accountId, replyToId }) => { + const runtime = await loadBlueBubblesChannelRuntime(); const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; // Resolve short ID (e.g., "5") to full UUID const replyToMessageGuid = rawReplyToId - ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) : ""; - const result = await sendMessageBlueBubbles(to, text, { + const result = await runtime.sendMessageBlueBubbles(to, text, { cfg: cfg, accountId: accountId ?? undefined, replyToMessageGuid: replyToMessageGuid || undefined, @@ -253,6 +259,7 @@ export const bluebubblesPlugin: ChannelPlugin = { return { channel: "bluebubbles", ...result }; }, sendMedia: async (ctx) => { + const runtime = await loadBlueBubblesChannelRuntime(); const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { mediaPath?: string; @@ -262,7 +269,7 @@ export const bluebubblesPlugin: ChannelPlugin = { caption?: string; }; const resolvedCaption = caption ?? text; - const result = await sendBlueBubblesMedia({ + const result = await runtime.sendBlueBubblesMedia({ cfg: cfg, to, mediaUrl, @@ -290,7 +297,7 @@ export const bluebubblesPlugin: ChannelPlugin = { buildChannelSummary: ({ snapshot }) => buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), probeAccount: async ({ account, timeoutMs }) => - probeBlueBubbles({ + (await loadBlueBubblesChannelRuntime()).probeBlueBubbles({ baseUrl: account.baseUrl, password: account.config.password ?? null, timeoutMs, @@ -315,8 +322,9 @@ export const bluebubblesPlugin: ChannelPlugin = { }, gateway: { startAccount: async (ctx) => { + const runtime = await loadBlueBubblesChannelRuntime(); const account = ctx.account; - const webhookPath = resolveWebhookPathFromConfig(account.config); + const webhookPath = runtime.resolveWebhookPathFromConfig(account.config); const statusSink = createAccountStatusSink({ accountId: ctx.accountId, setStatus: ctx.setStatus, @@ -325,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin = { baseUrl: account.baseUrl, }); ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`); - return monitorBlueBubblesProvider({ + return runtime.monitorBlueBubblesProvider({ account, config: ctx.cfg, runtime: ctx.runtime, From 92700940d901e40127c28cb8f04bc3c3e2e20a1c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 09:51:28 +0000 Subject: [PATCH 38/56] Plugin SDK: restore scoped imports for bundled channels --- extensions/discord/index.ts | 4 ++-- extensions/discord/src/runtime.ts | 2 +- extensions/discord/src/subagent-hooks.ts | 2 +- extensions/imessage/index.ts | 4 ++-- extensions/imessage/src/runtime.ts | 2 +- extensions/signal/index.ts | 4 ++-- extensions/signal/src/runtime.ts | 2 +- extensions/slack/index.ts | 4 ++-- extensions/slack/src/message-action-dispatch.ts | 2 +- extensions/slack/src/runtime.ts | 2 +- extensions/telegram/index.ts | 4 ++-- extensions/telegram/src/runtime.ts | 2 +- extensions/whatsapp/index.ts | 4 ++-- extensions/whatsapp/src/runtime.ts | 2 +- src/plugin-sdk/core.ts | 1 + src/plugin-sdk/subpaths.test.ts | 16 +++++++++++++++- 16 files changed, 36 insertions(+), 21 deletions(-) diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index 04906b6fd5d..13b32f08bb1 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js"; diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 066dcdbad12..2dc10a295fd 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = createPluginRuntimeStore("Discord runtime not initialized"); diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index f73511dba20..c9ba7b97984 100644 --- a/extensions/discord/src/subagent-hooks.ts +++ b/extensions/discord/src/subagent-hooks.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { resolveDiscordAccount } from "./accounts.js"; import { autoBindSpawnedDiscordSubagent, diff --git a/extensions/imessage/index.ts b/extensions/imessage/index.ts index 7eb0e80b070..e87d421cf2e 100644 --- a/extensions/imessage/index.ts +++ b/extensions/imessage/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { imessagePlugin } from "./src/channel.js"; import { setIMessageRuntime } from "./src/runtime.js"; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index 8805ce3141f..08c9b6ccbbd 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = createPluginRuntimeStore("iMessage runtime not initialized"); diff --git a/extensions/signal/index.ts b/extensions/signal/index.ts index e1069e466e2..0a686851120 100644 --- a/extensions/signal/index.ts +++ b/extensions/signal/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { signalPlugin } from "./src/channel.js"; import { setSignalRuntime } from "./src/runtime.js"; diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index 1b004d82b8a..b7cc4160f1c 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = createPluginRuntimeStore("Signal runtime not initialized"); diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 6f5945616c7..f1147cb9c91 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { slackPlugin } from "./src/channel.js"; import { setSlackRuntime } from "./src/runtime.js"; diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 58fc4d77184..fc902f49558 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,5 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/core"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js"; diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index fd1a2ba17c6..313f472eec4 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = createPluginRuntimeStore("Slack runtime not initialized"); diff --git a/extensions/telegram/index.ts b/extensions/telegram/index.ts index a2492fca87d..d47ae46b6ce 100644 --- a/extensions/telegram/index.ts +++ b/extensions/telegram/index.ts @@ -1,5 +1,5 @@ -import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { telegramPlugin } from "./src/channel.js"; import { setTelegramRuntime } from "./src/runtime.js"; diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index 97ba41a3a4d..d4e15f463d9 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = createPluginRuntimeStore("Telegram runtime not initialized"); diff --git a/extensions/whatsapp/index.ts b/extensions/whatsapp/index.ts index 1b19ff6775d..c0f097ddf7d 100644 --- a/extensions/whatsapp/index.ts +++ b/extensions/whatsapp/index.ts @@ -1,5 +1,5 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { whatsappPlugin } from "./src/channel.js"; import { setWhatsAppRuntime } from "./src/runtime.js"; diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index c3644a531d2..07dd4e3d688 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,5 +1,5 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = createPluginRuntimeStore("WhatsApp runtime not initialized"); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 01807d79132..2b23eff6b14 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -53,6 +53,7 @@ export type { SshSandboxSettings, } from "../agents/sandbox.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { ChannelMessageActionContext } from "../channels/plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 5e3f62849d7..0a65721ceb4 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,6 +1,11 @@ import * as extensionApi from "openclaw/extension-api"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as coreSdk from "openclaw/plugin-sdk/core"; +import type { + ChannelMessageActionContext as CoreChannelMessageActionContext, + OpenClawPluginApi as CoreOpenClawPluginApi, + PluginRuntime as CorePluginRuntime, +} from "openclaw/plugin-sdk/core"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lineSdk from "openclaw/plugin-sdk/line"; @@ -10,7 +15,10 @@ import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; +import type { OpenClawPluginApi } from "../plugins/types.js"; import { pluginSdkSubpaths } from "./entrypoints.js"; const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); @@ -35,6 +43,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof coreSdk.createLoggerBackedRuntime).toBe("function"); }); + it("exports shared core types used by bundled channels", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + it("exports Discord helpers", () => { expect(typeof discordSdk.buildChannelConfigSchema).toBe("function"); expect(typeof discordSdk.DiscordConfigSchema).toBe("object"); From 296083a49acbbb3902752307bf6538b0d0fad24b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 10:05:40 +0000 Subject: [PATCH 39/56] Plugin SDK: consolidate shared channel exports --- src/plugin-sdk/channel-plugin-common.ts | 4 +++ src/plugin-sdk/core.ts | 12 +++++---- src/plugin-sdk/discord.ts | 20 ++++++++++++++- src/plugin-sdk/imessage.ts | 20 ++++++++++++++- src/plugin-sdk/line.ts | 13 +++++----- src/plugin-sdk/signal.ts | 20 ++++++++++++++- src/plugin-sdk/slack.ts | 20 ++++++++++++++- src/plugin-sdk/subpaths.test.ts | 11 +++++++++ src/plugin-sdk/telegram.ts | 33 +++++++++++++------------ src/plugin-sdk/whatsapp.ts | 27 +++++++++++--------- 10 files changed, 137 insertions(+), 43 deletions(-) diff --git a/src/plugin-sdk/channel-plugin-common.ts b/src/plugin-sdk/channel-plugin-common.ts index 59c347c8f0c..3c5153733c0 100644 --- a/src/plugin-sdk/channel-plugin-common.ts +++ b/src/plugin-sdk/channel-plugin-common.ts @@ -1,4 +1,8 @@ +// Canonical shared prelude for channel-oriented plugin SDK surfaces. +// Keep `core` and channel-specific SDK entrypoints derived from this module +// so bundled channel entrypoints do not drift across overlapping exports. export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { ChannelMessageActionContext } from "../channels/plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 2b23eff6b14..1e33cafe4e9 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,6 +1,5 @@ export type { AnyAgentTool, - OpenClawPluginApi, OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, @@ -52,9 +51,6 @@ export type { SshSandboxSession, SshSandboxSettings, } from "../agents/sandbox.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export type { ChannelMessageActionContext } from "../channels/plugins/types.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; export type { @@ -62,8 +58,14 @@ export type { UsageProviderId, UsageWindow, } from "../infra/provider-usage.types.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { emptyPluginConfigSchema } from "./channel-plugin-common.js"; export { buildExecRemoteCommand, buildRemoteCommand, diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 82ffb8dde5c..d15f5091b9d 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,7 +1,25 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -export * from "./channel-plugin-common.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index f896799b323..a974910e680 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -1,5 +1,23 @@ export type { IMessageAccountConfig } from "../config/types.js"; -export * from "./channel-plugin-common.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index c21ee9661fb..b6617199472 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -6,15 +6,14 @@ export type { export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { ReplyPayload } from "../auto-reply/types.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +export type { OpenClawPluginApi, PluginRuntime } from "./channel-plugin-common.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; - -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + emptyPluginConfigSchema, +} from "./channel-plugin-common.js"; export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; export { diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 86f83b06318..8fd6fd2afd0 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,6 +1,24 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { SignalAccountConfig } from "../config/types.js"; -export * from "./channel-plugin-common.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { looksLikeSignalTargetId, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 93ad140bfad..f7533b95687 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,6 +1,24 @@ export type { OpenClawConfig } from "../config/config.js"; export type { SlackAccountConfig } from "../config/types.slack.js"; -export * from "./channel-plugin-common.js"; +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; +export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 0a65721ceb4..856bacbca10 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -19,6 +19,11 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { OpenClawPluginApi } from "../plugins/types.js"; +import type { + ChannelMessageActionContext as SharedChannelMessageActionContext, + OpenClawPluginApi as SharedOpenClawPluginApi, + PluginRuntime as SharedPluginRuntime, +} from "./channel-plugin-common.js"; import { pluginSdkSubpaths } from "./entrypoints.js"; const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); @@ -49,6 +54,12 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); + it("keeps core shared types aligned with the channel prelude", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + it("exports Discord helpers", () => { expect(typeof discordSdk.buildChannelConfigSchema).toBe("function"); expect(typeof discordSdk.DiscordConfigSchema).toBe("object"); diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 397a48fa019..2eed87097f0 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -3,28 +3,29 @@ export type { ChannelGatewayContext, ChannelMessageActionAdapter, } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; export type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { + buildChannelConfigSchema, deleteAccountFromConfigSection, - clearAccountEntryFields, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export { getChatChannelMeta } from "../channels/registry.js"; +} from "./channel-plugin-common.js"; + +export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; export { projectCredentialSnapshotFields, diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 7e4debbef43..df814fa04eb 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,20 +1,25 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - +export type { + ChannelMessageActionContext, + ChannelPlugin, + OpenClawPluginApi, + PluginRuntime, +} from "./channel-plugin-common.js"; export { + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, applyAccountNameToChannelSection, + buildChannelConfigSchema, + deleteAccountFromConfigSection, + emptyPluginConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { getChatChannelMeta } from "../channels/registry.js"; + normalizeAccountId, + setAccountEnabledInConfigSection, +} from "./channel-plugin-common.js"; export { formatWhatsAppConfigAllowFromEntries, resolveWhatsAppConfigAllowFrom, From 1cf544ffbc69746568b6357d49ed76a95c371009 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 10:07:55 +0000 Subject: [PATCH 40/56] Channels: fix surface contract plugin lookup --- src/channels/plugins/contracts/registry.ts | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 77bf23b335c..2d4569383f8 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -492,12 +492,12 @@ export const statusContractRegistry: StatusContractEntry[] = [ export const surfaceContractRegistry: SurfaceContractEntry[] = [ { id: "bluebubbles", - plugin: bluebubblesPlugin, + plugin: requireBundledChannelPlugin("bluebubbles"), surfaces: ["actions", "setup", "status", "outbound", "messaging", "threading", "gateway"], }, { id: "discord", - plugin: discordPlugin, + plugin: requireBundledChannelPlugin("discord"), surfaces: [ "actions", "setup", @@ -511,12 +511,12 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "feishu", - plugin: feishuPlugin, + plugin: requireBundledChannelPlugin("feishu"), surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], }, { id: "googlechat", - plugin: googlechatPlugin, + plugin: requireBundledChannelPlugin("googlechat"), surfaces: [ "actions", "setup", @@ -530,22 +530,22 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "imessage", - plugin: imessagePlugin, + plugin: requireBundledChannelPlugin("imessage"), surfaces: ["setup", "status", "outbound", "messaging", "gateway"], }, { id: "irc", - plugin: ircPlugin, + plugin: requireBundledChannelPlugin("irc"), surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], }, { id: "line", - plugin: linePlugin, + plugin: requireBundledChannelPlugin("line"), surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"], }, { id: "matrix", - plugin: matrixPlugin, + plugin: requireBundledChannelPlugin("matrix"), surfaces: [ "actions", "setup", @@ -559,7 +559,7 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "mattermost", - plugin: mattermostPlugin, + plugin: requireBundledChannelPlugin("mattermost"), surfaces: [ "actions", "setup", @@ -573,7 +573,7 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "msteams", - plugin: msteamsPlugin, + plugin: requireBundledChannelPlugin("msteams"), surfaces: [ "actions", "setup", @@ -587,22 +587,22 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "nextcloud-talk", - plugin: nextcloudTalkPlugin, + plugin: requireBundledChannelPlugin("nextcloud-talk"), surfaces: ["setup", "status", "outbound", "messaging", "gateway"], }, { id: "nostr", - plugin: nostrPlugin, + plugin: requireBundledChannelPlugin("nostr"), surfaces: ["setup", "status", "outbound", "messaging", "gateway"], }, { id: "signal", - plugin: signalPlugin, + plugin: requireBundledChannelPlugin("signal"), surfaces: ["actions", "setup", "status", "outbound", "messaging", "gateway"], }, { id: "slack", - plugin: slackPlugin, + plugin: requireBundledChannelPlugin("slack"), surfaces: [ "actions", "setup", @@ -616,12 +616,12 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "synology-chat", - plugin: synologyChatPlugin, + plugin: requireBundledChannelPlugin("synology-chat"), surfaces: ["setup", "outbound", "messaging", "directory", "gateway"], }, { id: "telegram", - plugin: telegramPlugin, + plugin: requireBundledChannelPlugin("telegram"), surfaces: [ "actions", "setup", @@ -635,17 +635,17 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "tlon", - plugin: tlonPlugin, + plugin: requireBundledChannelPlugin("tlon"), surfaces: ["setup", "status", "outbound", "messaging", "gateway"], }, { id: "whatsapp", - plugin: whatsappPlugin, + plugin: requireBundledChannelPlugin("whatsapp"), surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"], }, { id: "zalo", - plugin: zaloPlugin, + plugin: requireBundledChannelPlugin("zalo"), surfaces: [ "actions", "setup", @@ -659,7 +659,7 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [ }, { id: "zalouser", - plugin: zalouserPlugin, + plugin: requireBundledChannelPlugin("zalouser"), surfaces: [ "actions", "setup", From 8cd1bdd345a81bd30d475f1147c7f903937853fb Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 10:27:44 +0000 Subject: [PATCH 41/56] Status: stabilize startup memory probes --- scripts/check-cli-startup-memory.mjs | 10 +- src/commands/status.scan.test.ts | 138 ++++++++++++++++++++++++++- src/commands/status.scan.ts | 24 +++++ 3 files changed, 168 insertions(+), 4 deletions(-) diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index fcbd63d8d11..ce452d1a7ab 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -63,11 +63,12 @@ const cases = [ ]; function parseMaxRssMb(stderr) { - const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m")); - if (!match) { + const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))]; + const lastMatch = matches.at(-1); + if (!lastMatch) { return null; } - return Number(match[1]) / 1024; + return Number(lastMatch[1]) / 1024; } function buildBenchEnv() { @@ -98,6 +99,9 @@ function buildBenchEnv() { // one-shot compile cache overhead, which varies across runner builds. env.NODE_DISABLE_COMPILE_CACHE = "1"; } + // Keep the benchmark on a single process so RSS reflects the actual command + // path rather than the warning-suppression respawn wrapper. + env.OPENCLAW_NO_RESPAWN = "1"; return env; } diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 6e778070c09..edb77ae4fcf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ getUpdateCheckResult: vi.fn(), getAgentLocalStatuses: vi.fn(), getStatusSummary: vi.fn(), + getMemorySearchManager: vi.fn(), buildGatewayConnectionDetails: vi.fn(), probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), @@ -53,7 +54,7 @@ vi.mock("../infra/os-summary.js", () => ({ vi.mock("./status.scan.deps.runtime.js", () => ({ getTailnetHostname: vi.fn(), - getMemorySearchManager: vi.fn(), + getMemorySearchManager: mocks.getMemorySearchManager, })); vi.mock("../gateway/call.js", () => ({ @@ -196,6 +197,141 @@ describe("scanStatus", () => { expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); }); + it("skips memory backend inspection for default memory-core with no existing store", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.getMemorySearchManager).not.toHaveBeenCalled(); + }); + + it("inspects memory backend when memory search is explicitly configured", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + gateway: {}, + agents: { + defaults: { + memorySearch: { + provider: "local", + local: { modelPath: "/tmp/model.gguf" }, + fallback: "none", + }, + }, + }, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + gateway: {}, + agents: { + defaults: { + memorySearch: { + provider: "local", + local: { modelPath: "/tmp/model.gguf" }, + fallback: "none", + }, + }, + }, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.getMemorySearchManager.mockResolvedValue({ + manager: { + probeVectorAvailability: vi.fn(async () => true), + status: vi.fn(() => ({ files: 0, chunks: 0, dirty: false })), + close: vi.fn(async () => {}), + }, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.getMemorySearchManager).toHaveBeenCalledWith({ + cfg: expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ + memorySearch: expect.any(Object), + }), + }), + }), + agentId: "main", + purpose: "status", + }); + }); + it("preloads configured channel plugins for status --json when channel config exists", async () => { mocks.readBestEffortConfig.mockResolvedValue({ session: {}, diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index bbe10301624..6c2bd67f3dd 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; @@ -33,6 +35,19 @@ type MemoryPluginStatus = { reason?: string; }; +function hasExplicitMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { + if ( + cfg.agents?.defaults && + Object.prototype.hasOwnProperty.call(cfg.agents.defaults, "memorySearch") + ) { + return true; + } + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + return agents.some( + (agent) => agent?.id === agentId && Object.prototype.hasOwnProperty.call(agent, "memorySearch"), + ); +} + type DeferredResult = { ok: true; value: T } | { ok: false; error: unknown }; type GatewayProbeSnapshot = { @@ -190,6 +205,15 @@ async function resolveMemoryStatusSnapshot(params: { return null; } const agentId = agentStatus.defaultId ?? "main"; + const resolvedMemory = resolveMemorySearchConfig(cfg, agentId); + if (!resolvedMemory) { + return null; + } + const shouldInspectStore = + hasExplicitMemorySearchConfig(cfg, agentId) || existsSync(resolvedMemory.store.path); + if (!shouldInspectStore) { + return null; + } const { getMemorySearchManager } = await loadStatusScanDepsRuntimeModule(); const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); if (!manager) { From 6e650666160af9932d759d49ad52cdf4146fea39 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 10:45:56 +0000 Subject: [PATCH 42/56] Media: avoid slow auth misses in auto-detect --- src/agents/model-auth.profiles.test.ts | 41 +++++- src/agents/model-auth.ts | 50 ++++++++ .../runner.auto-audio.test.ts | 117 ++++++++++-------- src/media-understanding/runner.ts | 27 ++-- src/media-understanding/runner.video.test.ts | 113 +++++++++-------- 5 files changed, 229 insertions(+), 119 deletions(-) diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index ca509f632d4..f9395373024 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -5,7 +5,12 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; -import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js"; +import { + getApiKeyForModel, + hasAvailableAuthForProvider, + resolveApiKeyForProvider, + resolveEnvApiKey, +} from "./model-auth.js"; const envVar = (...parts: string[]) => parts.join("_"); @@ -206,6 +211,40 @@ describe("getApiKeyForModel", () => { ); }); + it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => { + await withEnvAsync( + { + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: "google-test-key", // pragma: allowlist secret + }, + async () => { + await expect( + hasAvailableAuthForProvider({ + provider: "google", + store: { version: 1, profiles: {} }, + }), + ).resolves.toBe(true); + }, + ); + }); + + it("hasAvailableAuthForProvider returns false when no provider auth is available", async () => { + await withEnvAsync( + { + ZAI_API_KEY: undefined, + Z_AI_API_KEY: undefined, + }, + async () => { + await expect( + hasAvailableAuthForProvider({ + provider: "zai", + store: { version: 1, profiles: {} }, + }), + ).resolves.toBe(false); + }, + ); + }); + it("resolves Synthetic API key from env", async () => { await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => { // pragma: allowlist secret diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 9e94c51dad7..e494cc71b8c 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -487,6 +487,56 @@ export function resolveModelAuthMode( return "unknown"; } +export async function hasAvailableAuthForProvider(params: { + provider: string; + cfg?: OpenClawConfig; + preferredProfile?: string; + store?: AuthProfileStore; + agentDir?: string; +}): Promise { + const { provider, cfg, preferredProfile } = params; + const store = params.store ?? ensureAuthProfileStore(params.agentDir); + + const authOverride = resolveProviderAuthOverride(cfg, provider); + if (authOverride === "aws-sdk") { + return true; + } + + const order = resolveAuthProfileOrder({ + cfg, + store, + provider, + preferredProfile, + }); + for (const candidate of order) { + try { + const resolved = await resolveApiKeyForProfile({ + cfg, + store, + profileId: candidate, + agentDir: params.agentDir, + }); + if (resolved) { + return true; + } + } catch (err) { + log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); + } + } + + if (resolveEnvApiKey(provider)) { + return true; + } + if (resolveUsableCustomProviderApiKey({ cfg, provider })) { + return true; + } + if (resolveSyntheticLocalProviderAuth({ cfg, provider })) { + return true; + } + + return authOverride === undefined && normalizeProviderId(provider) === "amazon-bedrock"; +} + export async function getApiKeyForModel(params: { model: Model; cfg?: OpenClawConfig; diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index b2e282f3666..775e2ecb6be 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -1,5 +1,9 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { buildProviderRegistry, runCapability } from "./runner.js"; import { withAudioFixture } from "./runner.test-utils.js"; @@ -109,68 +113,71 @@ describe("runCapability auto audio entries", () => { }); it("uses mistral when only mistral key is configured", async () => { - const priorEnv: Record = { - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - GROQ_API_KEY: process.env.GROQ_API_KEY, - DEEPGRAM_API_KEY: process.env.DEEPGRAM_API_KEY, - GEMINI_API_KEY: process.env.GEMINI_API_KEY, - MISTRAL_API_KEY: process.env.MISTRAL_API_KEY, - }; - delete process.env.OPENAI_API_KEY; - delete process.env.GROQ_API_KEY; - delete process.env.DEEPGRAM_API_KEY; - delete process.env.GEMINI_API_KEY; - process.env.MISTRAL_API_KEY = "mistral-test-key"; // pragma: allowlist secret + const isolatedAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audio-agent-")); let runResult: Awaited> | undefined; try { - await withAudioFixture("openclaw-auto-audio-mistral", async ({ ctx, media, cache }) => { - const providerRegistry = buildProviderRegistry({ - openai: { - id: "openai", - capabilities: ["audio"], - transcribeAudio: async () => ({ text: "openai", model: "gpt-4o-mini-transcribe" }), - }, - mistral: { - id: "mistral", - capabilities: ["audio"], - transcribeAudio: async (req) => ({ text: "mistral", model: req.model ?? "unknown" }), - }, - }); - const cfg = { - models: { - providers: { + await withEnvAsync( + { + OPENAI_API_KEY: undefined, + GROQ_API_KEY: undefined, + DEEPGRAM_API_KEY: undefined, + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: undefined, + MISTRAL_API_KEY: "mistral-test-key", // pragma: allowlist secret + OPENCLAW_AGENT_DIR: isolatedAgentDir, + PI_CODING_AGENT_DIR: isolatedAgentDir, + }, + async () => { + await withAudioFixture("openclaw-auto-audio-mistral", async ({ ctx, media, cache }) => { + const providerRegistry = buildProviderRegistry({ + openai: { + id: "openai", + capabilities: ["audio"], + transcribeAudio: async () => ({ + text: "openai", + model: "gpt-4o-mini-transcribe", + }), + }, mistral: { - apiKey: "mistral-test-key", // pragma: allowlist secret - models: [], + id: "mistral", + capabilities: ["audio"], + transcribeAudio: async (req) => ({ + text: "mistral", + model: req.model ?? "unknown", + }), }, - }, - }, - tools: { - media: { - audio: { - enabled: true, + }); + const cfg = { + models: { + providers: { + mistral: { + apiKey: "mistral-test-key", // pragma: allowlist secret + models: [], + }, + }, }, - }, - }, - } as unknown as OpenClawConfig; + tools: { + media: { + audio: { + enabled: true, + }, + }, + }, + } as unknown as OpenClawConfig; - runResult = await runCapability({ - capability: "audio", - cfg, - ctx, - attachments: cache, - media, - providerRegistry, - }); - }); + runResult = await runCapability({ + capability: "audio", + cfg, + ctx, + attachments: cache, + media, + providerRegistry, + }); + }); + }, + ); } finally { - for (const [key, value] of Object.entries(priorEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } + await fs.rm(isolatedAgentDir, { recursive: true, force: true }); } if (!runResult) { throw new Error("Expected auto audio mistral result"); diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index c2ffe584448..a04cc6420fa 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -2,7 +2,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveApiKeyForProvider } from "../agents/model-auth.js"; +import { hasAvailableAuthForProvider } from "../agents/model-auth.js"; import { findModelInCatalog, loadModelCatalog, @@ -362,12 +362,16 @@ async function resolveKeyEntry(params: { if (capability === "video" && !provider.describeVideo) { return null; } - try { - await resolveApiKeyForProvider({ provider: providerId, cfg, agentDir }); - return { type: "provider" as const, provider: providerId, model }; - } catch { + if ( + !(await hasAvailableAuthForProvider({ + provider: providerId, + cfg, + agentDir, + })) + ) { return null; } + return { type: "provider" as const, provider: providerId, model }; }; if (capability === "image") { @@ -553,13 +557,12 @@ async function resolveActiveModelEntry(params: { if (params.capability === "video" && !provider.describeVideo) { return null; } - try { - await resolveApiKeyForProvider({ - provider: providerId, - cfg: params.cfg, - agentDir: params.agentDir, - }); - } catch { + const hasAuth = await hasAvailableAuthForProvider({ + provider: providerId, + cfg: params.cfg, + agentDir: params.agentDir, + }); + if (!hasAuth) { return null; } return { diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 90eab226cea..5c3992dfc55 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -74,62 +77,70 @@ describe("runCapability video provider wiring", () => { }); it("auto-selects moonshot for video when google is unavailable", async () => { - await withEnvAsync( - { - GEMINI_API_KEY: undefined, - MOONSHOT_API_KEY: undefined, - }, - async () => { - await withVideoFixture("openclaw-video-auto-moonshot", async ({ ctx, media, cache }) => { - const cfg = { - models: { - providers: { - moonshot: { - apiKey: "moonshot-key", // pragma: allowlist secret - models: [], + const isolatedAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-video-agent-")); + try { + await withEnvAsync( + { + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: undefined, + MOONSHOT_API_KEY: undefined, + OPENCLAW_AGENT_DIR: isolatedAgentDir, + PI_CODING_AGENT_DIR: isolatedAgentDir, + }, + async () => { + await withVideoFixture("openclaw-video-auto-moonshot", async ({ ctx, media, cache }) => { + const cfg = { + models: { + providers: { + moonshot: { + apiKey: "moonshot-key", // pragma: allowlist secret + models: [], + }, }, }, - }, - tools: { - media: { - video: { - enabled: true, + tools: { + media: { + video: { + enabled: true, + }, }, }, - }, - } as unknown as OpenClawConfig; + } as unknown as OpenClawConfig; - const result = await runCapability({ - capability: "video", - cfg, - ctx, - attachments: cache, - media, - providerRegistry: new Map([ - [ - "google", - { - id: "google", - capabilities: ["video"], - describeVideo: async () => ({ text: "google" }), - }, - ], - [ - "moonshot", - { - id: "moonshot", - capabilities: ["video"], - describeVideo: async () => ({ text: "moonshot", model: "kimi-k2.5" }), - }, - ], - ]), + const result = await runCapability({ + capability: "video", + cfg, + ctx, + attachments: cache, + media, + providerRegistry: new Map([ + [ + "google", + { + id: "google", + capabilities: ["video"], + describeVideo: async () => ({ text: "google" }), + }, + ], + [ + "moonshot", + { + id: "moonshot", + capabilities: ["video"], + describeVideo: async () => ({ text: "moonshot", model: "kimi-k2.5" }), + }, + ], + ]), + }); + + expect(result.decision.outcome).toBe("success"); + expect(result.outputs[0]?.provider).toBe("moonshot"); + expect(result.outputs[0]?.text).toBe("moonshot"); }); - - expect(result.decision.outcome).toBe("success"); - expect(result.outputs[0]?.provider).toBe("moonshot"); - expect(result.outputs[0]?.text).toBe("moonshot"); - }); - }, - ); + }, + ); + } finally { + await fs.rm(isolatedAgentDir, { recursive: true, force: true }); + } }); }); From d61c08efbb8c4f57792e803141361c196a33c08d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 10:48:42 +0000 Subject: [PATCH 43/56] Tests: scope Codex bundle loader fixture --- src/plugins/loader.test.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 82867213fdd..c70c0f5c3b3 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -348,7 +348,9 @@ afterEach(() => { describe("bundle plugins", () => { it("reports Codex bundles as loaded bundle plugins without importing runtime code", () => { + useNoBundledPlugins(); const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "sample-bundle"); mkdirSafe(path.join(bundleRoot, ".codex-plugin")); mkdirSafe(path.join(bundleRoot, "skills")); @@ -366,19 +368,22 @@ describe("bundle plugins", () => { "---\ndescription: fixture\n---\n", ); - const registry = loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "sample-bundle": { - enabled: true, + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: ["sample-bundle"], + config: { + plugins: { + entries: { + "sample-bundle": { + enabled: true, + }, }, }, }, - }, - cache: false, - }); + cache: false, + }), + ); const plugin = registry.plugins.find((entry) => entry.id === "sample-bundle"); expect(plugin?.status).toBe("loaded"); From fdfa98cda84285cc1ca825d8976d9007dc8fc667 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 11:03:17 +0000 Subject: [PATCH 44/56] Tests: isolate bundle surface fixtures --- src/plugins/loader.test.ts | 50 +++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index c70c0f5c3b3..740db382095 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -393,7 +393,9 @@ describe("bundle plugins", () => { }); it("treats Claude command roots and settings as supported bundle surfaces", () => { + useNoBundledPlugins(); const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-skills"); mkdirSafe(path.join(bundleRoot, "commands")); fs.writeFileSync( @@ -402,19 +404,22 @@ describe("bundle plugins", () => { ); fs.writeFileSync(path.join(bundleRoot, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); - const registry = loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "claude-skills": { - enabled: true, + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: ["claude-skills"], + config: { + plugins: { + entries: { + "claude-skills": { + enabled: true, + }, }, }, }, - }, - cache: false, - }); + cache: false, + }), + ); const plugin = registry.plugins.find((entry) => entry.id === "claude-skills"); expect(plugin?.status).toBe("loaded"); @@ -432,7 +437,9 @@ describe("bundle plugins", () => { }); it("treats Cursor command roots as supported bundle skill surfaces", () => { + useNoBundledPlugins(); const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "cursor-skills"); mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); @@ -448,19 +455,22 @@ describe("bundle plugins", () => { "---\ndescription: fixture\n---\n", ); - const registry = loadOpenClawPlugins({ - workspaceDir, - config: { - plugins: { - entries: { - "cursor-skills": { - enabled: true, + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: ["cursor-skills"], + config: { + plugins: { + entries: { + "cursor-skills": { + enabled: true, + }, }, }, }, - }, - cache: false, - }); + cache: false, + }), + ); const plugin = registry.plugins.find((entry) => entry.id === "cursor-skills"); expect(plugin?.status).toBe("loaded"); From 6a8f5bc12f72399264c03444b07949e5d5c140e9 Mon Sep 17 00:00:00 2001 From: Myeongwon Choi Date: Mon, 16 Mar 2026 20:18:34 +0900 Subject: [PATCH 45/56] feat(telegram): add configurable silent error replies (#19776) Port and complete #19776 on top of the current Telegram extension layout. Adds a default-off `channels.telegram.silentErrorReplies` setting. When enabled, Telegram bot replies marked as errors are delivered silently across the regular bot reply flow, native/slash command replies, and fallback sends. Thanks @auspic7 Co-authored-by: Myeongwon Choi <36367286+auspic7@users.noreply.github.com> Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com> --- CHANGELOG.md | 1 + .../telegram/src/bot-message-dispatch.test.ts | 37 +++++++++++++ .../telegram/src/bot-message-dispatch.ts | 3 ++ .../bot-native-commands.session-meta.test.ts | 35 +++++++++++- .../telegram/src/bot-native-commands.test.ts | 52 ++++++++++++++++++ .../telegram/src/bot-native-commands.ts | 4 +- .../telegram/src/bot/delivery.replies.ts | 15 ++++++ extensions/telegram/src/bot/delivery.send.ts | 6 +++ extensions/telegram/src/bot/delivery.test.ts | 54 +++++++++++++++++++ src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.telegram.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + 13 files changed, 211 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df03ad8fc5d..5a2873ccd64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. - Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. +- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF. - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 156d9296ae7..64fe301658a 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -298,6 +298,43 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); + it("sends error replies silently when silentErrorReplies is enabled", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + telegramCfg: { silentErrorReplies: true }, + }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + + it("keeps error replies notifying by default", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ context: createContext() }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: false, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + it("keeps block streaming enabled when session reasoning level is on", async () => { loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" }, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index a9c0e625508..61fc9f92fbf 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -465,6 +465,7 @@ export const dispatchTelegramMessage = async ({ linkPreview: telegramCfg.linkPreview, replyQuoteText, }; + const silentErrorReplies = telegramCfg.silentErrorReplies === true; const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => { if (payload.text === text) { return payload; @@ -476,6 +477,7 @@ export const dispatchTelegramMessage = async ({ ...deliveryBaseOptions, replies: [payload], onVoiceRecording: sendRecordVoice, + silent: silentErrorReplies && payload.isError === true, }); if (result.delivered) { deliveryState.markDelivered(); @@ -809,6 +811,7 @@ export const dispatchTelegramMessage = async ({ const result = await deliverReplies({ replies: [{ text: fallbackText }], ...deliveryBaseOptions, + silent: silentErrorReplies && dispatchError != null, }); sentFallback = result.delivered; } diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index db3fdc23bba..6160afccf01 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: { cfg: OpenClawConfig; allowFrom?: string[]; groupAllowFrom?: string[]; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; } { - const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params; + const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params; return registerAndResolveCommandHandlerBase({ commandName: "status", cfg, allowFrom: allowFrom ?? ["*"], groupAllowFrom: groupAllowFrom ?? [], useAccessGroups: true, + telegramCfg, resolveTelegramGroupConfig, }); } @@ -209,6 +211,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom: string[]; groupAllowFrom: string[]; useAccessGroups: boolean; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -220,6 +223,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, } = params; const commandHandlers = new Map(); @@ -239,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, }), }); @@ -254,6 +259,7 @@ function registerAndResolveCommandHandler(params: { allowFrom?: string[]; groupAllowFrom?: string[]; useAccessGroups?: boolean; + telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -265,6 +271,7 @@ function registerAndResolveCommandHandler(params: { allowFrom, groupAllowFrom, useAccessGroups, + telegramCfg, resolveTelegramGroupConfig, } = params; return registerAndResolveCommandHandlerBase({ @@ -273,6 +280,7 @@ function registerAndResolveCommandHandler(params: { allowFrom: allowFrom ?? [], groupAllowFrom: groupAllowFrom ?? [], useAccessGroups: useAccessGroups ?? true, + telegramCfg, resolveTelegramGroupConfig, }); } @@ -443,6 +451,31 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); }); + it("sends native command error replies silently when silentErrorReplies is enabled", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + telegramCfg: { silentErrorReplies: true }, + }); + await handler(buildStatusCommandContext()); + + const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as + | DeliverRepliesParams + | undefined; + expect(deliveredCall).toEqual( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); + it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index f6ebfe0dfe8..bc843293fc5 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -290,4 +290,56 @@ describe("registerTelegramNativeCommands", () => { ); expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); }); + + it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => { + const commandHandlers = new Map Promise>(); + + pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([ + { + name: "plug", + description: "Plugin command", + }, + ] as never); + pluginCommandMocks.matchPluginCommand.mockReturnValue({ + command: { key: "plug", requireAuth: false }, + args: undefined, + } as never); + pluginCommandMocks.executePluginCommand.mockResolvedValue({ + text: "plugin failed", + isError: true, + } as never); + + registerTelegramNativeCommands({ + ...buildParams({}), + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"], + telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig, + }); + + const handler = commandHandlers.get("plug"); + expect(handler).toBeTruthy(); + await handler?.({ + match: "", + message: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { id: 123, type: "private" }, + from: { id: 456, username: "alice" }, + }, + }); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ isError: true })], + }), + ); + }); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 7dd91f6ad63..64874d1f8eb 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -363,6 +363,7 @@ export const registerTelegramNativeCommands = ({ shouldSkipUpdate, opts, }: RegisterTelegramNativeCommandsParams) => { + const silentErrorReplies = telegramCfg.silentErrorReplies === true; const boundRoute = nativeEnabled && nativeSkillsEnabled ? resolveAgentRoute({ cfg, channel: "telegram", accountId }) @@ -734,7 +735,6 @@ export const registerTelegramNativeCommands = ({ typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined; - const deliveryState = { delivered: false, skippedNonSilent: 0, @@ -766,6 +766,7 @@ export const registerTelegramNativeCommands = ({ const result = await deliverReplies({ replies: [payload], ...deliveryBaseOptions, + silent: silentErrorReplies && payload.isError === true, }); if (result.delivered) { deliveryState.delivered = true; @@ -885,6 +886,7 @@ export const registerTelegramNativeCommands = ({ await deliverReplies({ replies: [result], ...deliveryBaseOptions, + silent: silentErrorReplies && result.isError === true, }); } }); diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 84d66fec12b..2dfc1c8e956 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -103,6 +103,7 @@ async function deliverTextReply(params: { replyMarkup?: ReturnType; replyQuoteText?: string; linkPreview?: boolean; + silent?: boolean; replyToId?: number; replyToMode: ReplyToMode; progress: DeliveryProgress; @@ -129,6 +130,7 @@ async function deliverTextReply(params: { textMode: "html", plainText: chunk.text, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup, }, ); @@ -149,6 +151,7 @@ async function sendPendingFollowUpText(params: { text: string; replyMarkup?: ReturnType; linkPreview?: boolean; + silent?: boolean; replyToId?: number; replyToMode: ReplyToMode; progress: DeliveryProgress; @@ -167,6 +170,7 @@ async function sendPendingFollowUpText(params: { textMode: "html", plainText: chunk.text, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup, }); }, @@ -196,6 +200,7 @@ async function sendTelegramVoiceFallbackText(opts: { replyToId?: number; thread?: TelegramThreadSpec | null; linkPreview?: boolean; + silent?: boolean; replyMarkup?: ReturnType; replyQuoteText?: string; }): Promise { @@ -213,6 +218,7 @@ async function sendTelegramVoiceFallbackText(opts: { textMode: "html", plainText: chunk.text, linkPreview: opts.linkPreview, + silent: opts.silent, replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined, }); if (firstDeliveredMessageId == null) { @@ -237,6 +243,7 @@ async function deliverMediaReply(params: { chunkText: ChunkTextFn; onVoiceRecording?: () => Promise | void; linkPreview?: boolean; + silent?: boolean; replyQuoteText?: string; replyMarkup?: ReturnType; replyToId?: number; @@ -282,6 +289,7 @@ async function deliverMediaReply(params: { ...buildTelegramSendParams({ replyToMessageId, thread: params.thread, + silent: params.silent, }), }; if (isGif) { @@ -375,6 +383,7 @@ async function deliverMediaReply(params: { replyToId: voiceFallbackReplyTo, thread: params.thread, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup: params.replyMarkup, replyQuoteText: params.replyQuoteText, }); @@ -404,6 +413,7 @@ async function deliverMediaReply(params: { replyToId: undefined, thread: params.thread, linkPreview: params.linkPreview, + silent: params.silent, replyMarkup: params.replyMarkup, }); } @@ -451,6 +461,7 @@ async function deliverMediaReply(params: { text: pendingFollowUpText, replyMarkup: params.replyMarkup, linkPreview: params.linkPreview, + silent: params.silent, replyToId: params.replyToId, replyToMode: params.replyToMode, progress: params.progress, @@ -557,6 +568,8 @@ export async function deliverReplies(params: { onVoiceRecording?: () => Promise | void; /** Controls whether link previews are shown. Default: true (previews enabled). */ linkPreview?: boolean; + /** When true, messages are sent with disable_notification. */ + silent?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; }): Promise<{ delivered: boolean }> { @@ -637,6 +650,7 @@ export async function deliverReplies(params: { replyMarkup, replyQuoteText: params.replyQuoteText, linkPreview: params.linkPreview, + silent: params.silent, replyToId, replyToMode: params.replyToMode, progress, @@ -654,6 +668,7 @@ export async function deliverReplies(params: { chunkText, onVoiceRecording: params.onVoiceRecording, linkPreview: params.linkPreview, + silent: params.silent, replyQuoteText: params.replyQuoteText, replyMarkup, replyToId, diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index f541495aa76..d8768899c28 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -76,6 +76,7 @@ export async function sendTelegramWithThreadFallback(params: { export function buildTelegramSendParams(opts?: { replyToMessageId?: number; thread?: TelegramThreadSpec | null; + silent?: boolean; }): Record { const threadParams = buildTelegramThreadParams(opts?.thread); const params: Record = {}; @@ -85,6 +86,9 @@ export function buildTelegramSendParams(opts?: { if (threadParams) { params.message_thread_id = threadParams.message_thread_id; } + if (opts?.silent === true) { + params.disable_notification = true; + } return params; } @@ -100,12 +104,14 @@ export async function sendTelegramText( textMode?: "markdown" | "html"; plainText?: string; linkPreview?: boolean; + silent?: boolean; replyMarkup?: ReturnType; }, ): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, thread: opts?.thread, + silent: opts?.silent, }); // Add link_preview_options when link preview is disabled. const linkPreviewEnabled = opts?.linkPreview ?? true; diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index a1dce34dceb..d9dbbf7e99b 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -211,6 +211,30 @@ describe("deliverReplies", () => { ); }); + it("sets disable_notification when silent is true", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "hello" }], + runtime, + bot, + silent: true, + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.objectContaining({ + disable_notification: true, + }), + ); + }); + it("emits internal message:sent when session hook context is available", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } }); @@ -645,6 +669,36 @@ describe("deliverReplies", () => { ); }); + it("keeps disable_notification on voice fallback text when silent is true", async () => { + const runtime = createRuntime(); + const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError()); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = createBot({ sendVoice, sendMessage }); + + mockMediaLoad("note.ogg", "audio/ogg", "voice"); + + await deliverWith({ + replies: [ + { mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true }, + ], + runtime, + bot, + silent: true, + }); + + expect(sendVoice).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.stringContaining("Hello there"), + expect.objectContaining({ + disable_notification: true, + }), + ); + }); + it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => { const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ voiceError: createVoiceMessagesForbiddenError(), diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 627dccb5049..3054b3f2ed2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1530,6 +1530,8 @@ export const FIELD_HELP: Record = { "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "channels.telegram.timeoutSeconds": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", + "channels.telegram.silentErrorReplies": + "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.", "channels.telegram.threadBindings.enabled": "Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.", "channels.telegram.threadBindings.idleHours": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9541ad3b10a..2e9ebe1189c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -737,6 +737,7 @@ export const FIELD_LABELS: Record = { "channels.telegram.retry.jitter": "Telegram Retry Jitter", "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.silentErrorReplies": "Telegram Silent Error Replies", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.telegram.execApprovals": "Telegram Exec Approvals", "channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index fe1c5be3962..aa40cec7077 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -188,6 +188,8 @@ export type TelegramAccountConfig = { healthMonitor?: ChannelHealthMonitorConfig; /** Controls whether link previews are shown in outbound messages. Default: true. */ linkPreview?: boolean; + /** Send Telegram bot error replies silently (no notification sound). Default: false. */ + silentErrorReplies?: boolean; /** * Per-channel outbound response prefix override. * diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index da81ef61a4f..e65030d8f38 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -277,6 +277,7 @@ export const TelegramAccountSchemaBase = z heartbeat: ChannelHeartbeatVisibilitySchema, healthMonitor: ChannelHealthMonitorSchema, linkPreview: z.boolean().optional(), + silentErrorReplies: z.boolean().optional(), responsePrefix: z.string().optional(), ackReaction: z.string().optional(), }) From fba394c56bc8f7ac98d31d373d9a5b4a51e87247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=80=80=E5=AE=8F?= Date: Fri, 13 Mar 2026 17:37:19 +0800 Subject: [PATCH 46/56] fix(ui): auto load Usage tab data on navigation --- ui/src/ui/app-settings.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 23f1de68caa..2a9c2685589 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -219,6 +219,9 @@ export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "instances") { await loadPresence(host as unknown as OpenClawApp); } + if (host.tab === "usage") { + await loadUsage(host as unknown as OpenClawApp); + } if (host.tab === "sessions") { await loadSessions(host as unknown as OpenClawApp); } From 8b438a308b82a52b055dd7cceccfd18b98512424 Mon Sep 17 00:00:00 2001 From: ImJarvis by LukeF <92253590+ImLukeF@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:44:10 +1100 Subject: [PATCH 47/56] fix(telegram): keep silent error fallback replies quiet --- .../telegram/src/bot-message-dispatch.test.ts | 23 +++++++++++++++++++ .../telegram/src/bot-message-dispatch.ts | 11 +++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 64fe301658a..ea1c098e7b6 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -335,6 +335,29 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); + it("keeps fallback replies silent after an error reply is skipped", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + dispatcherOptions.onSkip?.( + { text: "oops", isError: true }, + { kind: "final", reason: "empty" }, + ); + return { queuedFinal: false }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + telegramCfg: { silentErrorReplies: true }, + }); + + expect(deliverReplies).toHaveBeenLastCalledWith( + expect.objectContaining({ + silent: true, + replies: [expect.objectContaining({ text: expect.any(String) })], + }), + ); + }); + it("keeps block streaming enabled when session reasoning level is on", async () => { loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" }, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 61fc9f92fbf..9b603393450 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -515,6 +515,7 @@ export const dispatchTelegramMessage = async ({ }); let queuedFinal = false; + let hadErrorReplyFailureOrSkip = false; if (statusReactionController) { void statusReactionController.setThinking(); @@ -541,6 +542,9 @@ export const dispatchTelegramMessage = async ({ ...prefixOptions, typingCallbacks, deliver: async (payload, info) => { + if (payload.isError === true) { + hadErrorReplyFailureOrSkip = true; + } if (info.kind === "final") { // Assistant callbacks are fire-and-forget; ensure queued boundary // rotations/partials are applied before final delivery mapping. @@ -654,7 +658,10 @@ export const dispatchTelegramMessage = async ({ await flushBufferedFinalAnswer(); } }, - onSkip: (_payload, info) => { + onSkip: (payload, info) => { + if (payload.isError === true) { + hadErrorReplyFailureOrSkip = true; + } if (info.reason !== "silent") { deliveryState.markNonSilentSkip(); } @@ -811,7 +818,7 @@ export const dispatchTelegramMessage = async ({ const result = await deliverReplies({ replies: [{ text: fallbackText }], ...deliveryBaseOptions, - silent: silentErrorReplies && dispatchError != null, + silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip), }); sentFallback = result.delivered; } From ccba943738e02833fb28721a1144d5cc97d34aa9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 17:16:48 +0530 Subject: [PATCH 48/56] test(gateway): restore agent request route mock --- src/gateway/server-node-events.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 07425808cea..a5a7578ddbc 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -59,6 +59,13 @@ vi.mock("../infra/device-identity.js", () => ({ })); vi.mock("./session-utils.js", () => ({ loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)), + migrateAndPruneGatewaySessionStoreKey: vi.fn( + ({ key, store }: { key: string; store: Record }) => ({ + target: { canonicalKey: key, storeKeys: [key] }, + primaryKey: key, + entry: store[key], + }), + ), pruneLegacyStoreKeys: vi.fn(), resolveGatewaySessionStoreTarget: vi.fn(({ key }: { key: string }) => ({ canonicalKey: key, From d6aa9b516e5903d99e584fc3cf6c1665dce3434f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 11:39:15 +0000 Subject: [PATCH 49/56] Cron: isolate active-model delivery tests --- ...onse-has-heartbeat-ok-but-includes.test.ts | 2 ++ .../isolated-agent.model-formatting.test.ts | 2 ++ ...p-recipient-besteffortdeliver-true.test.ts | 2 ++ ....uses-last-non-empty-agent-text-as.test.ts | 19 +++++++++-------- src/cron/isolated-agent/run.ts | 21 +++++++++++++++++++ 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 5678b75e4f7..6316f394b9c 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -1,6 +1,7 @@ import "./isolated-agent.mocks.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import * as modelSelection from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; @@ -72,6 +73,7 @@ async function runTelegramAnnounceTurn(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined); setupIsolatedAgentTurnMocks({ fast: true }); }); diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index b09a9db5ea1..c783247e6f3 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -1,6 +1,7 @@ import "./isolated-agent.mocks.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; +import * as modelSelection from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; @@ -125,6 +126,7 @@ async function expectInvalidModel(home: string, model: string) { describe("cron model formatting and precedence edge cases", () => { beforeEach(() => { + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined); vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 5abbb453f35..639c207459e 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -1,6 +1,7 @@ import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as modelSelection from "../agents/model-selection.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; import { @@ -261,6 +262,7 @@ async function assertExplicitTelegramTargetDelivery(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { + vi.spyOn(modelSelection, "resolveThinkingDefault").mockReturnValue(undefined); setupIsolatedAgentTurnMocks(); }); diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 2a4b786f99c..5830029d9e7 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; +import * as modelSelection from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { CliDeps } from "../cli/deps.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; @@ -15,6 +16,10 @@ import { } from "./isolated-agent.test-harness.js"; import type { CronJob } from "./types.js"; +let resolveThinkingDefaultSpy: ReturnType< + typeof vi.spyOn +>; + function makeDeps(): CliDeps { return { sendMessageSlack: vi.fn(), @@ -163,6 +168,9 @@ async function runStoredOverrideAndExpectModel(params: { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { + resolveThinkingDefaultSpy = vi + .spyOn(modelSelection, "resolveThinkingDefault") + .mockReturnValue(undefined); vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); @@ -503,16 +511,9 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("defaults thinking to low for reasoning-capable models", async () => { + it("passes through the resolved default thinking level", async () => { await withTempHome(async (home) => { - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - id: "claude-opus-4-5", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - ]); + resolveThinkingDefaultSpy.mockReturnValueOnce("low"); await runCronTurn(home, { jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 8a074338da7..9f3f28584e3 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -171,6 +171,27 @@ async function resolveCronDeliveryContext(params: { deliveryContract: IsolatedDeliveryContract; }) { const deliveryPlan = resolveCronDeliveryPlan(params.job); + if (!deliveryPlan.requested) { + const resolvedDelivery = { + ok: false as const, + channel: undefined, + to: undefined, + accountId: undefined, + threadId: undefined, + mode: "implicit" as const, + error: new Error("cron delivery not requested"), + }; + return { + deliveryPlan, + deliveryRequested: false, + resolvedDelivery, + toolPolicy: resolveCronToolPolicy({ + deliveryRequested: false, + resolvedDelivery, + deliveryContract: params.deliveryContract, + }), + }; + } const resolvedDelivery = await resolveDeliveryTarget(params.cfg, params.agentId, { channel: deliveryPlan.channel ?? "last", to: deliveryPlan.to, From 5e4851ae2bccf9e1f4691b761b6e183a114c4964 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 11:39:20 +0000 Subject: [PATCH 50/56] Tests: align media auth fixture with selection checks --- src/media-understanding/apply.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 10e5da610cc..b979a2ac4ad 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -12,12 +12,23 @@ import { withEnvAsync } from "../test-utils/env.js"; import { clearMediaUnderstandingBinaryCacheForTests } from "./runner.js"; import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; -vi.mock("../agents/model-auth.js", () => ({ - resolveApiKeyForProvider: vi.fn(async () => ({ +const resolveApiKeyForProviderMock = vi.hoisted(() => + vi.fn(async () => ({ apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), +); +const hasAvailableAuthForProviderMock = vi.hoisted(() => + vi.fn(async (...args: unknown[]) => { + const resolved = await resolveApiKeyForProviderMock(...args); + return Boolean(resolved?.apiKey); + }), +); + +vi.mock("../agents/model-auth.js", () => ({ + resolveApiKeyForProvider: resolveApiKeyForProviderMock, + hasAvailableAuthForProvider: hasAvailableAuthForProviderMock, requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => { if (auth?.apiKey) { return auth.apiKey; @@ -247,6 +258,7 @@ describe("applyMediaUnderstanding", () => { source: "test", mode: "api-key", }); + hasAvailableAuthForProviderMock.mockClear(); mockedFetchRemoteMedia.mockClear(); mockedRunExec.mockReset(); mockedFetchRemoteMedia.mockResolvedValue({ From 4c8853122aa34c035bf2e788d61ab1e7a2d295ac Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 11:39:26 +0000 Subject: [PATCH 51/56] Plugins: preserve lazy runtime provider resolution --- src/plugins/loader.test.ts | 18 +++++++++----- src/plugins/loader.ts | 37 +++++++++++++++++++++++++--- src/plugins/provider-runtime.test.ts | 23 +++++++++++++++++ src/plugins/provider-runtime.ts | 16 +++++++----- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 740db382095..5c5b0ee4717 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2800,6 +2800,7 @@ module.exports = { it("preserves runtime reflection semantics when runtime is lazily initialized", () => { useNoBundledPlugins(); + const stateDir = makeTempDir(); const plugin = writePlugin({ id: "runtime-introspection", filename: "runtime-introspection.cjs", @@ -2818,12 +2819,17 @@ module.exports = { } };`, }); - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["runtime-introspection"], - }, - }); + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["runtime-introspection"], + }, + options: { + onlyPluginIds: ["runtime-introspection"], + }, + }), + ); const record = registry.plugins.find((entry) => entry.id === "runtime-introspection"); expect(record?.status).toBe("loaded"); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index da9bcd3e993..2fff62b0b95 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -60,6 +60,21 @@ export type PluginLoadOptions = { const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; const registryCache = new Map(); const openAllowlistWarningCache = new Set(); +const LAZY_RUNTIME_REFLECTION_KEYS = [ + "version", + "config", + "subagent", + "system", + "media", + "tts", + "stt", + "tools", + "channel", + "events", + "logging", + "state", + "modelAuth", +] as const satisfies readonly (keyof PluginRuntime)[]; export function clearPluginLoaderCache(): void { registryCache.clear(); @@ -870,6 +885,22 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions); return resolvedRuntime; }; + const lazyRuntimeReflectionKeySet = new Set(LAZY_RUNTIME_REFLECTION_KEYS); + const resolveLazyRuntimeDescriptor = (prop: PropertyKey): PropertyDescriptor | undefined => { + if (!lazyRuntimeReflectionKeySet.has(prop)) { + return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop); + } + return { + configurable: true, + enumerable: true, + get() { + return Reflect.get(resolveRuntime() as object, prop); + }, + set(value: unknown) { + Reflect.set(resolveRuntime() as object, prop, value); + }, + }; + }; const runtime = new Proxy({} as PluginRuntime, { get(_target, prop, receiver) { return Reflect.get(resolveRuntime(), prop, receiver); @@ -878,13 +909,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return Reflect.set(resolveRuntime(), prop, value, receiver); }, has(_target, prop) { - return Reflect.has(resolveRuntime(), prop); + return lazyRuntimeReflectionKeySet.has(prop) || Reflect.has(resolveRuntime(), prop); }, ownKeys() { - return Reflect.ownKeys(resolveRuntime() as object); + return [...LAZY_RUNTIME_REFLECTION_KEYS]; }, getOwnPropertyDescriptor(_target, prop) { - return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop); + return resolveLazyRuntimeDescriptor(prop); }, defineProperty(_target, prop, attributes) { return Reflect.defineProperty(resolveRuntime() as object, prop, attributes); diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 266abe24556..dec7be0b53d 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -58,6 +58,7 @@ describe("provider-runtime", () => { }); it("matches providers by alias for runtime hook lookup", () => { + resolveOwningPluginIdsForProviderMock.mockReturnValue(["openrouter"]); resolvePluginProvidersMock.mockReturnValue([ { id: "openrouter", @@ -77,13 +78,35 @@ describe("provider-runtime", () => { ); expect(resolvePluginProvidersMock).toHaveBeenCalledWith( expect.objectContaining({ + onlyPluginIds: ["openrouter"], bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }), ); }); + it("skips plugin loading when the provider has no owning plugin", () => { + const plugin = resolveProviderRuntimePlugin({ provider: "anthropic" }); + + expect(plugin).toBeUndefined(); + expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "anthropic", + }), + ); + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + }); + it("dispatches runtime hooks for the matched provider", async () => { + resolveOwningPluginIdsForProviderMock.mockImplementation((params: { provider?: string }) => { + if (params.provider === "demo") { + return ["demo"]; + } + if (params.provider === "openai") { + return ["openai"]; + } + return undefined; + }); const prepareDynamicModel = vi.fn(async () => undefined); const prepareRuntimeAuth = vi.fn(async () => ({ apiKey: "runtime-token", diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 189b5ccef0c..3d1bd77f6d9 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -54,14 +54,18 @@ export function resolveProviderRuntimePlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { + const owningPluginIds = resolveOwningPluginIdsForProvider({ + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + if (!owningPluginIds || owningPluginIds.length === 0) { + return undefined; + } return resolveProviderPluginsForHooks({ ...params, - onlyPluginIds: resolveOwningPluginIdsForProvider({ - provider: params.provider, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }), + onlyPluginIds: owningPluginIds, }).find((plugin) => matchesProviderId(plugin, params.provider)); } From 092afc850dc6e26615b74c3d3d6740b169e19944 Mon Sep 17 00:00:00 2001 From: huntharo Date: Mon, 16 Mar 2026 07:51:44 -0400 Subject: [PATCH 52/56] Bootstrap: report nested entry import misses --- openclaw.mjs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/openclaw.mjs b/openclaw.mjs index 248db52ea44..099c7f6a406 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import module from "node:module"; +import { fileURLToPath } from "node:url"; const MIN_NODE_MAJOR = 22; const MIN_NODE_MINOR = 12; @@ -47,6 +48,20 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) { const isModuleNotFoundError = (err) => err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND"; +const isDirectModuleNotFoundError = (err, specifier) => { + if (!isModuleNotFoundError(err)) { + return false; + } + + const expectedUrl = new URL(specifier, import.meta.url); + if ("url" in err && err.url === expectedUrl.href) { + return true; + } + + const message = "message" in err && typeof err.message === "string" ? err.message : ""; + return message.includes(fileURLToPath(expectedUrl)); +}; + const installProcessWarningFilter = async () => { // Keep bootstrap warnings consistent with the TypeScript runtime. for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) { @@ -57,7 +72,7 @@ const installProcessWarningFilter = async () => { return; } } catch (err) { - if (isModuleNotFoundError(err)) { + if (isDirectModuleNotFoundError(err, specifier)) { continue; } throw err; @@ -72,8 +87,8 @@ const tryImport = async (specifier) => { await import(specifier); return true; } catch (err) { - // Only swallow missing-module errors; rethrow real runtime errors. - if (isModuleNotFoundError(err)) { + // Only swallow direct entry misses; rethrow transitive resolution failures. + if (isDirectModuleNotFoundError(err, specifier)) { return false; } throw err; From 55f6d2d1adfd2a20037009d62d9509ba1a8854d1 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 17:24:37 +0530 Subject: [PATCH 53/56] fix(channels): parse bundled targets without plugin registry --- src/channels/plugins/target-parsing.test.ts | 59 +++++++++++++++++++++ src/channels/plugins/target-parsing.ts | 23 +++++++- 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/channels/plugins/target-parsing.test.ts diff --git a/src/channels/plugins/target-parsing.test.ts b/src/channels/plugins/target-parsing.test.ts new file mode 100644 index 00000000000..a1c5d278fde --- /dev/null +++ b/src/channels/plugins/target-parsing.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { parseExplicitTargetForChannel } from "./target-parsing.js"; + +describe("parseExplicitTargetForChannel", () => { + beforeEach(() => { + setActivePluginRegistry(createTestRegistry([])); + }); + + it("parses bundled Telegram targets without an active Telegram registry entry", () => { + expect(parseExplicitTargetForChannel("telegram", "telegram:group:-100123:topic:77")).toEqual({ + to: "-100123", + threadId: 77, + chatType: "group", + }); + expect(parseExplicitTargetForChannel("telegram", "-100123")).toEqual({ + to: "-100123", + chatType: "group", + }); + }); + + it("parses registered non-bundled channel targets via the active plugin contract", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "msteams", + source: "test", + plugin: { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "test stub", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + messaging: { + parseExplicitTarget: ({ raw }: { raw: string }) => ({ + to: raw.trim().toUpperCase(), + chatType: "direct" as const, + }), + }, + }, + }, + ]), + ); + + expect(parseExplicitTargetForChannel("msteams", "team-room")).toEqual({ + to: "TEAM-ROOM", + chatType: "direct", + }); + }); +}); diff --git a/src/channels/plugins/target-parsing.ts b/src/channels/plugins/target-parsing.ts index 5d7fd6e28da..beea68adca3 100644 --- a/src/channels/plugins/target-parsing.ts +++ b/src/channels/plugins/target-parsing.ts @@ -1,4 +1,7 @@ +import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import type { ChatType } from "../chat-type.js"; +import { normalizeChatChannelId } from "../registry.js"; import { getChannelPlugin, normalizeChannelId } from "./registry.js"; export type ParsedChannelExplicitTarget = { @@ -11,10 +14,28 @@ function parseWithPlugin( rawChannel: string, rawTarget: string, ): ParsedChannelExplicitTarget | null { - const channel = normalizeChannelId(rawChannel); + const channel = normalizeChatChannelId(rawChannel) ?? normalizeChannelId(rawChannel); if (!channel) { return null; } + if (channel === "telegram") { + const target = parseTelegramTarget(rawTarget); + return { + to: target.chatId, + ...(target.messageThreadId != null ? { threadId: target.messageThreadId } : {}), + ...(target.chatType === "unknown" ? {} : { chatType: target.chatType }), + }; + } + if (channel === "discord") { + const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + to: target.id, + chatType: target.kind === "user" ? "direct" : "channel", + }; + } return getChannelPlugin(channel)?.messaging?.parseExplicitTarget?.({ raw: rawTarget }) ?? null; } From e78b51baea9ecc240aca52e41478cd4e85377468 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 17:24:44 +0530 Subject: [PATCH 54/56] test(telegram): cover shared parsing without registry --- src/auto-reply/reply/reply-payloads.test.ts | 16 ++++++++++++++++ src/plugins/commands.test.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/auto-reply/reply/reply-payloads.test.ts b/src/auto-reply/reply/reply-payloads.test.ts index 614fcd37951..8664eec5c72 100644 --- a/src/auto-reply/reply/reply-payloads.test.ts +++ b/src/auto-reply/reply/reply-payloads.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { filterMessagingToolMediaDuplicates, shouldSuppressMessagingToolReplies, @@ -153,4 +155,18 @@ describe("shouldSuppressMessagingToolReplies", () => { }), ).toBe(true); }); + + it("suppresses telegram replies even when the active plugin registry omits telegram", () => { + setActivePluginRegistry(createTestRegistry([])); + + expect( + shouldSuppressMessagingToolReplies({ + messageProvider: "telegram", + originatingTo: "telegram:group:-100123:topic:77", + messagingToolSentTargets: [ + { tool: "message", provider: "telegram", to: "-100123", threadId: "77" }, + ], + }), + ).toBe(true); + }); }); diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 6f371305a81..d95a98b18d9 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -136,6 +136,22 @@ describe("registerPluginCommand", () => { }); }); + it("resolves Telegram topic command bindings without a Telegram registry entry", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "telegram", + from: "telegram:group:-100123", + to: "telegram:group:-100123:topic:77", + accountId: "default", + }), + ).toEqual({ + channel: "telegram", + accountId: "default", + conversationId: "-100123", + threadId: 77, + }); + }); + it("does not resolve binding conversations for unsupported command channels", () => { expect( __testing.resolveBindingConversationFromCommand({ From 9fc6c1929aaff1196797e78b45c7c9532fff5dca Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:04:32 +0000 Subject: [PATCH 55/56] Plugin SDK: split setup and sandbox subpaths --- extensions/ollama/index.ts | 27 ++++--- extensions/openshell/index.ts | 2 +- extensions/openshell/src/backend.ts | 4 +- extensions/openshell/src/cli.ts | 4 +- extensions/openshell/src/fs-bridge.ts | 2 +- extensions/openshell/src/remote-fs-bridge.ts | 2 +- extensions/sglang/index.ts | 59 ++++++++------ extensions/vllm/index.ts | 59 ++++++++------ package.json | 8 ++ src/agents/ollama-defaults.ts | 1 + src/agents/sglang-defaults.ts | 4 + src/agents/vllm-defaults.ts | 4 + src/plugin-sdk/core.ts | 79 ------------------- src/plugin-sdk/provider-setup.ts | 37 +++++++++ src/plugin-sdk/sandbox.ts | 45 +++++++++++ src/plugin-sdk/subpaths.test.ts | 20 +++++ .../contracts/discovery.contract.test.ts | 4 +- 17 files changed, 214 insertions(+), 147 deletions(-) create mode 100644 src/agents/ollama-defaults.ts create mode 100644 src/agents/sglang-defaults.ts create mode 100644 src/agents/vllm-defaults.ts create mode 100644 src/plugin-sdk/provider-setup.ts create mode 100644 src/plugin-sdk/sandbox.ts diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 0609c597dc4..f19c4cfbc0a 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -1,10 +1,5 @@ import { - buildOllamaProvider, emptyPluginConfigSchema, - ensureOllamaModelPulled, - OLLAMA_DEFAULT_BASE_URL, - promptAndConfigureOllama, - configureOllamaNonInteractive, type OpenClawPluginApi, type ProviderAuthContext, type ProviderAuthMethodNonInteractiveContext, @@ -12,10 +7,15 @@ import { type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/core"; import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../../src/agents/ollama-defaults.js"; const PROVIDER_ID = "ollama"; const DEFAULT_API_KEY = "ollama-local"; +async function loadProviderSetup() { + return await import("openclaw/plugin-sdk/provider-setup"); +} + const ollamaPlugin = { id: "ollama", name: "Ollama Provider", @@ -34,7 +34,8 @@ const ollamaPlugin = { hint: "Cloud and local open models", kind: "custom", run: async (ctx: ProviderAuthContext): Promise => { - const result = await promptAndConfigureOllama({ + const providerSetup = await loadProviderSetup(); + const result = await providerSetup.promptAndConfigureOllama({ cfg: ctx.config, prompter: ctx.prompter, }); @@ -53,12 +54,14 @@ const ollamaPlugin = { defaultModel: `ollama/${result.defaultModelId}`, }; }, - runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => - configureOllamaNonInteractive({ + runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.configureOllamaNonInteractive({ nextConfig: ctx.config, opts: ctx.opts, runtime: ctx.runtime, - }), + }); + }, }, ], discovery: { @@ -81,7 +84,8 @@ const ollamaPlugin = { }; } - const provider = await buildOllamaProvider(explicit?.baseUrl, { + const providerSetup = await loadProviderSetup(); + const provider = await providerSetup.buildOllamaProvider(explicit?.baseUrl, { quiet: !ollamaKey && !explicit, }); if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) { @@ -115,7 +119,8 @@ const ollamaPlugin = { if (!model.startsWith("ollama/")) { return; } - await ensureOllamaModelPulled({ config, prompter }); + const providerSetup = await loadProviderSetup(); + await providerSetup.ensureOllamaModelPulled({ config, prompter }); }, }); }, diff --git a/extensions/openshell/index.ts b/extensions/openshell/index.ts index 910abe31b44..35e00cf0f52 100644 --- a/extensions/openshell/index.ts +++ b/extensions/openshell/index.ts @@ -1,5 +1,5 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { registerSandboxBackend } from "openclaw/plugin-sdk/core"; +import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; import { createOpenShellSandboxBackendFactory, createOpenShellSandboxBackendManager, diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index d87b1c92af8..847e8022e24 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -11,13 +11,13 @@ import type { SandboxBackendHandle, SandboxBackendManager, SshSandboxSession, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; import { createRemoteShellSandboxFsBridge, disposeSshSandboxSession, resolvePreferredOpenClawTmpDir, runSshSandboxCommand, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; import { buildExecRemoteCommand, buildRemoteCommand, diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 411166520e7..a35b6aba69f 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -4,10 +4,10 @@ import { runPluginCommandWithTimeout, shellEscape, type SshSandboxSession, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; import type { ResolvedOpenShellPluginConfig } from "./config.js"; -export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core"; +export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox"; export type OpenShellExecContext = { config: ResolvedOpenShellPluginConfig; diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts index 00257e81be4..195e8ec555e 100644 --- a/extensions/openshell/src/fs-bridge.ts +++ b/extensions/openshell/src/fs-bridge.ts @@ -5,7 +5,7 @@ import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; import type { OpenShellSandboxBackend } from "./backend.js"; import { movePathWithCopyFallback } from "./mirror.js"; diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts index 9cc1ddf704d..eeee51b7ee6 100644 --- a/extensions/openshell/src/remote-fs-bridge.ts +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -3,7 +3,7 @@ import { type RemoteShellSandboxHandle, type SandboxContext, type SandboxFsBridge, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/sandbox"; export function createOpenShellRemoteFsBridge(params: { sandbox: SandboxContext; diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts index 5672034ad9c..38ecf508b07 100644 --- a/extensions/sglang/index.ts +++ b/extensions/sglang/index.ts @@ -1,15 +1,20 @@ import { - buildSglangProvider, - configureOpenAICompatibleSelfHostedProviderNonInteractive, - discoverOpenAICompatibleSelfHostedProvider, emptyPluginConfigSchema, - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, type OpenClawPluginApi, type ProviderAuthMethodNonInteractiveContext, } from "openclaw/plugin-sdk/core"; +import { + SGLANG_DEFAULT_API_KEY_ENV_VAR, + SGLANG_DEFAULT_BASE_URL, + SGLANG_MODEL_PLACEHOLDER, + SGLANG_PROVIDER_LABEL, +} from "../../src/agents/sglang-defaults.js"; const PROVIDER_ID = "sglang"; -const DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1"; + +async function loadProviderSetup() { + return await import("openclaw/plugin-sdk/provider-setup"); +} const sglangPlugin = { id: "sglang", @@ -25,38 +30,44 @@ const sglangPlugin = { auth: [ { id: "custom", - label: "SGLang", + label: SGLANG_PROVIDER_LABEL, hint: "Fast self-hosted OpenAI-compatible server", kind: "custom", - run: async (ctx) => - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ cfg: ctx.config, prompter: ctx.prompter, providerId: PROVIDER_ID, - providerLabel: "SGLang", - defaultBaseUrl: DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "SGLANG_API_KEY", - modelPlaceholder: "Qwen/Qwen3-8B", - }), - runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => - configureOpenAICompatibleSelfHostedProviderNonInteractive({ + providerLabel: SGLANG_PROVIDER_LABEL, + defaultBaseUrl: SGLANG_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: SGLANG_MODEL_PLACEHOLDER, + }); + }, + runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({ ctx, providerId: PROVIDER_ID, - providerLabel: "SGLang", - defaultBaseUrl: DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "SGLANG_API_KEY", - modelPlaceholder: "Qwen/Qwen3-8B", - }), + providerLabel: SGLANG_PROVIDER_LABEL, + defaultBaseUrl: SGLANG_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: SGLANG_MODEL_PLACEHOLDER, + }); + }, }, ], discovery: { order: "late", - run: async (ctx) => - discoverOpenAICompatibleSelfHostedProvider({ + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({ ctx, providerId: PROVIDER_ID, - buildProvider: buildSglangProvider, - }), + buildProvider: providerSetup.buildSglangProvider, + }); + }, }, wizard: { setup: { diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts index 571692c6585..8ab11b9f1bc 100644 --- a/extensions/vllm/index.ts +++ b/extensions/vllm/index.ts @@ -1,15 +1,20 @@ import { - buildVllmProvider, - configureOpenAICompatibleSelfHostedProviderNonInteractive, - discoverOpenAICompatibleSelfHostedProvider, emptyPluginConfigSchema, - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, type OpenClawPluginApi, type ProviderAuthMethodNonInteractiveContext, } from "openclaw/plugin-sdk/core"; +import { + VLLM_DEFAULT_API_KEY_ENV_VAR, + VLLM_DEFAULT_BASE_URL, + VLLM_MODEL_PLACEHOLDER, + VLLM_PROVIDER_LABEL, +} from "../../src/agents/vllm-defaults.js"; const PROVIDER_ID = "vllm"; -const DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1"; + +async function loadProviderSetup() { + return await import("openclaw/plugin-sdk/provider-setup"); +} const vllmPlugin = { id: "vllm", @@ -25,38 +30,44 @@ const vllmPlugin = { auth: [ { id: "custom", - label: "vLLM", + label: VLLM_PROVIDER_LABEL, hint: "Local/self-hosted OpenAI-compatible server", kind: "custom", - run: async (ctx) => - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ cfg: ctx.config, prompter: ctx.prompter, providerId: PROVIDER_ID, - providerLabel: "vLLM", - defaultBaseUrl: DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "VLLM_API_KEY", - modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", - }), - runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => - configureOpenAICompatibleSelfHostedProviderNonInteractive({ + providerLabel: VLLM_PROVIDER_LABEL, + defaultBaseUrl: VLLM_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: VLLM_MODEL_PLACEHOLDER, + }); + }, + runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({ ctx, providerId: PROVIDER_ID, - providerLabel: "vLLM", - defaultBaseUrl: DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "VLLM_API_KEY", - modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", - }), + providerLabel: VLLM_PROVIDER_LABEL, + defaultBaseUrl: VLLM_DEFAULT_BASE_URL, + defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: VLLM_MODEL_PLACEHOLDER, + }); + }, }, ], discovery: { order: "late", - run: async (ctx) => - discoverOpenAICompatibleSelfHostedProvider({ + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({ ctx, providerId: PROVIDER_ID, - buildProvider: buildVllmProvider, - }), + buildProvider: providerSetup.buildVllmProvider, + }); + }, }, wizard: { setup: { diff --git a/package.json b/package.json index ebaa3607ad1..4f1c502b586 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,14 @@ "types": "./dist/plugin-sdk/compat.d.ts", "default": "./dist/plugin-sdk/compat.js" }, + "./plugin-sdk/provider-setup": { + "types": "./dist/plugin-sdk/provider-setup.d.ts", + "default": "./dist/plugin-sdk/provider-setup.js" + }, + "./plugin-sdk/sandbox": { + "types": "./dist/plugin-sdk/sandbox.d.ts", + "default": "./dist/plugin-sdk/sandbox.js" + }, "./plugin-sdk/routing": { "types": "./dist/plugin-sdk/routing.d.ts", "default": "./dist/plugin-sdk/routing.js" diff --git a/src/agents/ollama-defaults.ts b/src/agents/ollama-defaults.ts new file mode 100644 index 00000000000..434efeb8dcb --- /dev/null +++ b/src/agents/ollama-defaults.ts @@ -0,0 +1 @@ +export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; diff --git a/src/agents/sglang-defaults.ts b/src/agents/sglang-defaults.ts new file mode 100644 index 00000000000..d91355a8257 --- /dev/null +++ b/src/agents/sglang-defaults.ts @@ -0,0 +1,4 @@ +export const SGLANG_DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1"; +export const SGLANG_PROVIDER_LABEL = "SGLang"; +export const SGLANG_DEFAULT_API_KEY_ENV_VAR = "SGLANG_API_KEY"; +export const SGLANG_MODEL_PLACEHOLDER = "Qwen/Qwen3-8B"; diff --git a/src/agents/vllm-defaults.ts b/src/agents/vllm-defaults.ts new file mode 100644 index 00000000000..3f2498221f0 --- /dev/null +++ b/src/agents/vllm-defaults.ts @@ -0,0 +1,4 @@ +export const VLLM_DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1"; +export const VLLM_PROVIDER_LABEL = "vLLM"; +export const VLLM_DEFAULT_API_KEY_ENV_VAR = "VLLM_API_KEY"; +export const VLLM_MODEL_PLACEHOLDER = "meta-llama/Meta-Llama-3-8B-Instruct"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 1e33cafe4e9..0c521f84122 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -30,27 +30,6 @@ export type { ProviderAuthMethod, ProviderAuthResult, } from "../plugins/types.js"; -export type { - CreateSandboxBackendParams, - RemoteShellSandboxHandle, - RunSshSandboxCommandParams, - SandboxBackendCommandParams, - SandboxBackendCommandResult, - SandboxBackendExecSpec, - SandboxBackendFactory, - SandboxFsBridge, - SandboxFsStat, - SandboxBackendHandle, - SandboxBackendId, - SandboxBackendManager, - SandboxBackendRegistration, - SandboxBackendRuntimeInfo, - SandboxContext, - SandboxResolvedPath, - SandboxSshConfig, - SshSandboxSession, - SshSandboxSettings, -} from "../agents/sandbox.js"; export type { OpenClawConfig } from "../config/config.js"; export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; export type { @@ -66,58 +45,7 @@ export type { } from "./channel-plugin-common.js"; export { emptyPluginConfigSchema } from "./channel-plugin-common.js"; -export { - buildExecRemoteCommand, - buildRemoteCommand, - buildSshSandboxArgv, - createRemoteShellSandboxFsBridge, - createSshSandboxSessionFromConfigText, - createSshSandboxSessionFromSettings, - disposeSshSandboxSession, - getSandboxBackendFactory, - getSandboxBackendManager, - registerSandboxBackend, - runSshSandboxCommand, - shellEscape, - uploadDirectoryToSshTarget, - requireSandboxBackendFactory, -} from "../agents/sandbox.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; -export { - applyProviderDefaultModel, - configureOpenAICompatibleSelfHostedProviderNonInteractive, - discoverOpenAICompatibleSelfHostedProvider, - promptAndConfigureOpenAICompatibleSelfHostedProvider, - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, - SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, - SELF_HOSTED_DEFAULT_COST, - SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.js"; -export { - OLLAMA_DEFAULT_BASE_URL, - OLLAMA_DEFAULT_MODEL, - configureOllamaNonInteractive, - ensureOllamaModelPulled, - promptAndConfigureOllama, -} from "../commands/ollama-setup.js"; -export { - VLLM_DEFAULT_BASE_URL, - VLLM_DEFAULT_CONTEXT_WINDOW, - VLLM_DEFAULT_COST, - VLLM_DEFAULT_MAX_TOKENS, - promptAndConfigureVllm, -} from "../commands/vllm-setup.js"; -export { - buildOllamaProvider, - buildSglangProvider, - buildVllmProvider, -} from "../agents/models-config.providers.discovery.js"; - -export { - approveDevicePairing, - listDevicePairing, - rejectDevicePairing, -} from "../infra/device-pairing.js"; export { DEFAULT_SECRET_FILE_MAX_BYTES, loadSecretFileSync, @@ -126,13 +54,6 @@ export { } from "../infra/secret-file.js"; export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secret-file.js"; -export { - runPluginCommandWithTimeout, - type PluginCommandRunOptions, - type PluginCommandRunResult, -} from "./run-command.js"; -export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; - export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; diff --git a/src/plugin-sdk/provider-setup.ts b/src/plugin-sdk/provider-setup.ts new file mode 100644 index 00000000000..6569c36a324 --- /dev/null +++ b/src/plugin-sdk/provider-setup.ts @@ -0,0 +1,37 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, +} from "../plugins/types.js"; + +export { + applyProviderDefaultModel, + configureOpenAICompatibleSelfHostedProviderNonInteractive, + discoverOpenAICompatibleSelfHostedProvider, + promptAndConfigureOpenAICompatibleSelfHostedProvider, + promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "../commands/self-hosted-provider-setup.js"; +export { + OLLAMA_DEFAULT_BASE_URL, + OLLAMA_DEFAULT_MODEL, + configureOllamaNonInteractive, + ensureOllamaModelPulled, + promptAndConfigureOllama, +} from "../commands/ollama-setup.js"; +export { + VLLM_DEFAULT_BASE_URL, + VLLM_DEFAULT_CONTEXT_WINDOW, + VLLM_DEFAULT_COST, + VLLM_DEFAULT_MAX_TOKENS, + promptAndConfigureVllm, +} from "../commands/vllm-setup.js"; +export { + buildOllamaProvider, + buildSglangProvider, + buildVllmProvider, +} from "../agents/models-config.providers.discovery.js"; diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts new file mode 100644 index 00000000000..245fa9f6b83 --- /dev/null +++ b/src/plugin-sdk/sandbox.ts @@ -0,0 +1,45 @@ +export type { + CreateSandboxBackendParams, + RemoteShellSandboxHandle, + RunSshSandboxCommandParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxFsBridge, + SandboxFsStat, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, + SandboxContext, + SandboxResolvedPath, + SandboxSshConfig, + SshSandboxSession, + SshSandboxSettings, +} from "../agents/sandbox.js"; + +export { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createRemoteShellSandboxFsBridge, + createSshSandboxSessionFromConfigText, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, + runSshSandboxCommand, + shellEscape, + uploadDirectoryToSshTarget, +} from "../agents/sandbox.js"; + +export { + runPluginCommandWithTimeout, + type PluginCommandRunOptions, + type PluginCommandRunResult, +} from "./run-command.js"; +export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 856bacbca10..813d3e28e1e 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -11,6 +11,8 @@ import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; +import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; +import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; @@ -46,6 +48,24 @@ describe("plugin-sdk subpath exports", () => { expect(typeof coreSdk.resolveThreadSessionKeys).toBe("function"); expect(typeof coreSdk.runPassiveAccountLifecycle).toBe("function"); expect(typeof coreSdk.createLoggerBackedRuntime).toBe("function"); + expect("registerSandboxBackend" in asExports(coreSdk)).toBe(false); + expect("promptAndConfigureOpenAICompatibleSelfHostedProviderAuth" in asExports(coreSdk)).toBe( + false, + ); + }); + + it("exports provider setup helpers from the dedicated subpath", () => { + expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); + expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); + expect(typeof providerSetupSdk.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth).toBe( + "function", + ); + }); + + it("exports sandbox helpers from the dedicated subpath", () => { + expect(typeof sandboxSdk.registerSandboxBackend).toBe("function"); + expect(typeof sandboxSdk.runPluginCommandWithTimeout).toBe("function"); + expect(typeof sandboxSdk.createRemoteShellSandboxFsBridge).toBe("function"); }); it("exports shared core types used by bundled channels", () => { diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 9ca5f1184e6..a7576acdd5c 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -22,8 +22,8 @@ vi.mock("../../../extensions/github-copilot/token.js", async () => { }; }); -vi.mock("openclaw/plugin-sdk/core", async () => { - const actual = await vi.importActual("openclaw/plugin-sdk/core"); +vi.mock("openclaw/plugin-sdk/provider-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/provider-setup"); return { ...actual, buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), From c08f2aa21a6e336b303355297b8bbce08c0fa4a3 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:04:41 +0000 Subject: [PATCH 56/56] Providers: centralize setup defaults and helper boundaries --- src/agents/auth-profiles/upsert-with-lock.ts | 56 ++++++++++++++ .../models-config.providers.discovery.ts | 34 ++++----- src/agents/ollama-models.ts | 3 +- src/agents/ollama-stream.ts | 3 +- src/agents/self-hosted-provider-defaults.ts | 8 ++ src/cli/config-cli.ts | 2 +- src/commands/auth-profile-config.ts | 73 +++++++++++++++++++ src/commands/ollama-setup.ts | 6 +- src/commands/onboard-auth.config-core.ts | 73 +------------------ src/commands/onboard-custom.test.ts | 2 +- src/commands/onboard-custom.ts | 2 +- src/commands/self-hosted-provider-setup.ts | 22 +++--- src/commands/vllm-setup.ts | 16 ++-- 13 files changed, 184 insertions(+), 116 deletions(-) create mode 100644 src/agents/auth-profiles/upsert-with-lock.ts create mode 100644 src/agents/self-hosted-provider-defaults.ts create mode 100644 src/commands/auth-profile-config.ts diff --git a/src/agents/auth-profiles/upsert-with-lock.ts b/src/agents/auth-profiles/upsert-with-lock.ts new file mode 100644 index 00000000000..965798da940 --- /dev/null +++ b/src/agents/auth-profiles/upsert-with-lock.ts @@ -0,0 +1,56 @@ +import { withFileLock } from "../../infra/file-lock.js"; +import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; +import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION } from "./constants.js"; +import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; +import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js"; + +function coerceAuthProfileStore(raw: unknown): AuthProfileStore { + const record = raw && typeof raw === "object" ? (raw as Record) : {}; + const profiles = + record.profiles && typeof record.profiles === "object" && !Array.isArray(record.profiles) + ? { ...(record.profiles as Record) } + : {}; + const order = + record.order && typeof record.order === "object" && !Array.isArray(record.order) + ? (record.order as Record) + : undefined; + const lastGood = + record.lastGood && typeof record.lastGood === "object" && !Array.isArray(record.lastGood) + ? (record.lastGood as Record) + : undefined; + const usageStats = + record.usageStats && typeof record.usageStats === "object" && !Array.isArray(record.usageStats) + ? (record.usageStats as Record) + : undefined; + + return { + version: + typeof record.version === "number" && Number.isFinite(record.version) + ? record.version + : AUTH_STORE_VERSION, + profiles, + ...(order ? { order } : {}), + ...(lastGood ? { lastGood } : {}), + ...(usageStats ? { usageStats } : {}), + }; +} + +export async function upsertAuthProfileWithLock(params: { + profileId: string; + credential: AuthProfileCredential; + agentDir?: string; +}): Promise { + const authPath = resolveAuthStorePath(params.agentDir); + ensureAuthStoreFile(authPath); + + try { + return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => { + const store = coerceAuthProfileStore(loadJsonFile(authPath)); + store.profiles[params.profileId] = params.credential; + saveJsonFile(authPath, store); + return store; + }); + } catch { + return null; + } +} diff --git a/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts index a6d99afa89f..01dfb28e469 100644 --- a/src/agents/models-config.providers.discovery.ts +++ b/src/agents/models-config.providers.discovery.ts @@ -18,8 +18,15 @@ import { resolveOllamaApiBase, type OllamaTagsResponse, } from "./ollama-models.js"; +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "./self-hosted-provider-defaults.js"; +import { SGLANG_DEFAULT_BASE_URL, SGLANG_PROVIDER_LABEL } from "./sglang-defaults.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; +import { VLLM_DEFAULT_BASE_URL, VLLM_PROVIDER_LABEL } from "./vllm-defaults.js"; export { resolveOllamaApiBase } from "./ollama-models.js"; @@ -31,19 +38,6 @@ const log = createSubsystemLogger("agents/model-providers"); const OLLAMA_SHOW_CONCURRENCY = 8; const OLLAMA_SHOW_MAX_MODELS = 200; -const OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW = 128000; -const OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS = 8192; -const OPENAI_COMPAT_LOCAL_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const SGLANG_BASE_URL = "http://127.0.0.1:30000/v1"; - -const VLLM_BASE_URL = "http://127.0.0.1:8000/v1"; - type OpenAICompatModelsResponse = { data?: Array<{ id?: string; @@ -140,9 +134,9 @@ async function discoverOpenAICompatibleLocalModels(params: { name: modelId, reasoning: isReasoningModelHeuristic(modelId), input: ["text"], - cost: OPENAI_COMPAT_LOCAL_DEFAULT_COST, - contextWindow: params.contextWindow ?? OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW, - maxTokens: params.maxTokens ?? OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS, + cost: SELF_HOSTED_DEFAULT_COST, + contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, } satisfies ModelDefinitionConfig; }); } catch (error) { @@ -197,11 +191,11 @@ export async function buildVllmProvider(params?: { baseUrl?: string; apiKey?: string; }): Promise { - const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, ""); + const baseUrl = (params?.baseUrl?.trim() || VLLM_DEFAULT_BASE_URL).replace(/\/+$/, ""); const models = await discoverOpenAICompatibleLocalModels({ baseUrl, apiKey: params?.apiKey, - label: "vLLM", + label: VLLM_PROVIDER_LABEL, }); return { baseUrl, @@ -214,11 +208,11 @@ export async function buildSglangProvider(params?: { baseUrl?: string; apiKey?: string; }): Promise { - const baseUrl = (params?.baseUrl?.trim() || SGLANG_BASE_URL).replace(/\/+$/, ""); + const baseUrl = (params?.baseUrl?.trim() || SGLANG_DEFAULT_BASE_URL).replace(/\/+$/, ""); const models = await discoverOpenAICompatibleLocalModels({ baseUrl, apiKey: params?.apiKey, - label: "SGLang", + label: SGLANG_PROVIDER_LABEL, }); return { baseUrl, diff --git a/src/agents/ollama-models.ts b/src/agents/ollama-models.ts index 20406b3a80e..ee0fcfde447 100644 --- a/src/agents/ollama-models.ts +++ b/src/agents/ollama-models.ts @@ -1,7 +1,6 @@ import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "./ollama-defaults.js"; -export const OLLAMA_DEFAULT_BASE_URL = OLLAMA_NATIVE_BASE_URL; export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000; export const OLLAMA_DEFAULT_MAX_TOKENS = 8192; export const OLLAMA_DEFAULT_COST = { diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index 70a2ef33cf1..f332ad1fd83 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -10,6 +10,7 @@ import type { import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "./ollama-defaults.js"; import { buildAssistantMessage as buildStreamAssistantMessage, buildStreamErrorAssistantMessage, @@ -18,7 +19,7 @@ import { const log = createSubsystemLogger("ollama-stream"); -export const OLLAMA_NATIVE_BASE_URL = "http://127.0.0.1:11434"; +export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL; export function resolveOllamaBaseUrlForRun(params: { modelBaseUrl?: string; diff --git a/src/agents/self-hosted-provider-defaults.ts b/src/agents/self-hosted-provider-defaults.ts new file mode 100644 index 00000000000..da9dcc4b1d6 --- /dev/null +++ b/src/agents/self-hosted-provider-defaults.ts @@ -0,0 +1,8 @@ +export const SELF_HOSTED_DEFAULT_CONTEXT_WINDOW = 128000; +export const SELF_HOSTED_DEFAULT_MAX_TOKENS = 8192; +export const SELF_HOSTED_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 4793ff6bea6..0469952d322 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import JSON5 from "json5"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { CONFIG_PATH } from "../config/paths.js"; @@ -20,7 +21,6 @@ type ConfigSetParseOpts = { const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"]; const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"]; -const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; function isIndexSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); diff --git a/src/commands/auth-profile-config.ts b/src/commands/auth-profile-config.ts new file mode 100644 index 00000000000..797135b87b2 --- /dev/null +++ b/src/commands/auth-profile-config.ts @@ -0,0 +1,73 @@ +import type { OpenClawConfig } from "../config/config.js"; + +export function applyAuthProfileConfig( + cfg: OpenClawConfig, + params: { + profileId: string; + provider: string; + mode: "api_key" | "oauth" | "token"; + email?: string; + preferProfileFirst?: boolean; + }, +): OpenClawConfig { + const normalizedProvider = params.provider.toLowerCase(); + const profiles = { + ...cfg.auth?.profiles, + [params.profileId]: { + provider: params.provider, + mode: params.mode, + ...(params.email ? { email: params.email } : {}), + }, + }; + + const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {}) + .filter(([, profile]) => profile.provider.toLowerCase() === normalizedProvider) + .map(([profileId, profile]) => ({ profileId, mode: profile.mode })); + + // Maintain `auth.order` when it already exists. Additionally, if we detect + // mixed auth modes for the same provider (e.g. legacy oauth + newly selected + // api_key), create an explicit order to keep the newly selected profile first. + const existingProviderOrder = cfg.auth?.order?.[params.provider]; + const preferProfileFirst = params.preferProfileFirst ?? true; + const reorderedProviderOrder = + existingProviderOrder && preferProfileFirst + ? [ + params.profileId, + ...existingProviderOrder.filter((profileId) => profileId !== params.profileId), + ] + : existingProviderOrder; + const hasMixedConfiguredModes = configuredProviderProfiles.some( + ({ profileId, mode }) => profileId !== params.profileId && mode !== params.mode, + ); + const derivedProviderOrder = + existingProviderOrder === undefined && preferProfileFirst && hasMixedConfiguredModes + ? [ + params.profileId, + ...configuredProviderProfiles + .map(({ profileId }) => profileId) + .filter((profileId) => profileId !== params.profileId), + ] + : undefined; + const order = + existingProviderOrder !== undefined + ? { + ...cfg.auth?.order, + [params.provider]: reorderedProviderOrder?.includes(params.profileId) + ? reorderedProviderOrder + : [...(reorderedProviderOrder ?? []), params.profileId], + } + : derivedProviderOrder + ? { + ...cfg.auth?.order, + [params.provider]: derivedProviderOrder, + } + : cfg.auth?.order; + return { + ...cfg, + auth: { + ...cfg.auth, + profiles, + ...(order ? { order } : {}), + }, + }; +} diff --git a/src/commands/ollama-setup.ts b/src/commands/ollama-setup.ts index 060724061bd..4557f606bb6 100644 --- a/src/commands/ollama-setup.ts +++ b/src/commands/ollama-setup.ts @@ -1,6 +1,6 @@ -import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js"; +import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import { - OLLAMA_DEFAULT_BASE_URL, buildOllamaModelDefinition, enrichOllamaModelsWithContext, fetchOllamaModels, @@ -15,7 +15,7 @@ import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; import { openUrl } from "./onboard-helpers.js"; import type { OnboardMode, OnboardOptions } from "./onboard-types.js"; -export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js"; +export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; export const OLLAMA_DEFAULT_MODEL = "glm-4.7-flash"; const OLLAMA_SUGGESTED_MODELS_LOCAL = ["glm-4.7-flash"]; diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 0afd59c3910..c939a2cb99d 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -84,6 +84,7 @@ import { MODELSTUDIO_GLOBAL_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.models.js"; +export { applyAuthProfileConfig } from "./auth-profile-config.js"; function mergeProviderModels( existingProvider: Record | undefined, @@ -484,78 +485,6 @@ export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { return applyAgentDefaultModelPrimary(next, KILOCODE_DEFAULT_MODEL_REF); } -export function applyAuthProfileConfig( - cfg: OpenClawConfig, - params: { - profileId: string; - provider: string; - mode: "api_key" | "oauth" | "token"; - email?: string; - preferProfileFirst?: boolean; - }, -): OpenClawConfig { - const normalizedProvider = params.provider.toLowerCase(); - const profiles = { - ...cfg.auth?.profiles, - [params.profileId]: { - provider: params.provider, - mode: params.mode, - ...(params.email ? { email: params.email } : {}), - }, - }; - - const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {}) - .filter(([, profile]) => profile.provider.toLowerCase() === normalizedProvider) - .map(([profileId, profile]) => ({ profileId, mode: profile.mode })); - - // Maintain `auth.order` when it already exists. Additionally, if we detect - // mixed auth modes for the same provider (e.g. legacy oauth + newly selected - // api_key), create an explicit order to keep the newly selected profile first. - const existingProviderOrder = cfg.auth?.order?.[params.provider]; - const preferProfileFirst = params.preferProfileFirst ?? true; - const reorderedProviderOrder = - existingProviderOrder && preferProfileFirst - ? [ - params.profileId, - ...existingProviderOrder.filter((profileId) => profileId !== params.profileId), - ] - : existingProviderOrder; - const hasMixedConfiguredModes = configuredProviderProfiles.some( - ({ profileId, mode }) => profileId !== params.profileId && mode !== params.mode, - ); - const derivedProviderOrder = - existingProviderOrder === undefined && preferProfileFirst && hasMixedConfiguredModes - ? [ - params.profileId, - ...configuredProviderProfiles - .map(({ profileId }) => profileId) - .filter((profileId) => profileId !== params.profileId), - ] - : undefined; - const order = - existingProviderOrder !== undefined - ? { - ...cfg.auth?.order, - [params.provider]: reorderedProviderOrder?.includes(params.profileId) - ? reorderedProviderOrder - : [...(reorderedProviderOrder ?? []), params.profileId], - } - : derivedProviderOrder - ? { - ...cfg.auth?.order, - [params.provider]: derivedProviderOrder, - } - : cfg.auth?.order; - return { - ...cfg, - auth: { - ...cfg.auth, - profiles, - ...(order ? { order } : {}), - }, - }; -} - export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; models[QIANFAN_DEFAULT_MODEL_REF] = { diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index 6d78766853a..cf86da64211 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; -import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import { defaultRuntime } from "../runtime.js"; import { diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 874018a74ea..9de8e3f85cf 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -1,7 +1,7 @@ import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js"; -import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js"; +import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; diff --git a/src/commands/self-hosted-provider-setup.ts b/src/commands/self-hosted-provider-setup.ts index c067d797f15..ec2d8c683e3 100644 --- a/src/commands/self-hosted-provider-setup.ts +++ b/src/commands/self-hosted-provider-setup.ts @@ -1,5 +1,10 @@ -import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js"; import type { ApiKeyCredential, AuthProfileCredential } from "../agents/auth-profiles/types.js"; +import { upsertAuthProfileWithLock } from "../agents/auth-profiles/upsert-with-lock.js"; +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "../agents/self-hosted-provider-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ProviderDiscoveryContext, @@ -8,16 +13,13 @@ import type { ProviderNonInteractiveApiKeyResult, } from "../plugins/types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { applyAuthProfileConfig } from "./onboard-auth.js"; +import { applyAuthProfileConfig } from "./auth-profile-config.js"; -export const SELF_HOSTED_DEFAULT_CONTEXT_WINDOW = 128000; -export const SELF_HOSTED_DEFAULT_MAX_TOKENS = 8192; -export const SELF_HOSTED_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; +export { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "../agents/self-hosted-provider-defaults.js"; export function applyProviderDefaultModel(cfg: OpenClawConfig, modelRef: string): OpenClawConfig { const existingModel = cfg.agents?.defaults?.model; diff --git a/src/commands/vllm-setup.ts b/src/commands/vllm-setup.ts index 4d8657306e6..4c44587c06e 100644 --- a/src/commands/vllm-setup.ts +++ b/src/commands/vllm-setup.ts @@ -1,14 +1,20 @@ +import { + VLLM_DEFAULT_API_KEY_ENV_VAR, + VLLM_DEFAULT_BASE_URL, + VLLM_MODEL_PLACEHOLDER, + VLLM_PROVIDER_LABEL, +} from "../agents/vllm-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyProviderDefaultModel, - promptAndConfigureOpenAICompatibleSelfHostedProvider, SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, + promptAndConfigureOpenAICompatibleSelfHostedProvider, } from "./self-hosted-provider-setup.js"; -export const VLLM_DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1"; +export { VLLM_DEFAULT_BASE_URL } from "../agents/vllm-defaults.js"; export const VLLM_DEFAULT_CONTEXT_WINDOW = SELF_HOSTED_DEFAULT_CONTEXT_WINDOW; export const VLLM_DEFAULT_MAX_TOKENS = SELF_HOSTED_DEFAULT_MAX_TOKENS; export const VLLM_DEFAULT_COST = SELF_HOSTED_DEFAULT_COST; @@ -21,10 +27,10 @@ export async function promptAndConfigureVllm(params: { cfg: params.cfg, prompter: params.prompter, providerId: "vllm", - providerLabel: "vLLM", + providerLabel: VLLM_PROVIDER_LABEL, defaultBaseUrl: VLLM_DEFAULT_BASE_URL, - defaultApiKeyEnvVar: "VLLM_API_KEY", - modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", + defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: VLLM_MODEL_PLACEHOLDER, }); return { config: result.config,