diff --git a/CHANGELOG.md b/CHANGELOG.md index a80bae4ced0..df03ad8fc5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,26 +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 @@ -33,65 +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. +- 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 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) 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} 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/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/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 13b5d9bb6e8..966a5a1cbcd 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -8,45 +8,54 @@ import { createScopedAccountConfigAccessors, formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; +import { + buildAgentSessionKey, + resolveThreadSessionKeys, + type RoutePeer, +} from "openclaw/plugin-sdk/core"; 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 71526f20564..b0d94a1a437 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -3,6 +3,7 @@ import { buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/compat"; +import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -11,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/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/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/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts new file mode 100644 index 00000000000..58fc4d77184 --- /dev/null +++ b/extensions/slack/src/message-action-dispatch.ts @@ -0,0 +1,334 @@ +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 }); + if (!channelId) { + throw new Error("channelId required"); + } + 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/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/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/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/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/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/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/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/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/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 908c323c676..67c5b8184b2 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-internal/signal.js"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramReactionLevel, +} 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 b02e8a59fb8..bb2cad960bd 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-internal/signal.js"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramReactionLevel, +} from "../../../plugin-sdk-internal/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..54386ad4267 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-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 c38f2d7066f..8a7f93aacbb 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,5 +1,6 @@ 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 { createThreadDiscord, deleteMessageDiscord, @@ -21,14 +22,15 @@ import { sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, -} from "../../../extensions/discord/src/send.js"; +} from "../../plugin-sdk-internal/discord.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"; +} 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"; diff --git a/src/agents/tools/discord-actions-moderation.ts b/src/agents/tools/discord-actions-moderation.ts index 68db19d1d7f..63c3cc601bc 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-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 46f476bafec..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 { getGateway } from "../../../extensions/discord/src/monitor/gateway-registry.js"; import type { DiscordActionConfig } from "../../config/config.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 9b1c57bb240..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 { createDiscordActionGate } from "../../../extensions/discord/src/accounts.js"; import type { OpenClawConfig } from "../../config/config.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/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..c7fc16ed8b1 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-internal/slack.js"; +import { + parseSlackBlocksInput, + parseSlackTarget, + recordSlackThreadParticipation, + resolveSlackAccount, + resolveSlackChannelId, +} 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 ccfc9d5ae13..9f2d48831c3 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,17 +1,17 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { OpenClawConfig } from "../../config/config.js"; import { createTelegramActionGate, resolveTelegramPollActionGateState, -} from "../../../extensions/telegram/src/accounts.js"; +} from "../../plugin-sdk-internal/telegram.js"; import type { TelegramButtonStyle, TelegramInlineButtons, -} from "../../../extensions/telegram/src/button-types.js"; +} from "../../plugin-sdk-internal/telegram.js"; import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, -} from "../../../extensions/telegram/src/inline-buttons.js"; -import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/reaction-level.js"; +} from "../../plugin-sdk-internal/telegram.js"; import { createForumTopicTelegram, deleteMessageTelegram, @@ -21,10 +21,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"; +} from "../../plugin-sdk-internal/telegram.js"; +import { + getCacheStats, + resolveTelegramReactionLevel, + resolveTelegramToken, + searchStickers, +} from "../../plugin-sdk-internal/telegram.js"; import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { diff --git a/src/agents/tools/whatsapp-actions.ts b/src/agents/tools/whatsapp-actions.ts index 92332d1b3c5..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 { sendReactionWhatsApp } from "../../../extensions/whatsapp/src/send.js"; import type { OpenClawConfig } from "../../config/config.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 569a930d1a5..76e7e15d084 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-internal/whatsapp.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; import { ToolAuthorizationError } from "./common.js"; diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index e41fbd80ec2..5d732e4b4e6 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AcpRuntimeError } from "../../acp/runtime/errors.js"; +import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; @@ -395,6 +396,7 @@ async function runInternalAcpCommand(params: { describe("/acp command", () => { beforeEach(() => { + setDefaultChannelPluginRegistryForTests(); acpManagerTesting.resetAcpSessionManagerForTests(); hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]); hoisted.callGatewayMock.mockReset().mockResolvedValue({ ok: true }); diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index fd5eb50ee09..59db08384af 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -1,4 +1,3 @@ -import { buildFeishuConversationId } from "../../../../extensions/feishu/src/conversation-id.js"; import { buildTelegramTopicConversationId, normalizeConversationText, @@ -7,6 +6,7 @@ import { import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; +import { buildFeishuConversationId } from "../../../plugin-sdk/feishu.js"; import { parseAgentSessionKey } from "../../../routing/session-key.js"; import type { HandleCommandsParams } from "../commands-types.js"; import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.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/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 07f984eb92e..0359c77331b 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -22,7 +22,12 @@ const SESSION_COMMAND_PREFIX = "/session"; const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]); const SESSION_ACTION_IDLE = "idle"; const SESSION_ACTION_MAX_AGE = "max-age"; -const channelRuntime = createPluginRuntime().channel; +let cachedChannelRuntime: ReturnType["channel"] | undefined; + +function getChannelRuntime() { + cachedChannelRuntime ??= createPluginRuntime().channel; + return cachedChannelRuntime; +} function resolveSessionCommandUsage() { return "Usage: /session idle | /session max-age (example: /session idle 24h)"; @@ -373,6 +378,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined; + const channelRuntime = getChannelRuntime(); const discordManager = onDiscord ? channelRuntime.discord.threadBindings.getManager(accountId) 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 c97584aeae3..b426b18eab5 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,9 +1,9 @@ -import type { StickerMetadata } from "../../extensions/telegram/src/bot/types.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { MediaUnderstandingDecision, MediaUnderstandingOutput, } from "../media-understanding/types.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/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.test.ts b/src/auto-reply/thinking.test.ts index 18736f38905..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"); @@ -144,7 +148,23 @@ 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"); + }); + + 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"); diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 1652a80bdf6..1f2f1738b1f 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -1,38 +1,40 @@ +import { + formatThinkingLevels as formatThinkingLevelsFallback, + isBinaryThinkingProvider as isBinaryThinkingProviderFallback, + listThinkingLevelLabels as listThinkingLevelLabelsFallback, + listThinkingLevels as listThinkingLevelsFallback, + normalizeProviderId, + resolveThinkingDefaultForModel as resolveThinkingDefaultForModelFallback, +} from "./thinking.shared.js"; +import type { ThinkLevel, ThinkingCatalogEntry } 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; -}; - -const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; - -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) { @@ -49,46 +51,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); } export function supportsXHighThinking(provider?: string | null, model?: string | null): boolean { @@ -113,11 +76,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; } @@ -125,7 +87,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( @@ -133,11 +95,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: { @@ -146,7 +106,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,133 +120,5 @@ 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"; - } - 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/src/channel-web.ts b/src/channel-web.ts index 99e36ef67bc..f7e451b142a 100644 --- a/src/channel-web.ts +++ b/src/channel-web.ts @@ -7,19 +7,15 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, - type WebChannelStatus, - type WebMonitorTuning, -} from "../extensions/whatsapp/src/auto-reply.js"; +} from "./plugin-sdk-internal/whatsapp.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-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, @@ -30,4 +26,4 @@ export { WA_WEB_AUTH_DIR, waitForWaConnection, webAuthExists, -} from "../extensions/whatsapp/src/session.js"; +} from "./plugin-sdk-internal/whatsapp.js"; diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts index 6b8689effb3..ec11ca6c970 100644 --- a/src/channels/plugins/actions/discord.ts +++ b/src/channels/plugins/actions/discord.ts @@ -1,2 +1,2 @@ -// Shim: re-exports from extension -export * from "../../../../extensions/discord/src/channel-actions.js"; +// Public entrypoint for the Discord channel action adapter. +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 57a690d2208..e34c4598ade 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -1 +1,2 @@ -export * from "../../../../extensions/telegram/src/channel-actions.js"; +// Public entrypoint for the Telegram channel action adapter. +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 741b40a6fc9..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: 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-internal/whatsapp.js"; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts new file mode 100644 index 00000000000..c7cae53de20 --- /dev/null +++ b/src/channels/plugins/bundled.ts @@ -0,0 +1,88 @@ +import { bluebubblesPlugin } from "../../../extensions/bluebubbles/src/channel.js"; +import { discordPlugin } from "../../../extensions/discord/src/channel.js"; +import { discordSetupPlugin } from "../../../extensions/discord/src/channel.setup.js"; +import { setDiscordRuntime } from "../../../extensions/discord/src/runtime.js"; +import { feishuPlugin } from "../../../extensions/feishu/src/channel.js"; +import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; +import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; +import { imessageSetupPlugin } from "../../../extensions/imessage/src/channel.setup.js"; +import { ircPlugin } from "../../../extensions/irc/src/channel.js"; +import { linePlugin } from "../../../extensions/line/src/channel.js"; +import { lineSetupPlugin } from "../../../extensions/line/src/channel.setup.js"; +import { setLineRuntime } from "../../../extensions/line/src/runtime.js"; +import { matrixPlugin } from "../../../extensions/matrix/src/channel.js"; +import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; +import { msteamsPlugin } from "../../../extensions/msteams/src/channel.js"; +import { nextcloudTalkPlugin } from "../../../extensions/nextcloud-talk/src/channel.js"; +import { nostrPlugin } from "../../../extensions/nostr/src/channel.js"; +import { signalPlugin } from "../../../extensions/signal/src/channel.js"; +import { signalSetupPlugin } from "../../../extensions/signal/src/channel.setup.js"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; +import { slackSetupPlugin } from "../../../extensions/slack/src/channel.setup.js"; +import { synologyChatPlugin } from "../../../extensions/synology-chat/src/channel.js"; +import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import { telegramSetupPlugin } from "../../../extensions/telegram/src/channel.setup.js"; +import { setTelegramRuntime } from "../../../extensions/telegram/src/runtime.js"; +import { tlonPlugin } from "../../../extensions/tlon/src/channel.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; +import { whatsappSetupPlugin } from "../../../extensions/whatsapp/src/channel.setup.js"; +import { zaloPlugin } from "../../../extensions/zalo/src/channel.js"; +import { zalouserPlugin } from "../../../extensions/zalouser/src/channel.js"; +import type { ChannelId, ChannelPlugin } from "./types.js"; + +export const bundledChannelPlugins = [ + bluebubblesPlugin, + discordPlugin, + feishuPlugin, + googlechatPlugin, + imessagePlugin, + ircPlugin, + linePlugin, + matrixPlugin, + mattermostPlugin, + msteamsPlugin, + nextcloudTalkPlugin, + nostrPlugin, + signalPlugin, + slackPlugin, + synologyChatPlugin, + telegramPlugin, + tlonPlugin, + whatsappPlugin, + zaloPlugin, + zalouserPlugin, +] as ChannelPlugin[]; + +export const bundledChannelSetupPlugins = [ + telegramSetupPlugin, + whatsappSetupPlugin, + discordSetupPlugin, + ircPlugin, + googlechatPlugin, + slackSetupPlugin, + signalSetupPlugin, + imessageSetupPlugin, + lineSetupPlugin, +] as ChannelPlugin[]; + +const bundledChannelPluginsById = new Map( + bundledChannelPlugins.map((plugin) => [plugin.id, plugin] as const), +); + +export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { + return bundledChannelPluginsById.get(id); +} + +export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { + const plugin = getBundledChannelPlugin(id); + if (!plugin) { + throw new Error(`missing bundled channel plugin: ${id}`); + } + return plugin; +} + +export const bundledChannelRuntimeSetters = { + setDiscordRuntime, + setLineRuntime, + setTelegramRuntime, +}; diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index f5bb1d845e2..567181cef46 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -1,33 +1,11 @@ import { expect, vi } from "vitest"; -import { bluebubblesPlugin } from "../../../../extensions/bluebubbles/src/channel.js"; -import { discordPlugin } from "../../../../extensions/discord/src/channel.js"; -import { setDiscordRuntime } from "../../../../extensions/discord/src/runtime.js"; -import { feishuPlugin } from "../../../../extensions/feishu/src/channel.js"; -import { googlechatPlugin } from "../../../../extensions/googlechat/src/channel.js"; -import { imessagePlugin } from "../../../../extensions/imessage/src/channel.js"; -import { ircPlugin } from "../../../../extensions/irc/src/channel.js"; -import { linePlugin } from "../../../../extensions/line/src/channel.js"; -import { setLineRuntime } from "../../../../extensions/line/src/runtime.js"; -import { matrixPlugin } from "../../../../extensions/matrix/src/channel.js"; -import { mattermostPlugin } from "../../../../extensions/mattermost/src/channel.js"; -import { msteamsPlugin } from "../../../../extensions/msteams/src/channel.js"; -import { nextcloudTalkPlugin } from "../../../../extensions/nextcloud-talk/src/channel.js"; -import { nostrPlugin } from "../../../../extensions/nostr/src/channel.js"; -import { signalPlugin } from "../../../../extensions/signal/src/channel.js"; -import { slackPlugin } from "../../../../extensions/slack/src/channel.js"; -import { synologyChatPlugin } from "../../../../extensions/synology-chat/src/channel.js"; -import { telegramPlugin } from "../../../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../../../extensions/telegram/src/runtime.js"; -import { tlonPlugin } from "../../../../extensions/tlon/src/channel.js"; -import { whatsappPlugin } from "../../../../extensions/whatsapp/src/channel.js"; -import { zaloPlugin } from "../../../../extensions/zalo/src/channel.js"; -import { zalouserPlugin } from "../../../../extensions/zalouser/src/channel.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { resolveDefaultLineAccountId, resolveLineAccount, listLineAccountIds, } from "../../../line/accounts.js"; +import { bundledChannelRuntimeSetters, requireBundledChannelPlugin } from "../bundled.js"; import type { ChannelPlugin } from "../types.js"; type PluginContractEntry = { @@ -84,7 +62,7 @@ const telegramGetCapabilitiesMock = vi.fn(); const discordListActionsMock = vi.fn(); const discordGetCapabilitiesMock = vi.fn(); -setTelegramRuntime({ +bundledChannelRuntimeSetters.setTelegramRuntime({ channel: { telegram: { messageActions: { @@ -95,7 +73,7 @@ setTelegramRuntime({ }, } as never); -setDiscordRuntime({ +bundledChannelRuntimeSetters.setDiscordRuntime({ channel: { discord: { messageActions: { @@ -106,7 +84,7 @@ setDiscordRuntime({ }, } as never); -setLineRuntime({ +bundledChannelRuntimeSetters.setLineRuntime({ channel: { line: { listLineAccountIds, @@ -118,32 +96,32 @@ setLineRuntime({ } as never); export const pluginContractRegistry: PluginContractEntry[] = [ - { id: "bluebubbles", plugin: bluebubblesPlugin }, - { id: "discord", plugin: discordPlugin }, - { id: "feishu", plugin: feishuPlugin }, - { id: "googlechat", plugin: googlechatPlugin }, - { id: "imessage", plugin: imessagePlugin }, - { id: "irc", plugin: ircPlugin }, - { id: "line", plugin: linePlugin }, - { id: "matrix", plugin: matrixPlugin }, - { id: "mattermost", plugin: mattermostPlugin }, - { id: "msteams", plugin: msteamsPlugin }, - { id: "nextcloud-talk", plugin: nextcloudTalkPlugin }, - { id: "nostr", plugin: nostrPlugin }, - { id: "signal", plugin: signalPlugin }, - { id: "slack", plugin: slackPlugin }, - { id: "synology-chat", plugin: synologyChatPlugin }, - { id: "telegram", plugin: telegramPlugin }, - { id: "tlon", plugin: tlonPlugin }, - { id: "whatsapp", plugin: whatsappPlugin }, - { id: "zalo", plugin: zaloPlugin }, - { id: "zalouser", plugin: zalouserPlugin }, + { id: "bluebubbles", plugin: requireBundledChannelPlugin("bluebubbles") }, + { id: "discord", plugin: requireBundledChannelPlugin("discord") }, + { id: "feishu", plugin: requireBundledChannelPlugin("feishu") }, + { id: "googlechat", plugin: requireBundledChannelPlugin("googlechat") }, + { id: "imessage", plugin: requireBundledChannelPlugin("imessage") }, + { id: "irc", plugin: requireBundledChannelPlugin("irc") }, + { id: "line", plugin: requireBundledChannelPlugin("line") }, + { id: "matrix", plugin: requireBundledChannelPlugin("matrix") }, + { id: "mattermost", plugin: requireBundledChannelPlugin("mattermost") }, + { id: "msteams", plugin: requireBundledChannelPlugin("msteams") }, + { id: "nextcloud-talk", plugin: requireBundledChannelPlugin("nextcloud-talk") }, + { id: "nostr", plugin: requireBundledChannelPlugin("nostr") }, + { id: "signal", plugin: requireBundledChannelPlugin("signal") }, + { id: "slack", plugin: requireBundledChannelPlugin("slack") }, + { id: "synology-chat", plugin: requireBundledChannelPlugin("synology-chat") }, + { id: "telegram", plugin: requireBundledChannelPlugin("telegram") }, + { id: "tlon", plugin: requireBundledChannelPlugin("tlon") }, + { id: "whatsapp", plugin: requireBundledChannelPlugin("whatsapp") }, + { id: "zalo", plugin: requireBundledChannelPlugin("zalo") }, + { id: "zalouser", plugin: requireBundledChannelPlugin("zalouser") }, ]; export const actionContractRegistry: ActionsContractEntry[] = [ { id: "slack", - plugin: slackPlugin, + plugin: requireBundledChannelPlugin("slack"), unsupportedAction: "poll", cases: [ { @@ -217,7 +195,7 @@ export const actionContractRegistry: ActionsContractEntry[] = [ }, { id: "mattermost", - plugin: mattermostPlugin, + plugin: requireBundledChannelPlugin("mattermost"), unsupportedAction: "poll", cases: [ { @@ -265,7 +243,7 @@ export const actionContractRegistry: ActionsContractEntry[] = [ }, { id: "telegram", - plugin: telegramPlugin, + plugin: requireBundledChannelPlugin("telegram"), cases: [ { name: "forwards runtime-backed Telegram actions and capabilities", @@ -283,7 +261,7 @@ export const actionContractRegistry: ActionsContractEntry[] = [ }, { id: "discord", - plugin: discordPlugin, + plugin: requireBundledChannelPlugin("discord"), cases: [ { name: "forwards runtime-backed Discord actions and capabilities", @@ -304,7 +282,7 @@ export const actionContractRegistry: ActionsContractEntry[] = [ export const setupContractRegistry: SetupContractEntry[] = [ { id: "slack", - plugin: slackPlugin, + plugin: requireBundledChannelPlugin("slack"), cases: [ { name: "default account stores tokens and enables the channel", @@ -334,7 +312,7 @@ export const setupContractRegistry: SetupContractEntry[] = [ }, { id: "mattermost", - plugin: mattermostPlugin, + plugin: requireBundledChannelPlugin("mattermost"), cases: [ { name: "default account stores token and normalized base URL", @@ -363,7 +341,7 @@ export const setupContractRegistry: SetupContractEntry[] = [ }, { id: "line", - plugin: linePlugin, + plugin: requireBundledChannelPlugin("line"), cases: [ { name: "default account stores token and secret", @@ -396,7 +374,7 @@ export const setupContractRegistry: SetupContractEntry[] = [ export const statusContractRegistry: StatusContractEntry[] = [ { id: "slack", - plugin: slackPlugin, + plugin: requireBundledChannelPlugin("slack"), cases: [ { name: "configured account produces a configured status snapshot", @@ -424,7 +402,7 @@ export const statusContractRegistry: StatusContractEntry[] = [ }, { id: "mattermost", - plugin: mattermostPlugin, + plugin: requireBundledChannelPlugin("mattermost"), cases: [ { name: "configured account preserves connectivity details in the snapshot", @@ -455,7 +433,7 @@ export const statusContractRegistry: StatusContractEntry[] = [ }, { id: "line", - plugin: linePlugin, + plugin: requireBundledChannelPlugin("line"), cases: [ { name: "configured account produces a webhook status snapshot", 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/normalize/discord.ts b/src/channels/plugins/normalize/discord.ts deleted file mode 100644 index e4fcc4e9c00..00000000000 --- a/src/channels/plugins/normalize/discord.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim: re-exports from extension -export * from "../../../../extensions/discord/src/normalize.js"; diff --git a/src/channels/plugins/normalize/telegram.ts b/src/channels/plugins/normalize/telegram.ts deleted file mode 100644 index ab3971ff32b..00000000000 --- a/src/channels/plugins/normalize/telegram.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../../extensions/telegram/src/normalize.js"; diff --git a/src/channels/plugins/normalize/whatsapp.ts b/src/channels/plugins/normalize/whatsapp.ts index 1e464489818..edff8bfe5e1 100644 --- a/src/channels/plugins/normalize/whatsapp.ts +++ b/src/channels/plugins/normalize/whatsapp.ts @@ -1,2 +1,25 @@ -// Shim: re-exports from extensions/whatsapp/src/normalize.ts -export * from "../../../../extensions/whatsapp/src/normalize.js"; +import { normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js"; +import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js"; + +export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined { + const trimmed = trimMessagingTarget(raw); + if (!trimmed) { + return undefined; + } + return normalizeWhatsAppTarget(trimmed) ?? undefined; +} + +export function normalizeWhatsAppAllowFromEntries(allowFrom: Array): string[] { + return allowFrom + .map((entry) => String(entry).trim()) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) + .filter((entry): entry is string => Boolean(entry)); +} + +export function looksLikeWhatsAppTargetId(raw: string): boolean { + return looksLikeHandleOrPhoneTarget({ + raw, + prefixPattern: /^whatsapp:/i, + }); +} diff --git a/src/channels/plugins/setup-registry.ts b/src/channels/plugins/setup-registry.ts index a8c7212ca1f..bbb82022da9 100644 --- a/src/channels/plugins/setup-registry.ts +++ b/src/channels/plugins/setup-registry.ts @@ -1,17 +1,9 @@ -import { discordSetupPlugin } from "../../../extensions/discord/src/channel.setup.js"; -import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; -import { imessageSetupPlugin } from "../../../extensions/imessage/src/channel.setup.js"; -import { ircPlugin } from "../../../extensions/irc/src/channel.js"; -import { lineSetupPlugin } from "../../../extensions/line/src/channel.setup.js"; -import { signalSetupPlugin } from "../../../extensions/signal/src/channel.setup.js"; -import { slackSetupPlugin } from "../../../extensions/slack/src/channel.setup.js"; -import { telegramSetupPlugin } from "../../../extensions/telegram/src/channel.setup.js"; -import { whatsappSetupPlugin } from "../../../extensions/whatsapp/src/channel.setup.js"; import { getActivePluginRegistryVersion, requireActivePluginRegistry, } from "../../plugins/runtime.js"; import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "../registry.js"; +import { bundledChannelSetupPlugins } from "./bundled.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; type CachedChannelSetupPlugins = { @@ -28,18 +20,6 @@ const EMPTY_CHANNEL_SETUP_CACHE: CachedChannelSetupPlugins = { let cachedChannelSetupPlugins = EMPTY_CHANNEL_SETUP_CACHE; -const BUNDLED_CHANNEL_SETUP_PLUGINS = [ - telegramSetupPlugin, - whatsappSetupPlugin, - discordSetupPlugin, - ircPlugin, - googlechatPlugin, - slackSetupPlugin, - signalSetupPlugin, - imessageSetupPlugin, - lineSetupPlugin, -] as ChannelPlugin[]; - function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { const seen = new Set(); const resolved: ChannelPlugin[] = []; @@ -77,7 +57,7 @@ function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { const registryPlugins = (registry.channelSetups ?? []).map((entry) => entry.plugin); const sorted = sortChannelSetupPlugins( - registryPlugins.length > 0 ? registryPlugins : BUNDLED_CHANNEL_SETUP_PLUGINS, + registryPlugins.length > 0 ? registryPlugins : bundledChannelSetupPlugins, ); const byId = new Map(); for (const plugin of sorted) { 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"; 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/plugins/status-issues/discord.ts b/src/channels/plugins/status-issues/discord.ts deleted file mode 100644 index f42578df1e9..00000000000 --- a/src/channels/plugins/status-issues/discord.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim: re-exports from extension -export * from "../../../../extensions/discord/src/status-issues.js"; diff --git a/src/channels/plugins/status-issues/telegram.ts b/src/channels/plugins/status-issues/telegram.ts deleted file mode 100644 index 26425a07ae4..00000000000 --- a/src/channels/plugins/status-issues/telegram.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../../extensions/telegram/src/status-issues.js"; diff --git a/src/channels/plugins/status-issues/whatsapp.ts b/src/channels/plugins/status-issues/whatsapp.ts deleted file mode 100644 index 45be4231ed2..00000000000 --- a/src/channels/plugins/status-issues/whatsapp.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim: re-exports from extensions/whatsapp/src/status-issues.ts -export * from "../../../../extensions/whatsapp/src/status-issues.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..00d0943b1ec 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-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 6d0e0a10b29..c3e2bd5d83c 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-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 07866b9d450..1e633a0ff8e 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-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 f345e1a24bb..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("../channels/web/index.js", () => { +vi.mock("../plugin-sdk-internal/whatsapp.js", () => { moduleLoads.whatsapp(); return { sendMessageWhatsApp: sendFns.whatsapp }; }); -vi.mock("../../extensions/telegram/src/send.js", () => { +vi.mock("../plugin-sdk-internal/telegram.js", () => { moduleLoads.telegram(); return { sendMessageTelegram: sendFns.telegram }; }); -vi.mock("../../extensions/discord/src/send.js", () => { +vi.mock("../plugin-sdk-internal/discord.js", () => { moduleLoads.discord(); return { sendMessageDiscord: sendFns.discord }; }); -vi.mock("../../extensions/slack/src/send.js", () => { +vi.mock("../plugin-sdk-internal/slack.js", () => { moduleLoads.slack(); return { sendMessageSlack: sendFns.slack }; }); -vi.mock("../../extensions/signal/src/send.js", () => { +vi.mock("../plugin-sdk-internal/signal.js", () => { moduleLoads.signal(); return { sendMessageSignal: sendFns.signal }; }); -vi.mock("../../extensions/imessage/src/send.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 c9ab341dd18..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("../channels/web/index.js") as Promise>, + () => import("../plugin-sdk-internal/whatsapp.js") as Promise>, "sendMessageWhatsApp", ), telegram: createLazySender( "telegram", - () => import("../../extensions/telegram/src/send.js") as Promise>, + () => import("../plugin-sdk-internal/telegram.js") as Promise>, "sendMessageTelegram", ), discord: createLazySender( "discord", - () => import("../../extensions/discord/src/send.js") as Promise>, + () => import("../plugin-sdk-internal/discord.js") as Promise>, "sendMessageDiscord", ), slack: createLazySender( "slack", - () => import("../../extensions/slack/src/send.js") as Promise>, + () => import("../plugin-sdk-internal/slack.js") as Promise>, "sendMessageSlack", ), signal: createLazySender( "signal", - () => import("../../extensions/signal/src/send.js") as Promise>, + () => import("../plugin-sdk-internal/signal.js") as Promise>, "sendMessageSignal", ), imessage: createLazySender( "imessage", - () => import("../../extensions/imessage/src/send.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 "../../extensions/whatsapp/src/auth-store.js"; +export { logWebSelfId } from "../plugin-sdk-internal/whatsapp.js"; 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/commands/auth-choice.apply.api-key-providers.ts b/src/commands/auth-choice.apply.api-key-providers.ts index 86593773080..3ff35a46365 100644 --- a/src/commands/auth-choice.apply.api-key-providers.ts +++ b/src/commands/auth-choice.apply.api-key-providers.ts @@ -1,85 +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, - applyModelStudioStandardConfig, - applyModelStudioStandardConfigCn, - applyModelStudioStandardProviderConfig, - applyModelStudioStandardProviderConfigCn, - 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"], @@ -94,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"]; @@ -104,426 +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"), - }, - "modelstudio-standard-api-key-cn": { - provider: "modelstudio", - profileId: "modelstudio:default", - expectedProviders: ["modelstudio"], - envLabel: "MODELSTUDIO_API_KEY", - promptMessage: "Enter Alibaba Cloud Model Studio API key (China)", - setCredential: setModelStudioApiKey, - defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF, - applyDefaultConfig: applyModelStudioStandardConfigCn, - applyProviderConfig: applyModelStudioStandardProviderConfigCn, - noteDefault: MODELSTUDIO_DEFAULT_MODEL_REF, - noteMessage: [ - "Get your API key at: https://bailian.console.aliyun.com/", - "Endpoint: dashscope.aliyuncs.com/compatible-mode/v1", - "Models: qwen3.5-plus, qwen3.5-flash, qwen3-coder-plus, etc.", - ].join("\n"), - noteTitle: "Alibaba Cloud Model Studio (China)", - normalize: (value) => String(value ?? "").trim(), - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }, - "modelstudio-standard-api-key": { - provider: "modelstudio", - profileId: "modelstudio:default", - expectedProviders: ["modelstudio"], - envLabel: "MODELSTUDIO_API_KEY", - promptMessage: "Enter Alibaba Cloud Model Studio API key (Global/Intl)", - setCredential: setModelStudioApiKey, - defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF, - applyDefaultConfig: applyModelStudioStandardConfig, - applyProviderConfig: applyModelStudioStandardProviderConfig, - noteDefault: MODELSTUDIO_DEFAULT_MODEL_REF, - noteMessage: [ - "Get your API key at: https://modelstudio.console.alibabacloud.com/", - "Endpoint: dashscope-intl.aliyuncs.com/compatible-mode/v1", - "Models: qwen3.5-plus, qwen3.5-flash, qwen3-coder-plus, etc.", - ].join("\n"), - noteTitle: "Alibaba Cloud Model Studio (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, @@ -588,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/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index a76f747b326..38f3621146f 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -1,10 +1,4 @@ -import { discordPlugin } from "../../extensions/discord/src/channel.js"; -import { feishuPlugin } from "../../extensions/feishu/src/channel.js"; -import { imessagePlugin } from "../../extensions/imessage/src/channel.js"; -import { signalPlugin } from "../../extensions/signal/src/channel.js"; -import { slackPlugin } from "../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; +import { requireBundledChannelPlugin } from "../channels/plugins/bundled.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { getChannelSetupWizardAdapter } from "./channel-setup/registry.js"; @@ -27,13 +21,13 @@ type PatchedSetupAdapterFields = { export function setDefaultChannelPluginRegistryForTests(): void { const channels = [ - { pluginId: "discord", plugin: discordPlugin, source: "test" }, - { pluginId: "feishu", plugin: feishuPlugin, source: "test" }, - { pluginId: "slack", plugin: slackPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, - { pluginId: "signal", plugin: signalPlugin, source: "test" }, - { pluginId: "imessage", plugin: imessagePlugin, source: "test" }, + { pluginId: "discord", plugin: requireBundledChannelPlugin("discord"), source: "test" }, + { pluginId: "feishu", plugin: requireBundledChannelPlugin("feishu"), source: "test" }, + { pluginId: "slack", plugin: requireBundledChannelPlugin("slack"), source: "test" }, + { pluginId: "telegram", plugin: requireBundledChannelPlugin("telegram"), source: "test" }, + { pluginId: "whatsapp", plugin: requireBundledChannelPlugin("whatsapp"), source: "test" }, + { pluginId: "signal", plugin: requireBundledChannelPlugin("signal"), source: "test" }, + { pluginId: "imessage", plugin: requireBundledChannelPlugin("imessage"), source: "test" }, ] as unknown as Parameters[0]; setActivePluginRegistry(createTestRegistry(channels)); } 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/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/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 2a7524b2558..7ca736ee448 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,4 +1,3 @@ -import { hasAnyWhatsAppAuth } from "../../extensions/whatsapp/src/accounts.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { getChannelPluginCatalogEntry, @@ -9,6 +8,7 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.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 46cce98d193..627dccb5049 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 "../../extensions/discord/src/monitor/timeouts.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"; @@ -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/sessions/explicit-session-key-normalization.ts b/src/config/sessions/explicit-session-key-normalization.ts index 984b70487a3..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 { normalizeExplicitDiscordSessionKey } from "../../../extensions/discord/src/session-key-normalization.js"; import type { MsgContext } from "../../auto-reply/templating.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 a27fd3f8b45..aea4e7f8cfd 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,4 +1,4 @@ -import type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js"; +import type { DiscordPluralKitConfig } from "../plugin-sdk-internal/discord.js"; import type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, 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/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 4a70352e233..585e273e613 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -1,4 +1,3 @@ -import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -14,6 +13,7 @@ import { resolveSessionDeliveryTarget, } from "../../infra/outbound/targets.js"; import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.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/entry.ts b/src/entry.ts index 9b693c756e3..3496e48f0e9 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -41,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/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({ 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-http.ts b/src/gateway/server-http.ts index 75af96dd545..fe35da1f356 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-internal/slack.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, 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, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index d662d60841b..48066b6e2a7 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -31,7 +31,12 @@ import { } from "./session-utils.js"; const ACP_RUNTIME_CLEANUP_TIMEOUT_MS = 15_000; -const channelRuntime = createPluginRuntime().channel; +let cachedChannelRuntime: ReturnType["channel"] | undefined; + +function getChannelRuntime() { + cachedChannelRuntime ??= createPluginRuntime().channel; + return cachedChannelRuntime; +} function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined { if (!entry) { @@ -71,6 +76,7 @@ export async function emitSessionUnboundLifecycleEvent(params: { emitHooks?: boolean; }) { const targetKind = isSubagentSessionKey(params.targetSessionKey) ? "subagent" : "acp"; + const channelRuntime = getChannelRuntime(); channelRuntime.discord.threadBindings.unbindBySessionKey({ targetSessionKey: params.targetSessionKey, targetKind, 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 4daf6521df7..92cf6269cc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,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/bonjour.test.ts b/src/infra/bonjour.test.ts index d9e438f5cce..b101171782b 100644 --- a/src/infra/bonjour.test.ts +++ b/src/infra/bonjour.test.ts @@ -264,14 +264,16 @@ describe("gateway bonjour advertiser", () => { await Promise.resolve(); expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("advertise failed")); - // watchdog should attempt re-advertise at the 60s interval tick + // watchdog first retries, then recreates the advertiser after the service + // stays unhealthy across multiple 5s ticks. await vi.advanceTimersByTimeAsync(15_000); - expect(advertise).toHaveBeenCalledTimes(2); + expect(advertise).toHaveBeenCalledTimes(3); + expect(createService).toHaveBeenCalledTimes(2); await started.stop(); await vi.advanceTimersByTimeAsync(60_000); - expect(advertise).toHaveBeenCalledTimes(2); + expect(advertise).toHaveBeenCalledTimes(3); }); it("handles advertise throwing synchronously", async () => { @@ -338,6 +340,44 @@ describe("gateway bonjour advertiser", () => { expect(shutdown).toHaveBeenCalledTimes(2); }); + it("treats probing-to-announcing churn as one unhealthy window", async () => { + enableAdvertiserUnitMode(); + vi.useFakeTimers(); + + const stateRef = { value: "probing" }; + let advertiseCount = 0; + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockImplementation(() => { + advertiseCount += 1; + if (advertiseCount === 2) { + stateRef.value = "announcing"; + } + if (advertiseCount >= 3) { + stateRef.value = "announced"; + } + return Promise.resolve(); + }); + mockCiaoService({ advertise, destroy, stateRef }); + + const started = await startGatewayBonjourAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + expect(createService).toHaveBeenCalledTimes(1); + expect(advertise).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(15_000); + + expect(logWarn).toHaveBeenCalledWith(expect.stringContaining("service stuck in announcing")); + expect(createService).toHaveBeenCalledTimes(2); + expect(advertise).toHaveBeenCalledTimes(3); + expect(destroy).toHaveBeenCalledTimes(1); + expect(shutdown).toHaveBeenCalledTimes(1); + + await started.stop(); + }); + it("normalizes hostnames with domains for service names", async () => { // Allow advertiser to run in unit tests. delete process.env.VITEST; diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 44a0ed99448..457853a9b45 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -90,6 +90,10 @@ function serviceSummary(label: string, svc: BonjourService): string { return `${label} fqdn=${fqdn} host=${hostname} port=${port} state=${state}`; } +function isAnnouncedState(state: BonjourServiceState | "unknown") { + return String(state) === "announced"; +} + export async function startGatewayBonjourAdvertiser( opts: GatewayBonjourAdvertiseOpts, ): Promise { @@ -181,6 +185,20 @@ export async function startGatewayBonjourAdvertiser( if (!cycle) { return; } + const responder = cycle.responder as unknown as { + advertiseService?: (...args: unknown[]) => unknown; + announce?: (...args: unknown[]) => unknown; + probe?: (...args: unknown[]) => unknown; + republishService?: (...args: unknown[]) => unknown; + }; + const noopAsync = async () => {}; + // ciao schedules its own 2s retry timers after failed probe/announce attempts. + // Those callbacks target the original responder instance, so disarm it before + // destroy/shutdown to prevent a dead cycle from re-entering advertise/probe. + responder.advertiseService = noopAsync; + responder.announce = noopAsync; + responder.probe = noopAsync; + responder.republishService = noopAsync; for (const { svc } of cycle.services) { try { await svc.destroy(); @@ -257,8 +275,12 @@ export async function startGatewayBonjourAdvertiser( const nextState: BonjourServiceState | "unknown" = typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; const current = stateTracker.get(label); - if (!current || current.state !== nextState) { - stateTracker.set(label, { state: nextState, sinceMs: now }); + const nextEnteredAt = + current && !isAnnouncedState(current.state) && !isAnnouncedState(nextState) + ? current.sinceMs + : now; + if (!current || current.state !== nextState || current.sinceMs !== nextEnteredAt) { + stateTracker.set(label, { state: nextState, sinceMs: nextEnteredAt }); } } }; @@ -299,12 +321,12 @@ export async function startGatewayBonjourAdvertiser( } const tracked = stateTracker.get(label); if ( - stateUnknown === "announcing" && + stateUnknown !== "announced" && tracked && Date.now() - tracked.sinceMs >= STUCK_ANNOUNCING_MS ) { void recreateAdvertiser( - `service stuck announcing for ${Date.now() - tracked.sinceMs}ms (${serviceSummary( + `service stuck in ${stateUnknown} for ${Date.now() - tracked.sinceMs}ms (${serviceSummary( label, svc, )})`, 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 new file mode 100644 index 00000000000..e4d3688d7e5 --- /dev/null +++ b/src/infra/gaxios-fetch-compat.ts @@ -0,0 +1,212 @@ +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"; +} 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-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"; diff --git a/src/plugin-sdk/account-resolution.ts b/src/plugin-sdk/account-resolution.ts index e25c2cc74cb..4aceec2c945 100644 --- a/src/plugin-sdk/account-resolution.ts +++ b/src/plugin-sdk/account-resolution.ts @@ -1,3 +1,4 @@ +/** Resolve an account by id, then fall back to the default account when the primary lacks credentials. */ export function resolveAccountWithDefaultFallback(params: { accountId?: string | null; normalizeAccountId: (accountId?: string | null) => string; @@ -23,6 +24,7 @@ export function resolveAccountWithDefaultFallback(params: { return fallback; } +/** List normalized configured account ids from a raw channel account record map. */ export function listConfiguredAccountIds(params: { accounts: Record | undefined; normalizeAccountId: (accountId: string) => string; diff --git a/src/plugin-sdk/agent-media-payload.ts b/src/plugin-sdk/agent-media-payload.ts index 98d12a8420b..5fa1fb767f5 100644 --- a/src/plugin-sdk/agent-media-payload.ts +++ b/src/plugin-sdk/agent-media-payload.ts @@ -7,6 +7,7 @@ export type AgentMediaPayload = { MediaTypes?: string[]; }; +/** Convert outbound media descriptors into the legacy agent payload field layout. */ export function buildAgentMediaPayload( mediaList: Array<{ path: string; contentType?: string | null }>, ): AgentMediaPayload { diff --git a/src/plugin-sdk/allow-from.ts b/src/plugin-sdk/allow-from.ts index 9b43a8ced6d..f03f2427558 100644 --- a/src/plugin-sdk/allow-from.ts +++ b/src/plugin-sdk/allow-from.ts @@ -1,3 +1,4 @@ +/** Lowercase and optionally strip prefixes from allowlist entries before sender comparisons. */ export function formatAllowFromLowercase(params: { allowFrom: Array; stripPrefixRe?: RegExp; @@ -9,6 +10,7 @@ export function formatAllowFromLowercase(params: { .map((entry) => entry.toLowerCase()); } +/** Normalize allowlist entries through a channel-provided parser or canonicalizer. */ export function formatNormalizedAllowFromEntries(params: { allowFrom: Array; normalizeEntry: (entry: string) => string | undefined | null; @@ -20,6 +22,7 @@ export function formatNormalizedAllowFromEntries(params: { .filter((entry): entry is string => Boolean(entry)); } +/** Check whether a sender id matches a simple normalized allowlist with wildcard support. */ export function isNormalizedSenderAllowed(params: { senderId: string | number; allowFrom: Array; @@ -45,6 +48,7 @@ type ParsedChatAllowTarget = | { kind: "chat_identifier"; chatIdentifier: string } | { kind: "handle"; handle: string }; +/** Match chat-aware allowlist entries against sender, chat id, guid, or identifier fields. */ export function isAllowedParsedChatSender(params: { allowFrom: Array; sender: string; diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts index 4c9f10ec278..c9f2a92e3be 100644 --- a/src/plugin-sdk/allowlist-config-edit.ts +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -183,6 +183,7 @@ function applyAccountScopedAllowlistConfigEdit(params: { }; } +/** Build the default account-scoped allowlist editor used by channel plugins with config-backed lists. */ export function buildAccountScopedAllowlistConfigEditor(params: { channelId: ChannelId; normalize: (params: { diff --git a/src/plugin-sdk/allowlist-resolution.ts b/src/plugin-sdk/allowlist-resolution.ts index 8e955e422b3..1acf87f4d1c 100644 --- a/src/plugin-sdk/allowlist-resolution.ts +++ b/src/plugin-sdk/allowlist-resolution.ts @@ -6,6 +6,7 @@ export type BasicAllowlistResolutionEntry = { note?: string; }; +/** Clone allowlist resolution entries into a plain serializable shape for UI and docs output. */ export function mapBasicAllowlistResolutionEntries( entries: BasicAllowlistResolutionEntry[], ): BasicAllowlistResolutionEntry[] { @@ -18,6 +19,7 @@ export function mapBasicAllowlistResolutionEntries( })); } +/** Map allowlist inputs sequentially so resolver side effects stay ordered and predictable. */ export async function mapAllowlistResolutionInputs(params: { inputs: string[]; mapInput: (input: string) => Promise | T; diff --git a/src/plugin-sdk/boolean-param.ts b/src/plugin-sdk/boolean-param.ts index 4616eaec3b8..9e583027052 100644 --- a/src/plugin-sdk/boolean-param.ts +++ b/src/plugin-sdk/boolean-param.ts @@ -1,3 +1,4 @@ +/** Read loose boolean params from tool input that may arrive as booleans or "true"/"false" strings. */ export function readBooleanParam( params: Record, key: string, diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index aa405c4b9b7..564bc86bc68 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -10,16 +10,19 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeAccountId } from "../routing/session-key.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; +/** Coerce mixed allowlist config values into plain strings without trimming or deduping. */ export function mapAllowFromEntries( allowFrom: Array | null | undefined, ): string[] { return (allowFrom ?? []).map((entry) => String(entry)); } +/** Normalize user-facing allowlist entries the same way config and doctor flows expect. */ export function formatTrimmedAllowFromEntries(allowFrom: Array): string[] { return normalizeStringEntries(allowFrom); } +/** Collapse nullable config scalars into a trimmed optional string. */ export function resolveOptionalConfigString( value: string | number | null | undefined, ): string | undefined { @@ -30,6 +33,7 @@ export function resolveOptionalConfigString( return normalized || undefined; } +/** Build the shared allowlist/default target adapter surface for account-scoped channel configs. */ export function createScopedAccountConfigAccessors(params: { resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => ResolvedAccount; resolveAllowFrom: (account: ResolvedAccount) => Array | null | undefined; @@ -59,6 +63,7 @@ export function createScopedAccountConfigAccessors(params: { }; } +/** Build the common CRUD/config helpers for channels that store multiple named accounts. */ export function createScopedChannelConfigBase< ResolvedAccount, Config extends OpenClawConfig = OpenClawConfig, @@ -104,6 +109,7 @@ export function createScopedChannelConfigBase< }; } +/** Convert account-specific DM security fields into the shared runtime policy resolver shape. */ export function createScopedDmSecurityResolver< ResolvedAccount extends { accountId?: string | null }, >(params: { @@ -143,6 +149,7 @@ export function createScopedDmSecurityResolver< }); } +/** Read the effective WhatsApp allowlist through the active plugin contract. */ export function resolveWhatsAppConfigAllowFrom(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -153,10 +160,12 @@ export function resolveWhatsAppConfigAllowFrom(params: { : []; } +/** Format WhatsApp allowlist entries with the same normalization used by the channel plugin. */ export function formatWhatsAppConfigAllowFromEntries(allowFrom: Array): string[] { return normalizeWhatsAppAllowFromEntries(allowFrom); } +/** Resolve the effective WhatsApp default recipient after account and root config fallback. */ export function resolveWhatsAppConfigDefaultTo(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -167,6 +176,7 @@ export function resolveWhatsAppConfigDefaultTo(params: { return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined; } +/** Read iMessage allowlist entries from the active plugin's resolved account view. */ export function resolveIMessageConfigAllowFrom(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -178,6 +188,7 @@ export function resolveIMessageConfigAllowFrom(params: { return mapAllowFromEntries(account.config.allowFrom); } +/** Resolve the effective iMessage default recipient from the plugin-resolved account config. */ export function resolveIMessageConfigDefaultTo(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/plugin-sdk/channel-lifecycle.ts b/src/plugin-sdk/channel-lifecycle.ts index 7d4fea578d5..28045aeb058 100644 --- a/src/plugin-sdk/channel-lifecycle.ts +++ b/src/plugin-sdk/channel-lifecycle.ts @@ -11,6 +11,7 @@ type PassiveAccountLifecycleParams = { onStop?: () => void | Promise; }; +/** Bind a fixed account id into a status writer so lifecycle code can emit partial snapshots. */ export function createAccountStatusSink(params: { accountId: string; setStatus: (next: ChannelAccountSnapshot) => void; diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts index e64ff290fea..b73df6f0448 100644 --- a/src/plugin-sdk/channel-send-result.ts +++ b/src/plugin-sdk/channel-send-result.ts @@ -4,6 +4,7 @@ export type ChannelSendRawResult = { error?: string | null; }; +/** Normalize raw channel send results into the shape shared outbound callers expect. */ export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) { return { channel, diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 2e95974cf1f..0a09e0c1dcd 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -33,6 +33,7 @@ export type ResolveSenderCommandAuthorizationWithRuntimeParams = Omit< runtime: CommandAuthorizationRuntime; }; +/** Fast-path DM command authorization when only policy and sender allowlist state matter. */ export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; @@ -50,6 +51,7 @@ export function resolveDirectDmAuthorizationOutcome(params: { return "allowed"; } +/** Runtime-backed wrapper around sender command authorization for grouped helper surfaces. */ export async function resolveSenderCommandAuthorizationWithRuntime( params: ResolveSenderCommandAuthorizationWithRuntimeParams, ): ReturnType { @@ -60,6 +62,7 @@ export async function resolveSenderCommandAuthorizationWithRuntime( }); } +/** Compute effective allowlists and command authorization for one inbound sender. */ export async function resolveSenderCommandAuthorization( params: ResolveSenderCommandAuthorizationParams, ): Promise<{ diff --git a/src/plugin-sdk/config-paths.ts b/src/plugin-sdk/config-paths.ts index 06940f1842a..00c67a3036b 100644 --- a/src/plugin-sdk/config-paths.ts +++ b/src/plugin-sdk/config-paths.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; +/** Resolve the config path prefix for a channel account, falling back to the root channel section. */ export function resolveChannelAccountConfigBasePath(params: { cfg: OpenClawConfig; channelKey: string; diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts index d3cdaf38a22..6cca5f9f803 100644 --- a/src/plugin-sdk/discord-send.ts +++ b/src/plugin-sdk/discord-send.ts @@ -11,6 +11,7 @@ type DiscordSendMediaOptionInput = DiscordSendOptionInput & { mediaLocalRoots?: readonly string[]; }; +/** Build the common Discord send options from SDK-level reply payload fields. */ export function buildDiscordSendOptions(input: DiscordSendOptionInput) { return { verbose: false, @@ -20,6 +21,7 @@ export function buildDiscordSendOptions(input: DiscordSendOptionInput) { }; } +/** Extend the base Discord send options with media-specific fields. */ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) { return { ...buildDiscordSendOptions(input), @@ -28,6 +30,7 @@ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) }; } +/** Stamp raw Discord send results with the channel id expected by shared outbound flows. */ export function tagDiscordChannelResult(result: DiscordSendResult) { return { channel: "discord" as const, ...result }; } diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index c2060fa4b3e..82ffb8dde5c 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,16 +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 * from "./channel-plugin-common.js"; -export { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "../../extensions/discord/src/accounts.js"; -export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -19,13 +11,6 @@ export { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; -export { - looksLikeDiscordTargetId, - normalizeDiscordMessagingTarget, - normalizeDiscordOutboundTarget, -} from "../channels/plugins/normalize/discord.js"; -export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { collectDiscordStatusIssues } from "../channels/plugins/status-issues/discord.js"; export { resolveDefaultGroupPolicy, @@ -35,21 +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 type { - ThreadBindingManager, - ThreadBindingRecord, - ThreadBindingTargetKind, -} from "../../extensions/discord/src/monitor/thread-bindings.js"; - export { buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary, diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts index 04b7902de9e..cc337c16fcf 100644 --- a/src/plugin-sdk/entrypoints.ts +++ b/src/plugin-sdk/entrypoints.ts @@ -4,18 +4,21 @@ export const pluginSdkEntrypoints = [...pluginSdkEntryList]; export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); +/** Map every SDK entrypoint name to its source file path inside the repo. */ export function buildPluginSdkEntrySources() { return Object.fromEntries( pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), ); } +/** List the public package specifiers that should resolve to plugin SDK entrypoints. */ export function buildPluginSdkSpecifiers() { return pluginSdkEntrypoints.map((entry) => entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, ); } +/** Build the package.json exports map for all plugin SDK subpaths. */ export function buildPluginSdkPackageExports() { return Object.fromEntries( pluginSdkEntrypoints.map((entry) => [ @@ -28,6 +31,7 @@ export function buildPluginSdkPackageExports() { ); } +/** List the dist artifacts expected for every generated plugin SDK entrypoint. */ export function listPluginSdkDistArtifacts() { return pluginSdkEntrypoints.flatMap((entry) => [ `dist/plugin-sdk/${entry}.js`, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 4a6d8dc6251..ee15823738b 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -76,6 +76,10 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { withTempDownloadPath } from "./temp-path.js"; +export { + buildFeishuConversationId, + parseFeishuConversationId, +} from "../../extensions/feishu/src/conversation-id.js"; export { createFixedWindowRateLimiter, createWebhookAnomalyTracker, diff --git a/src/plugin-sdk/fetch-auth.ts b/src/plugin-sdk/fetch-auth.ts index fc04e4aa910..10945bb2a44 100644 --- a/src/plugin-sdk/fetch-auth.ts +++ b/src/plugin-sdk/fetch-auth.ts @@ -6,6 +6,7 @@ function isAuthFailureStatus(status: number): boolean { return status === 401 || status === 403; } +/** Retry a fetch with bearer tokens from the provided scopes when the unauthenticated attempt fails. */ export async function fetchWithBearerAuthScopeFallback(params: { url: string; scopes: readonly string[]; diff --git a/src/plugin-sdk/file-lock.ts b/src/plugin-sdk/file-lock.ts index 98277381868..3870c38fc35 100644 --- a/src/plugin-sdk/file-lock.ts +++ b/src/plugin-sdk/file-lock.ts @@ -100,6 +100,7 @@ async function releaseHeldLock(normalizedFile: string): Promise { await fs.rm(current.lockPath, { force: true }).catch(() => undefined); } +/** Acquire a re-entrant process-local file lock backed by a `.lock` sidecar file. */ export async function acquireFileLock( filePath: string, options: FileLockOptions, @@ -147,6 +148,7 @@ export async function acquireFileLock( throw new Error(`file lock timeout for ${normalizedFile}`); } +/** Run an async callback while holding a file lock, always releasing the lock afterward. */ export async function withFileLock( filePath: string, options: FileLockOptions, diff --git a/src/plugin-sdk/group-access.ts b/src/plugin-sdk/group-access.ts index 5a58242338b..bec84b4ba7c 100644 --- a/src/plugin-sdk/group-access.ts +++ b/src/plugin-sdk/group-access.ts @@ -40,6 +40,7 @@ export type MatchedGroupAccessDecision = { reason: MatchedGroupAccessReason; }; +/** Downgrade sender-scoped group policy to open mode when no allowlist is configured. */ export function resolveSenderScopedGroupPolicy(params: { groupPolicy: GroupPolicy; groupAllowFrom: string[]; @@ -50,6 +51,7 @@ export function resolveSenderScopedGroupPolicy(params: { return params.groupAllowFrom.length > 0 ? "allowlist" : "open"; } +/** Evaluate route-level group access after policy, route match, and enablement checks. */ export function evaluateGroupRouteAccessForPolicy(params: { groupPolicy: GroupPolicy; routeAllowlistConfigured: boolean; @@ -96,6 +98,7 @@ export function evaluateGroupRouteAccessForPolicy(params: { }; } +/** Evaluate generic allowlist match state for channels that compare derived group identifiers. */ export function evaluateMatchedGroupAccessForPolicy(params: { groupPolicy: GroupPolicy; allowlistConfigured: boolean; @@ -142,6 +145,7 @@ export function evaluateMatchedGroupAccessForPolicy(params: { }; } +/** Evaluate sender access for an already-resolved group policy and allowlist. */ export function evaluateSenderGroupAccessForPolicy(params: { groupPolicy: GroupPolicy; providerMissingFallbackApplied?: boolean; @@ -184,6 +188,7 @@ export function evaluateSenderGroupAccessForPolicy(params: { }; } +/** Resolve provider fallback policy first, then evaluate sender access against that result. */ export function evaluateSenderGroupAccess(params: { providerConfigPresent: boolean; configuredGroupPolicy?: GroupPolicy; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index a3a42f110ee..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,19 +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 { resolveAllowlistProviderRuntimeGroupPolicy, @@ -37,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/inbound-envelope.ts b/src/plugin-sdk/inbound-envelope.ts index 2a4ff0aaa06..f3662b725c3 100644 --- a/src/plugin-sdk/inbound-envelope.ts +++ b/src/plugin-sdk/inbound-envelope.ts @@ -24,6 +24,7 @@ type InboundRouteResolveParams = { peer: TPeer; }; +/** Create an envelope formatter bound to one resolved route and session store. */ export function createInboundEnvelopeBuilder(params: { cfg: TConfig; route: RouteLike; @@ -54,6 +55,7 @@ export function createInboundEnvelopeBuilder(params: { }; } +/** Resolve a route first, then return both the route and a formatter for future inbound messages. */ export function resolveInboundRouteEnvelopeBuilder< TConfig, TEnvelope, @@ -111,6 +113,7 @@ type InboundRouteEnvelopeRuntime< }; }; +/** Runtime-driven variant of inbound envelope resolution for plugins that already expose grouped helpers. */ export function resolveInboundRouteEnvelopeBuilderWithRuntime< TConfig, TEnvelope, diff --git a/src/plugin-sdk/inbound-reply-dispatch.ts b/src/plugin-sdk/inbound-reply-dispatch.ts index cf11b3ee451..b2ba466a21c 100644 --- a/src/plugin-sdk/inbound-reply-dispatch.ts +++ b/src/plugin-sdk/inbound-reply-dispatch.ts @@ -20,6 +20,7 @@ type DispatchReplyWithBufferedBlockDispatcherFn = type ReplyDispatchFromConfigOptions = Omit; +/** Run `dispatchReplyFromConfig` with a dispatcher that always gets its settled callback. */ export async function dispatchReplyFromConfigWithSettledDispatcher(params: { cfg: OpenClawConfig; ctxPayload: FinalizedMsgContext; @@ -40,6 +41,7 @@ export async function dispatchReplyFromConfigWithSettledDispatcher(params: { }); } +/** Assemble the common inbound reply dispatch dependencies for a resolved route. */ export function buildInboundReplyDispatchBase(params: { cfg: OpenClawConfig; channel: string; @@ -80,6 +82,7 @@ type RecordInboundSessionAndDispatchReplyParams = Parameters< typeof recordInboundSessionAndDispatchReply >[0]; +/** Resolve the shared dispatch base and immediately record + dispatch one inbound reply turn. */ export async function dispatchInboundReplyWithBase( params: BuildInboundReplyDispatchBaseParams & Pick< @@ -97,6 +100,7 @@ export async function dispatchInboundReplyWithBase( }); } +/** Record the inbound session first, then dispatch the reply using normalized outbound delivery. */ export async function recordInboundSessionAndDispatchReply(params: { cfg: OpenClawConfig; channel: string; 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/json-store.ts b/src/plugin-sdk/json-store.ts index 5c08be6c561..faff8f64e59 100644 --- a/src/plugin-sdk/json-store.ts +++ b/src/plugin-sdk/json-store.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { writeJsonAtomic } from "../infra/json-files.js"; import { safeParseJson } from "../utils.js"; +/** Read JSON from disk and fall back cleanly when the file is missing or invalid. */ export async function readJsonFileWithFallback( filePath: string, fallback: T, @@ -22,6 +23,7 @@ export async function readJsonFileWithFallback( } } +/** Write JSON with secure file permissions and atomic replacement semantics. */ export async function writeJsonFileAtomically(filePath: string, value: unknown): Promise { await writeJsonAtomic(filePath, value, { mode: 0o600, diff --git a/src/plugin-sdk/keyed-async-queue.ts b/src/plugin-sdk/keyed-async-queue.ts index 6e79cf35d59..0f07f3c8462 100644 --- a/src/plugin-sdk/keyed-async-queue.ts +++ b/src/plugin-sdk/keyed-async-queue.ts @@ -3,6 +3,7 @@ export type KeyedAsyncQueueHooks = { onSettle?: () => void; }; +/** Serialize async work per key while allowing unrelated keys to run concurrently. */ export function enqueueKeyedTask(params: { tails: Map>; key: string; diff --git a/src/plugin-sdk/oauth-utils.ts b/src/plugin-sdk/oauth-utils.ts index a6465d4d40e..e96a1856946 100644 --- a/src/plugin-sdk/oauth-utils.ts +++ b/src/plugin-sdk/oauth-utils.ts @@ -1,11 +1,13 @@ import { createHash, randomBytes } from "node:crypto"; +/** Encode a flat object as application/x-www-form-urlencoded form data. */ export function toFormUrlEncoded(data: Record): string { return Object.entries(data) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&"); } +/** Generate a PKCE verifier/challenge pair suitable for OAuth authorization flows. */ export function generatePkceVerifierChallenge(): { verifier: string; challenge: string } { const verifier = randomBytes(32).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); diff --git a/src/plugin-sdk/outbound-media.ts b/src/plugin-sdk/outbound-media.ts index 731ca9d140e..979f8ac77a3 100644 --- a/src/plugin-sdk/outbound-media.ts +++ b/src/plugin-sdk/outbound-media.ts @@ -5,6 +5,7 @@ export type OutboundMediaLoadOptions = { mediaLocalRoots?: readonly string[]; }; +/** Load outbound media from a remote URL or approved local path using the shared web-media policy. */ export async function loadOutboundMediaFromUrl( mediaUrl: string, options: OutboundMediaLoadOptions = {}, diff --git a/src/plugin-sdk/pairing-access.ts b/src/plugin-sdk/pairing-access.ts index 31f0cd4d3a7..260f24c89d1 100644 --- a/src/plugin-sdk/pairing-access.ts +++ b/src/plugin-sdk/pairing-access.ts @@ -8,6 +8,7 @@ type ScopedUpsertInput = Omit< "channel" | "accountId" >; +/** Scope pairing store operations to one channel/account pair for plugin-facing helpers. */ export function createScopedPairingAccess(params: { core: PluginRuntime; channel: ChannelId; diff --git a/src/plugin-sdk/persistent-dedupe.ts b/src/plugin-sdk/persistent-dedupe.ts index 0b33824c795..7a6ea159841 100644 --- a/src/plugin-sdk/persistent-dedupe.ts +++ b/src/plugin-sdk/persistent-dedupe.ts @@ -91,6 +91,7 @@ function pruneData( }); } +/** Create a dedupe helper that combines in-memory fast checks with a lock-protected disk store. */ export function createPersistentDedupe(options: PersistentDedupeOptions): PersistentDedupe { const ttlMs = Math.max(0, Math.floor(options.ttlMs)); const memoryMaxSize = Math.max(0, Math.floor(options.memoryMaxSize)); diff --git a/src/plugin-sdk/provider-auth-result.ts b/src/plugin-sdk/provider-auth-result.ts index c16c23cc15e..ece6cebb0df 100644 --- a/src/plugin-sdk/provider-auth-result.ts +++ b/src/plugin-sdk/provider-auth-result.ts @@ -2,6 +2,7 @@ import type { AuthProfileCredential } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ProviderAuthResult } from "../plugins/types.js"; +/** Build the standard auth result payload for OAuth-style provider login flows. */ export function buildOauthProviderAuthResult(params: { providerId: string; defaultModel: string; diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index e141da2a940..a35380f5250 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -5,6 +5,7 @@ export type OutboundReplyPayload = { replyToId?: string; }; +/** Extract the supported outbound reply fields from loose tool or agent payload objects. */ export function normalizeOutboundReplyPayload( payload: Record, ): OutboundReplyPayload { @@ -24,6 +25,7 @@ export function normalizeOutboundReplyPayload( }; } +/** Wrap a deliverer so callers can hand it arbitrary payloads while channels receive normalized data. */ export function createNormalizedOutboundDeliverer( handler: (payload: OutboundReplyPayload) => Promise, ): (payload: unknown) => Promise { @@ -36,6 +38,7 @@ export function createNormalizedOutboundDeliverer( }; } +/** Prefer multi-attachment payloads, then fall back to the legacy single-media field. */ export function resolveOutboundMediaUrls(payload: { mediaUrls?: string[]; mediaUrl?: string; @@ -49,6 +52,7 @@ export function resolveOutboundMediaUrls(payload: { return []; } +/** Send media-first payloads intact, or chunk text-only payloads through the caller's transport hooks. */ export async function sendPayloadWithChunkedTextAndMedia< TContext extends { payload: object }, TResult, @@ -90,6 +94,7 @@ export async function sendPayloadWithChunkedTextAndMedia< return lastResult!; } +/** Detect numeric-looking target ids for channels that distinguish ids from handles. */ export function isNumericTargetId(raw: string): boolean { const trimmed = raw.trim(); if (!trimmed) { @@ -98,6 +103,7 @@ export function isNumericTargetId(raw: string): boolean { return /^\d{3,}$/.test(trimmed); } +/** Append attachment links to plain text when the channel cannot send media inline. */ export function formatTextWithAttachmentLinks( text: string | undefined, mediaUrls: string[], @@ -118,6 +124,7 @@ export function formatTextWithAttachmentLinks( return `${trimmedText}\n\n${mediaBlock}`; } +/** Send a caption with only the first media item, mirroring caption-limited channel transports. */ export async function sendMediaWithLeadingCaption(params: { mediaUrls: string[]; caption: string; diff --git a/src/plugin-sdk/request-url.ts b/src/plugin-sdk/request-url.ts index 2ba7354cc28..a1ce2216276 100644 --- a/src/plugin-sdk/request-url.ts +++ b/src/plugin-sdk/request-url.ts @@ -1,3 +1,4 @@ +/** Extract a string URL from the common request-like inputs accepted by fetch helpers. */ export function resolveRequestUrl(input: RequestInfo | URL): string { if (typeof input === "string") { return input; diff --git a/src/plugin-sdk/resolution-notes.ts b/src/plugin-sdk/resolution-notes.ts index 9baf64c21d4..49b2f9c67e9 100644 --- a/src/plugin-sdk/resolution-notes.ts +++ b/src/plugin-sdk/resolution-notes.ts @@ -1,3 +1,4 @@ +/** Format a short note that separates successfully resolved targets from unresolved passthrough values. */ export function formatResolvedUnresolvedNote(params: { resolved: string[]; unresolved: string[]; diff --git a/src/plugin-sdk/run-command.ts b/src/plugin-sdk/run-command.ts index 03f0846a57e..7cf626ce8ef 100644 --- a/src/plugin-sdk/run-command.ts +++ b/src/plugin-sdk/run-command.ts @@ -13,6 +13,7 @@ export type PluginCommandRunOptions = { env?: NodeJS.ProcessEnv; }; +/** Run a plugin-managed command with timeout handling and normalized stdout/stderr results. */ export async function runPluginCommandWithTimeout( options: PluginCommandRunOptions, ): Promise { diff --git a/src/plugin-sdk/runtime-store.ts b/src/plugin-sdk/runtime-store.ts index de0d84131e1..67e8bb3644c 100644 --- a/src/plugin-sdk/runtime-store.ts +++ b/src/plugin-sdk/runtime-store.ts @@ -1,3 +1,4 @@ +/** Create a tiny mutable runtime slot with strict access when the runtime has not been initialized. */ export function createPluginRuntimeStore(errorMessage: string): { setRuntime: (next: T) => void; clearRuntime: () => void; diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index c438a4e9788..75b6f955dc7 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -6,6 +6,7 @@ type LoggerLike = { error: (message: string) => void; }; +/** Adapt a simple logger into the RuntimeEnv contract used by shared plugin SDK helpers. */ export function createLoggerBackedRuntime(params: { logger: LoggerLike; exitError?: (code: number) => Error; @@ -23,6 +24,7 @@ export function createLoggerBackedRuntime(params: { }; } +/** Reuse an existing runtime when present, otherwise synthesize one from the provided logger. */ export function resolveRuntimeEnv(params: { runtime?: RuntimeEnv; logger: LoggerLike; @@ -31,6 +33,7 @@ export function resolveRuntimeEnv(params: { return params.runtime ?? createLoggerBackedRuntime(params); } +/** Resolve a runtime that treats exit requests as unsupported errors instead of process termination. */ export function resolveRuntimeEnvWithUnavailableExit(params: { runtime?: RuntimeEnv; logger: LoggerLike; diff --git a/src/plugin-sdk/secret-input-schema.ts b/src/plugin-sdk/secret-input-schema.ts index 579d80df441..96d5247c767 100644 --- a/src/plugin-sdk/secret-input-schema.ts +++ b/src/plugin-sdk/secret-input-schema.ts @@ -7,6 +7,7 @@ import { SECRET_PROVIDER_ALIAS_PATTERN, } from "../secrets/ref-contract.js"; +/** Build the shared zod schema for secret inputs accepted by plugin auth/config surfaces. */ export function buildSecretInputSchema() { const providerSchema = z .string() diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index b407e944e47..86f83b06318 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,18 +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 { looksLikeSignalTargetId, normalizeSignalMessagingTarget, @@ -22,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-message-actions.ts b/src/plugin-sdk/slack-message-actions.ts index 1dbf597d0bf..ef7a5f12876 100644 --- a/src/plugin-sdk/slack-message-actions.ts +++ b/src/plugin-sdk/slack-message-actions.ts @@ -15,6 +15,7 @@ function readSlackBlocksParam(actionParams: Record) { return parseSlackBlocksInput(actionParams.blocks) as Record[] | undefined; } +/** Translate generic channel action requests into Slack-specific tool invocations and payload shapes. */ export async function handleSlackMessageAction(params: { providerId: string; ctx: ChannelMessageActionContext; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 0cbfd236274..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,14 +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 { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; - export { resolveDefaultGroupPolicy, resolveOpenProviderRuntimeGroupPolicy, @@ -41,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/ssrf-policy.ts b/src/plugin-sdk/ssrf-policy.ts index 351938d0456..420f7dfc6b7 100644 --- a/src/plugin-sdk/ssrf-policy.ts +++ b/src/plugin-sdk/ssrf-policy.ts @@ -24,6 +24,7 @@ function isHostnameAllowedBySuffixAllowlist( return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`)); } +/** Normalize suffix-style host allowlists into lowercase canonical entries with wildcard collapse. */ export function normalizeHostnameSuffixAllowlist( input?: readonly string[], defaults?: readonly string[], @@ -39,6 +40,7 @@ export function normalizeHostnameSuffixAllowlist( return Array.from(new Set(normalized)); } +/** Check whether a URL is HTTPS and its hostname matches the normalized suffix allowlist. */ export function isHttpsUrlAllowedByHostnameSuffixAllowlist( url: string, allowlist: readonly string[], diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index 42aad35a702..231c438b8ef 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -9,6 +9,7 @@ type RuntimeLifecycleSnapshot = { lastOutboundAt?: number | null; }; +/** Create the baseline runtime snapshot shape used by channel/account status stores. */ export function createDefaultChannelRuntimeState>( accountId: string, extra?: T, @@ -29,6 +30,7 @@ export function createDefaultChannelRuntimeState>( snapshot: { configured?: boolean | null; @@ -65,6 +68,7 @@ export function buildProbeChannelStatusSummary, 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 e8e823a6628..397a48fa019 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -8,12 +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 { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { @@ -28,15 +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 { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "../../extensions/telegram/src/accounts.js"; -export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, @@ -45,33 +34,6 @@ export { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; -export { - looksLikeTelegramTargetId, - normalizeTelegramMessagingTarget, -} from "../channels/plugins/normalize/telegram.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 { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.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 { resolveAllowlistProviderRuntimeGroupPolicy, @@ -81,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/temp-path.ts b/src/plugin-sdk/temp-path.ts index c418fe9f664..436377fe5e1 100644 --- a/src/plugin-sdk/temp-path.ts +++ b/src/plugin-sdk/temp-path.ts @@ -40,6 +40,7 @@ function isNodeErrorWithCode(err: unknown, code: string): boolean { ); } +/** Build a unique temp file path with sanitized prefix/extension parts. */ export function buildRandomTempFilePath(params: { prefix: string; extension?: string; @@ -58,6 +59,7 @@ export function buildRandomTempFilePath(params: { return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`); } +/** Create a temporary download directory, run the callback, then clean it up best-effort. */ export async function withTempDownloadPath( params: { prefix: string; diff --git a/src/plugin-sdk/text-chunking.ts b/src/plugin-sdk/text-chunking.ts index 47c98c10851..724c651168a 100644 --- a/src/plugin-sdk/text-chunking.ts +++ b/src/plugin-sdk/text-chunking.ts @@ -1,5 +1,6 @@ import { chunkTextByBreakResolver } from "../shared/text-chunking.js"; +/** Chunk outbound text while preferring newline boundaries over spaces. */ export function chunkTextForOutbound(text: string, limit: number): string[] { return chunkTextByBreakResolver(text, limit, (window) => { const lastNewline = window.lastIndexOf("\n"); diff --git a/src/plugin-sdk/tool-send.ts b/src/plugin-sdk/tool-send.ts index 835cd688d8a..61ee56fa9ac 100644 --- a/src/plugin-sdk/tool-send.ts +++ b/src/plugin-sdk/tool-send.ts @@ -1,3 +1,4 @@ +/** Extract the canonical send target fields from tool arguments when the action matches. */ export function extractToolSend( args: Record, expectedAction = "sendMessage", 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/webhook-memory-guards.ts b/src/plugin-sdk/webhook-memory-guards.ts index 50a43c0b3ab..6b4061cac3b 100644 --- a/src/plugin-sdk/webhook-memory-guards.ts +++ b/src/plugin-sdk/webhook-memory-guards.ts @@ -48,6 +48,7 @@ export type WebhookAnomalyTracker = { clear: () => void; }; +/** Create a simple fixed-window rate limiter for in-memory webhook protection. */ export function createFixedWindowRateLimiter(options: { windowMs: number; maxRequests: number; @@ -104,6 +105,7 @@ export function createFixedWindowRateLimiter(options: { }; } +/** Count keyed events in memory with optional TTL pruning and bounded cardinality. */ export function createBoundedCounter(options: { maxTrackedKeys: number; ttlMs?: number; @@ -161,6 +163,7 @@ export function createBoundedCounter(options: { }; } +/** Track repeated webhook failures and emit sampled logs for suspicious request patterns. */ export function createWebhookAnomalyTracker(options?: { maxTrackedKeys?: number; ttlMs?: number; diff --git a/src/plugin-sdk/webhook-path.ts b/src/plugin-sdk/webhook-path.ts index 41e4bd0ba98..fa68c9e20ee 100644 --- a/src/plugin-sdk/webhook-path.ts +++ b/src/plugin-sdk/webhook-path.ts @@ -1,3 +1,4 @@ +/** Normalize webhook paths into the canonical registry form used by route lookup. */ export function normalizeWebhookPath(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { @@ -10,6 +11,7 @@ export function normalizeWebhookPath(raw: string): string { return withSlash; } +/** Resolve the effective webhook path from explicit path, URL, or default fallback. */ export function resolveWebhookPath(params: { webhookPath?: string; webhookUrl?: string; diff --git a/src/plugin-sdk/webhook-request-guards.ts b/src/plugin-sdk/webhook-request-guards.ts index a45df7c06dd..f181859bc84 100644 --- a/src/plugin-sdk/webhook-request-guards.ts +++ b/src/plugin-sdk/webhook-request-guards.ts @@ -81,6 +81,7 @@ function respondWebhookBodyReadError(params: { return { ok: false }; } +/** Create an in-memory limiter that caps concurrent webhook handlers per key. */ export function createWebhookInFlightLimiter(options?: { maxInFlightPerKey?: number; maxTrackedKeys?: number; @@ -127,6 +128,7 @@ export function createWebhookInFlightLimiter(options?: { }; } +/** Detect JSON content types, including structured syntax suffixes like `application/ld+json`. */ export function isJsonContentType(value: string | string[] | undefined): boolean { const first = Array.isArray(value) ? value[0] : value; if (!first) { @@ -136,6 +138,7 @@ export function isJsonContentType(value: string | string[] | undefined): boolean return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); } +/** Apply method, rate-limit, and content-type guards before a webhook handler reads the body. */ export function applyBasicWebhookRequestGuards(params: { req: IncomingMessage; res: ServerResponse; @@ -176,6 +179,7 @@ export function applyBasicWebhookRequestGuards(params: { return true; } +/** Start the shared webhook request lifecycle and return a release hook for in-flight tracking. */ export function beginWebhookRequestPipelineOrReject(params: { req: IncomingMessage; res: ServerResponse; @@ -226,6 +230,7 @@ export function beginWebhookRequestPipelineOrReject(params: { }; } +/** Read a webhook request body with bounded size/time limits and translate failures into responses. */ export async function readWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; @@ -260,6 +265,7 @@ export async function readWebhookBodyOrReject(params: { } } +/** Read and parse a JSON webhook body, rejecting malformed or oversized payloads consistently. */ export async function readJsonWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; diff --git a/src/plugin-sdk/webhook-targets.ts b/src/plugin-sdk/webhook-targets.ts index 791f4591101..e3dd9eda01d 100644 --- a/src/plugin-sdk/webhook-targets.ts +++ b/src/plugin-sdk/webhook-targets.ts @@ -24,6 +24,7 @@ export type RegisterWebhookPluginRouteOptions = Omit< "path" | "fallbackPath" >; +/** Register a webhook target and lazily install the matching plugin HTTP route on first use. */ export function registerWebhookTargetWithPluginRoute(params: { targetsByPath: Map; target: T; @@ -54,6 +55,7 @@ function getPathTeardownMap(targetsByPath: Map): Map( targetsByPath: Map, target: T, @@ -99,6 +101,7 @@ export function registerWebhookTarget( return { target: normalizedTarget, unregister }; } +/** Resolve all registered webhook targets for the incoming request path. */ export function resolveWebhookTargets( req: IncomingMessage, targetsByPath: Map, @@ -112,6 +115,7 @@ export function resolveWebhookTargets( return { path, targets }; } +/** Run common webhook guards, then dispatch only when the request path resolves to live targets. */ export async function withResolvedWebhookRequestPipeline(params: { req: IncomingMessage; res: ServerResponse; @@ -183,6 +187,7 @@ function finalizeMatchedWebhookTarget(matched: T | undefined): WebhookTargetM return { kind: "single", target: matched }; } +/** Match exactly one synchronous target or report whether resolution was empty or ambiguous. */ export function resolveSingleWebhookTarget( targets: readonly T[], isMatch: (target: T) => boolean, @@ -201,6 +206,7 @@ export function resolveSingleWebhookTarget( return finalizeMatchedWebhookTarget(matched); } +/** Async variant of single-target resolution for auth checks that need I/O. */ export async function resolveSingleWebhookTargetAsync( targets: readonly T[], isMatch: (target: T) => Promise, @@ -219,6 +225,7 @@ export async function resolveSingleWebhookTargetAsync( return finalizeMatchedWebhookTarget(matched); } +/** Resolve an authorized target and send the standard unauthorized or ambiguous response on failure. */ export async function resolveWebhookTargetWithAuthOrReject(params: { targets: readonly T[]; res: ServerResponse; @@ -234,6 +241,7 @@ export async function resolveWebhookTargetWithAuthOrReject(params: { return resolveWebhookTargetMatchOrReject(params, match); } +/** Synchronous variant of webhook auth resolution for cheap in-memory match checks. */ export function resolveWebhookTargetWithAuthOrRejectSync(params: { targets: readonly T[]; res: ServerResponse; @@ -270,6 +278,7 @@ function resolveWebhookTargetMatchOrReject( return null; } +/** Reject non-POST webhook requests with the conventional Allow header. */ export function rejectNonPostWebhookRequest(req: IncomingMessage, res: ServerResponse): boolean { if (req.method === "POST") { return false; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index e84a60e785c..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,6 +24,7 @@ export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; +export { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; export { collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, @@ -51,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/plugin-sdk/windows-spawn.ts b/src/plugin-sdk/windows-spawn.ts index 16d24de629a..9a296508165 100644 --- a/src/plugin-sdk/windows-spawn.ts +++ b/src/plugin-sdk/windows-spawn.ts @@ -52,6 +52,7 @@ function isFilePath(candidate: string): boolean { } } +/** Resolve a Windows command name through PATH and PATHEXT so wrapper inspection sees the real file. */ export function resolveWindowsExecutablePath(command: string, env: NodeJS.ProcessEnv): string { if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) { return command; @@ -188,6 +189,7 @@ function resolveEntrypointFromPackageJson( return null; } +/** Resolve the safest direct spawn candidate for Windows wrappers, scripts, and binaries. */ export function resolveWindowsSpawnProgramCandidate( params: ResolveWindowsSpawnProgramCandidateParams, ): WindowsSpawnProgramCandidate { @@ -250,6 +252,7 @@ export function resolveWindowsSpawnProgramCandidate( }; } +/** Apply shell-fallback policy when Windows wrapper resolution could not find a direct entrypoint. */ export function applyWindowsSpawnProgramPolicy(params: { candidate: WindowsSpawnProgramCandidate; allowShellFallback?: boolean; @@ -275,6 +278,7 @@ export function applyWindowsSpawnProgramPolicy(params: { ); } +/** Resolve the final Windows spawn program after candidate discovery and fallback policy. */ export function resolveWindowsSpawnProgram( params: ResolveWindowsSpawnProgramParams, ): WindowsSpawnProgram { @@ -285,6 +289,7 @@ export function resolveWindowsSpawnProgram( }); } +/** Combine a resolved Windows spawn program with call-site argv for actual process launch. */ export function materializeWindowsSpawnProgram( program: WindowsSpawnProgram, argv: string[], 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/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts new file mode 100644 index 00000000000..fa4f4daa0ad --- /dev/null +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; +import { applyAuthChoiceLoadedPluginProvider } from "../../commands/auth-choice.apply.plugin-provider.js"; +import { resolvePreferredProviderForAuthChoice } from "../../commands/auth-choice.preferred-provider.js"; +import type { AuthChoice } from "../../commands/onboard-types.js"; +import { + createAuthTestLifecycle, + createExitThrowingRuntime, + createWizardPrompter, + readAuthProfilesForAgent, + requireOpenClawAgentDir, + setupAuthTestEnv, +} from "../../commands/test-wizard-helpers.js"; +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(() => [])); +const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn()); +const runProviderModelSelectedHookMock = vi.hoisted(() => + vi.fn(async () => {}), +); + +vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({ + loginQwenPortalOAuth: loginQwenPortalOAuthMock, +})); + +vi.mock("../../providers/github-copilot-auth.js", () => ({ + githubCopilotLoginCommand: githubCopilotLoginCommandMock, +})); + +vi.mock("../../commands/auth-choice.apply.plugin-provider.runtime.js", () => ({ + resolvePluginProviders: resolvePluginProvidersMock, + resolveProviderPluginChoice: resolveProviderPluginChoiceMock, + runProviderModelSelectedHook: runProviderModelSelectedHookMock, +})); + +type StoredAuthProfile = { + type?: string; + provider?: string; + access?: string; + refresh?: string; + key?: string; + token?: string; +}; + +const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; + +function registerProviders(...plugins: Array<{ register(api: OpenClawPluginApi): void }>) { + const captured = createCapturedPluginRegistration(); + for (const plugin of plugins) { + plugin.register(captured.api); + } + return captured.providers; +} + +function requireProvider(providers: ProviderPlugin[], providerId: string) { + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + throw new Error(`provider ${providerId} missing`); + } + return provider; +} + +describe("provider auth-choice contract", () => { + const lifecycle = createAuthTestLifecycle([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); + let activeStateDir: string | null = null; + + async function setupTempState() { + if (activeStateDir) { + await lifecycle.cleanup(); + } + const env = await setupAuthTestEnv("openclaw-provider-auth-choice-"); + activeStateDir = env.stateDir; + lifecycle.setStateDir(env.stateDir); + } + + afterEach(async () => { + loginQwenPortalOAuthMock.mockReset(); + githubCopilotLoginCommandMock.mockReset(); + resolvePluginProvidersMock.mockReset(); + resolvePluginProvidersMock.mockReturnValue([]); + resolveProviderPluginChoiceMock.mockReset(); + resolveProviderPluginChoiceMock.mockReturnValue(null); + runProviderModelSelectedHookMock.mockReset(); + clearRuntimeAuthProfileStoreSnapshots(); + await lifecycle.cleanup(); + activeStateDir = null; + }); + + it("maps plugin-backed auth choices through the shared preferred-provider resolver", async () => { + const scenarios = [ + { authChoice: "github-copilot" as const, expectedProvider: "github-copilot" }, + { authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" }, + { authChoice: "minimax-global-oauth" as const, expectedProvider: "minimax-portal" }, + { authChoice: "modelstudio-api-key" as const, expectedProvider: "modelstudio" }, + { authChoice: "ollama" as const, expectedProvider: "ollama" }, + { authChoice: "unknown" as AuthChoice, expectedProvider: undefined }, + ] as const; + + for (const scenario of scenarios) { + await expect( + resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice }), + ).resolves.toBe(scenario.expectedProvider); + } + }); + + it("applies qwen portal auth choices through the shared plugin-provider path", async () => { + await setupTempState(); + const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); + resolvePluginProvidersMock.mockReturnValue([qwenProvider]); + resolveProviderPluginChoiceMock.mockReturnValue({ + provider: qwenProvider, + method: qwenProvider.auth[0], + }); + loginQwenPortalOAuthMock.mockResolvedValueOnce({ + access: "access-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + resourceUrl: "portal.qwen.ai", + }); + + const note = vi.fn(async () => {}); + const result = await applyAuthChoiceLoadedPluginProvider({ + authChoice: "qwen-portal", + config: {}, + prompter: createWizardPrompter({ note }), + runtime: createExitThrowingRuntime(), + setDefaultModel: true, + }); + + expect(result?.config.agents?.defaults?.model).toEqual({ + primary: "qwen-portal/coder-model", + }); + expect(result?.config.auth?.profiles?.["qwen-portal:default"]).toMatchObject({ + provider: "qwen-portal", + mode: "oauth", + }); + expect(result?.config.models?.providers?.["qwen-portal"]).toMatchObject({ + baseUrl: "https://portal.qwen.ai/v1", + models: [], + }); + expect(note).toHaveBeenCalledWith( + "Default model set to qwen-portal/coder-model", + "Model configured", + ); + + const stored = await readAuthProfilesForAgent<{ profiles?: Record }>( + requireOpenClawAgentDir(), + ); + expect(stored.profiles?.["qwen-portal:default"]).toMatchObject({ + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + }); + }); + + it("returns provider agent overrides when default-model application is deferred", async () => { + await setupTempState(); + const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); + resolvePluginProvidersMock.mockReturnValue([qwenProvider]); + resolveProviderPluginChoiceMock.mockReturnValue({ + provider: qwenProvider, + method: qwenProvider.auth[0], + }); + loginQwenPortalOAuthMock.mockResolvedValueOnce({ + access: "access-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + resourceUrl: "portal.qwen.ai", + }); + + const result = await applyAuthChoiceLoadedPluginProvider({ + authChoice: "qwen-portal", + config: {}, + prompter: createWizardPrompter({}), + runtime: createExitThrowingRuntime(), + setDefaultModel: false, + }); + + expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + config: { + agents: { + defaults: { + models: { + "qwen-portal/coder-model": { + alias: "qwen", + }, + "qwen-portal/vision-model": {}, + }, + }, + }, + auth: { + profiles: { + "qwen-portal:default": { + provider: "qwen-portal", + mode: "oauth", + }, + }, + }, + models: { + providers: { + "qwen-portal": { + baseUrl: "https://portal.qwen.ai/v1", + models: [], + }, + }, + }, + }, + agentModelOverride: "qwen-portal/coder-model", + }); + + const stored = await readAuthProfilesForAgent<{ + profiles?: Record; + }>(requireOpenClawAgentDir()); + expect(stored.profiles?.["qwen-portal:default"]).toMatchObject({ + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + }); + }); +}); diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index 386c7acb6e7..cca85917c59 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -51,7 +51,13 @@ function buildPrompter(): WizardPrompter { intro: async () => {}, outro: async () => {}, note: async () => {}, - select: async (params: WizardSelectParams) => params.options[0].value, + select: async (params: WizardSelectParams) => { + const option = params.options[0]; + if (!option) { + throw new Error("missing select option"); + } + return option.value; + }, multiselect: async (params: WizardMultiSelectParams) => params.initialValues ?? [], text: async () => "", confirm: async () => false, diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 6dbc767635d..9ca5f1184e6 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -4,6 +4,7 @@ import { replaceRuntimeAuthProfileStoreSnapshots, } from "../../agents/auth-profiles/store.js"; import { QWEN_OAUTH_MARKER } from "../../agents/model-auth-markers.js"; +import type { ModelDefinitionConfig } from "../../config/types.models.js"; import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import { runProviderCatalog } from "../provider-discovery.js"; import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; @@ -58,6 +59,22 @@ function requireProvider(providers: ProviderPlugin[], providerId: string) { return provider; } +function createModelConfig(id: string, name = id): ModelDefinitionConfig { + return { + id, + name, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128_000, + maxTokens: 8_192, + }; +} describe("provider discovery contract", () => { afterEach(() => { resolveCopilotApiTokenMock.mockReset(); @@ -226,7 +243,7 @@ describe("provider discovery contract", () => { providers: { ollama: { baseUrl: "http://ollama-host:11434/v1/", - models: [{ id: "llama3.2", name: "llama3.2" }], + models: [createModelConfig("llama3.2")], }, }, }, @@ -234,12 +251,12 @@ describe("provider discovery contract", () => { env: {} as NodeJS.ProcessEnv, resolveProviderApiKey: () => ({ apiKey: undefined }), }), - ).resolves.toEqual({ + ).resolves.toMatchObject({ provider: { baseUrl: "http://ollama-host:11434", api: "ollama", apiKey: "ollama-local", - models: [{ id: "llama3.2", name: "llama3.2" }], + models: [createModelConfig("llama3.2")], }, }); expect(buildOllamaProviderMock).not.toHaveBeenCalled(); @@ -405,6 +422,7 @@ describe("provider discovery contract", () => { "minimax-portal": { baseUrl: "https://portal-proxy.example.com/anthropic", apiKey: "explicit-key", + models: [], }, }, }, @@ -431,6 +449,7 @@ describe("provider discovery contract", () => { providers: { modelstudio: { baseUrl: "https://coding.dashscope.aliyuncs.com/v1", + models: [], }, }, }, 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( 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/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/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 1ca7b32bf9b..6aadba32a9a 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,4 +1,5 @@ import { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js"; +import { discordMessageActions } from "../../../extensions/discord/src/channel-actions.js"; import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive, @@ -29,7 +30,6 @@ import { sendTypingDiscord, unpinMessageDiscord, } from "../../../extensions/discord/src/send.js"; -import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; import { createDiscordTypingLease } from "./runtime-discord-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index 1e6993ef489..9481c718565 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -2,6 +2,7 @@ import { auditTelegramGroupMembership, 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 { @@ -20,7 +21,6 @@ import { setTelegramThreadBindingMaxAgeBySessionKey, } from "../../../extensions/telegram/src/thread-bindings.js"; import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; -import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index b0b5941f24d..f8e6e095ef5 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -87,7 +87,7 @@ export type PluginRuntimeChannel = { shouldHandleTextCommands: typeof import("../../auto-reply/commands-registry.js").shouldHandleTextCommands; }; discord: { - messageActions: typeof import("../../channels/plugins/actions/discord.js").discordMessageActions; + messageActions: typeof import("../../../extensions/discord/src/channel-actions.js").discordMessageActions; auditChannelPermissions: typeof import("../../../extensions/discord/src/audit.js").auditDiscordChannelPermissions; listDirectoryGroupsLive: typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryGroupsLive; listDirectoryPeersLive: typeof import("../../../extensions/discord/src/directory-live.js").listDiscordDirectoryPeersLive; @@ -147,7 +147,7 @@ export type PluginRuntimeChannel = { sendMessageTelegram: typeof import("../../../extensions/telegram/src/send.js").sendMessageTelegram; sendPollTelegram: typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram; monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider; - messageActions: typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions; + messageActions: typeof import("../../../extensions/telegram/src/channel-actions.js").telegramMessageActions; threadBindings: { setIdleTimeoutBySessionKey: typeof import("../../../extensions/telegram/src/thread-bindings.js").setTelegramThreadBindingIdleTimeoutBySessionKey; setMaxAgeBySessionKey: typeof import("../../../extensions/telegram/src/thread-bindings.js").setTelegramThreadBindingMaxAgeBySessionKey; 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[]; 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"; 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"; 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,