Merge remote-tracking branch 'upstream/main' into feat/gigachat
# Conflicts: # package.json # pnpm-lock.yaml # src/commands/auth-choice.apply.api-providers.ts # src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts
This commit is contained in:
commit
e814d0e1b3
@ -1,3 +1,8 @@
|
||||
---
|
||||
name: parallels-discord-roundtrip
|
||||
description: Run the macOS Parallels smoke harness with Discord end-to-end roundtrip verification, including guest send, host verification, host reply, and guest readback.
|
||||
---
|
||||
|
||||
# Parallels Discord Roundtrip
|
||||
|
||||
Use when macOS Parallels smoke must prove Discord two-way delivery end to end.
|
||||
|
||||
67
.github/workflows/ci.yml
vendored
67
.github/workflows/ci.yml
vendored
@ -78,6 +78,50 @@ jobs:
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
changed-extensions:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }}
|
||||
changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure changed-extensions base commit
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
install-deps: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Detect changed extensions
|
||||
id: changed
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { listChangedExtensionIds } from "./scripts/test-extension.mjs";
|
||||
|
||||
const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" });
|
||||
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
|
||||
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
|
||||
EOF
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope]
|
||||
@ -205,6 +249,29 @@ jobs:
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
extension-fast:
|
||||
name: "extension-fast (${{ matrix.extension }})"
|
||||
needs: [docs-scope, changed-scope, changed-extensions]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_extensions_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run changed extension tests
|
||||
run: pnpm test:extension ${{ matrix.extension }}
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
|
||||
@ -1 +1,2 @@
|
||||
**/node_modules/
|
||||
docs/.generated/
|
||||
|
||||
135
CHANGELOG.md
135
CHANGELOG.md
@ -6,26 +6,28 @@ 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.
|
||||
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
|
||||
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
|
||||
|
||||
### Breaking
|
||||
|
||||
@ -33,65 +35,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
|
||||
|
||||
|
||||
@ -89,6 +89,9 @@ Welcome to the lobster tank! 🦞
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- For extension/plugin changes, run the fast local lane first:
|
||||
- `pnpm test:extension <extension-name>`
|
||||
- If you changed shared plugin or channel surfaces, still run the broader relevant lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
|
||||
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
|
||||
@ -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`).
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
@ -51869,6 +51938,48 @@
|
||||
"help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.installs.*.marketplaceName",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Marketplace Name",
|
||||
"help": "Marketplace display name recorded for marketplace-backed plugin installs (if available).",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.installs.*.marketplacePlugin",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Marketplace Plugin",
|
||||
"help": "Plugin entry name inside the source marketplace, used for later updates.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.installs.*.marketplaceSource",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Marketplace Source",
|
||||
"help": "Original marketplace source used to resolve the install (for example a repo path or Git URL).",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.installs.*.resolvedAt",
|
||||
"kind": "core",
|
||||
|
||||
@ -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":5101}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -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}
|
||||
@ -4501,6 +4506,9 @@
|
||||
{"recordType":"path","path":"plugins.installs.*.installedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Time","help":"ISO timestamp of last install/update.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.installs.*.installPath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Plugin Install Path","help":"Resolved install directory (usually ~/.openclaw/extensions/<id>).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.installs.*.integrity","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Integrity","help":"Resolved npm dist integrity hash for the fetched artifact (if reported by npm).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.installs.*.marketplaceName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Name","help":"Marketplace display name recorded for marketplace-backed plugin installs (if available).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.installs.*.marketplacePlugin","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Plugin","help":"Plugin entry name inside the source marketplace, used for later updates.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.installs.*.marketplaceSource","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Source","help":"Original marketplace source used to resolve the install (for example a repo path or Git URL).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.installs.*.resolvedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolution Time","help":"ISO timestamp when npm package metadata was last resolved for this install record.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.installs.*.resolvedName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Name","help":"Resolved npm package name from the fetched artifact.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.installs.*.resolvedSpec","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Spec","help":"Resolved exact npm spec (<name>@<version>) from the fetched artifact.","hasChildren":false}
|
||||
|
||||
@ -284,7 +284,8 @@ Manage extensions and their config:
|
||||
|
||||
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
|
||||
- `openclaw plugins info <id>` — show details for a plugin.
|
||||
- `openclaw plugins install <path|.tgz|npm-spec>` — install a plugin (or add a plugin path to `plugins.load.paths`).
|
||||
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
|
||||
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
|
||||
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
|
||||
- `openclaw plugins doctor` — report plugin load errors.
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
|
||||
summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, doctor)"
|
||||
read_when:
|
||||
- You want to install or manage Gateway plugins or compatible bundles
|
||||
- You want to debug plugin load failures
|
||||
@ -28,6 +28,7 @@ openclaw plugins uninstall <id>
|
||||
openclaw plugins doctor
|
||||
openclaw plugins update <id>
|
||||
openclaw plugins update --all
|
||||
openclaw plugins marketplace list <marketplace>
|
||||
```
|
||||
|
||||
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
|
||||
@ -46,6 +47,8 @@ capabilities.
|
||||
```bash
|
||||
openclaw plugins install <path-or-spec>
|
||||
openclaw plugins install <npm-spec> --pin
|
||||
openclaw plugins install <plugin>@<marketplace>
|
||||
openclaw plugins install <plugin> --marketplace <marketplace>
|
||||
```
|
||||
|
||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||
@ -65,6 +68,31 @@ name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
Claude marketplace installs are also supported.
|
||||
|
||||
Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's
|
||||
local registry cache at `~/.claude/plugins/known_marketplaces.json`:
|
||||
|
||||
```bash
|
||||
openclaw plugins marketplace list <marketplace-name>
|
||||
openclaw plugins install <plugin-name>@<marketplace-name>
|
||||
```
|
||||
|
||||
Use `--marketplace` when you want to pass the marketplace source explicitly:
|
||||
|
||||
```bash
|
||||
openclaw plugins install <plugin-name> --marketplace <marketplace-name>
|
||||
openclaw plugins install <plugin-name> --marketplace <owner/repo>
|
||||
openclaw plugins install <plugin-name> --marketplace ./my-marketplace
|
||||
```
|
||||
|
||||
Marketplace sources can be:
|
||||
|
||||
- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json`
|
||||
- a local marketplace root or `marketplace.json` path
|
||||
- a GitHub repo shorthand such as `owner/repo`
|
||||
- a git URL
|
||||
|
||||
For local paths and archives, OpenClaw auto-detects:
|
||||
|
||||
- native OpenClaw plugins (`openclaw.plugin.json`)
|
||||
@ -114,7 +142,8 @@ openclaw plugins update --all
|
||||
openclaw plugins update <id> --dry-run
|
||||
```
|
||||
|
||||
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
|
||||
Updates apply to tracked installs in `plugins.installs`, currently npm and
|
||||
marketplace installs.
|
||||
|
||||
When a stored integrity hash exists and the fetched artifact hash changes,
|
||||
OpenClaw prints a warning and asks for confirmation before proceeding. Use
|
||||
|
||||
@ -259,12 +259,19 @@ openclaw plugins install ./my-codex-bundle
|
||||
openclaw plugins install ./my-claude-bundle
|
||||
openclaw plugins install ./my-cursor-bundle
|
||||
openclaw plugins install ./my-bundle.tgz
|
||||
openclaw plugins marketplace list <marketplace-name>
|
||||
openclaw plugins install <plugin-name>@<marketplace-name>
|
||||
openclaw plugins info my-bundle
|
||||
```
|
||||
|
||||
If the directory is a native OpenClaw plugin/package, the native install path
|
||||
still wins.
|
||||
|
||||
For Claude marketplace names, OpenClaw reads the local Claude known-marketplace
|
||||
registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries
|
||||
can resolve to bundle-compatible directories/archives or to native plugin
|
||||
sources; after resolution, the normal install rules still apply.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bundle is detected but capabilities do not run
|
||||
|
||||
@ -57,6 +57,18 @@ openclaw plugins install ./my-bundle
|
||||
openclaw plugins install ./my-bundle.tgz
|
||||
```
|
||||
|
||||
For Claude marketplace installs, list the marketplace first, then install by
|
||||
marketplace entry name:
|
||||
|
||||
```bash
|
||||
openclaw plugins marketplace list <marketplace-name>
|
||||
openclaw plugins install <plugin-name>@<marketplace-name>
|
||||
```
|
||||
|
||||
OpenClaw resolves known Claude marketplace names from
|
||||
`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit
|
||||
marketplace source with `--marketplace`.
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenClaw's plugin system has four layers:
|
||||
@ -94,6 +106,10 @@ OpenClaw also recognizes two compatible external bundle layouts:
|
||||
component layout without a manifest
|
||||
- Cursor-style bundles: `.cursor-plugin/plugin.json`
|
||||
|
||||
Claude marketplace entries can point at any of these compatible bundles, or at
|
||||
native OpenClaw plugin sources. OpenClaw resolves the marketplace entry first,
|
||||
then runs the normal install path for the resolved source.
|
||||
|
||||
They are shown in the plugin list as `format=bundle`, with a subtype of
|
||||
`codex` or `claude` in verbose/info output.
|
||||
|
||||
@ -573,12 +589,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:
|
||||
|
||||
22
extensions/amazon-bedrock/index.test.ts
Normal file
22
extensions/amazon-bedrock/index.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
23
extensions/amazon-bedrock/index.ts
Normal file
23
extensions/amazon-bedrock/index.ts
Normal file
@ -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;
|
||||
9
extensions/amazon-bedrock/openclaw.plugin.json
Normal file
9
extensions/amazon-bedrock/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "amazon-bedrock",
|
||||
"providers": ["amazon-bedrock"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/amazon-bedrock/package.json
Normal file
12
extensions/amazon-bedrock/package.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
extensions/bluebubbles/src/actions.runtime.ts
Normal file
13
extensions/bluebubbles/src/actions.runtime.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
export {
|
||||
addBlueBubblesParticipant,
|
||||
editBlueBubblesMessage,
|
||||
leaveBlueBubblesChat,
|
||||
removeBlueBubblesParticipant,
|
||||
renameBlueBubblesChat,
|
||||
setGroupIconBlueBubbles,
|
||||
unsendBlueBubblesMessage,
|
||||
} from "./chat.js";
|
||||
export { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
export { sendBlueBubblesReaction } from "./reactions.js";
|
||||
export { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
@ -12,24 +12,18 @@ import {
|
||||
type ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import {
|
||||
editBlueBubblesMessage,
|
||||
unsendBlueBubblesMessage,
|
||||
renameBlueBubblesChat,
|
||||
setGroupIconBlueBubbles,
|
||||
addBlueBubblesParticipant,
|
||||
removeBlueBubblesParticipant,
|
||||
leaveBlueBubblesChat,
|
||||
} from "./chat.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
|
||||
let actionsRuntimePromise: Promise<typeof import("./actions.runtime.js")> | null = null;
|
||||
|
||||
function loadBlueBubblesActionsRuntime() {
|
||||
actionsRuntimePromise ??= import("./actions.runtime.js");
|
||||
return actionsRuntimePromise;
|
||||
}
|
||||
|
||||
const providerId = "bluebubbles";
|
||||
|
||||
function mapTarget(raw: string): BlueBubblesSendTarget {
|
||||
@ -99,6 +93,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
const runtime = await loadBlueBubblesActionsRuntime();
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
@ -147,7 +142,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
|
||||
}
|
||||
|
||||
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
|
||||
const resolved = await runtime.resolveChatGuidForTarget({ baseUrl, password, target });
|
||||
if (!resolved) {
|
||||
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
|
||||
}
|
||||
@ -173,11 +168,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||
requireKnownShortId: true,
|
||||
});
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
await runtime.sendBlueBubblesReaction({
|
||||
chatGuid: resolvedChatGuid,
|
||||
messageGuid: messageId,
|
||||
emoji,
|
||||
@ -218,11 +215,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||
requireKnownShortId: true,
|
||||
});
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||
|
||||
await editBlueBubblesMessage(messageId, newText, {
|
||||
await runtime.editBlueBubblesMessage(messageId, newText, {
|
||||
...opts,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||
@ -242,10 +241,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||
requireKnownShortId: true,
|
||||
});
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
await unsendBlueBubblesMessage(messageId, {
|
||||
await runtime.unsendBlueBubblesMessage(messageId, {
|
||||
...opts,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
});
|
||||
@ -276,10 +277,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||
requireKnownShortId: true,
|
||||
});
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||
...opts,
|
||||
replyToMessageGuid: messageId,
|
||||
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
@ -313,7 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||
...opts,
|
||||
effectId,
|
||||
});
|
||||
@ -330,7 +333,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
|
||||
}
|
||||
|
||||
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
|
||||
await runtime.renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
|
||||
|
||||
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
||||
}
|
||||
@ -355,7 +358,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
// Decode base64 to buffer
|
||||
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
||||
|
||||
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
||||
await runtime.setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
||||
...opts,
|
||||
contentType: contentType ?? undefined,
|
||||
});
|
||||
@ -372,7 +375,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
|
||||
}
|
||||
|
||||
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
await runtime.addBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
|
||||
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
|
||||
}
|
||||
@ -386,7 +389,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
|
||||
}
|
||||
|
||||
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
await runtime.removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
|
||||
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
|
||||
}
|
||||
@ -396,7 +399,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
assertPrivateApiEnabled();
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
await leaveBlueBubblesChat(resolvedChatGuid, opts);
|
||||
await runtime.leaveBlueBubblesChat(resolvedChatGuid, opts);
|
||||
|
||||
return jsonResult({ ok: true, left: resolvedChatGuid });
|
||||
}
|
||||
@ -427,7 +430,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
|
||||
}
|
||||
|
||||
const result = await sendBlueBubblesAttachment({
|
||||
const result = await runtime.sendBlueBubblesAttachment({
|
||||
to,
|
||||
buffer,
|
||||
filename,
|
||||
|
||||
6
extensions/bluebubbles/src/channel.runtime.ts
Normal file
6
extensions/bluebubbles/src/channel.runtime.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { sendBlueBubblesMedia } from "./media-send.js";
|
||||
export { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
export { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||
export { type BlueBubblesProbe, probeBlueBubbles } from "./probe.js";
|
||||
export { sendMessageBlueBubbles } from "./send.js";
|
||||
export { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||
@ -25,12 +25,8 @@ import {
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import type { BlueBubblesProbe } from "./channel.runtime.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
import { blueBubblesSetupAdapter } from "./setup-core.js";
|
||||
import { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
@ -41,6 +37,13 @@ import {
|
||||
parseBlueBubblesTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
let blueBubblesChannelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
|
||||
|
||||
function loadBlueBubblesChannelRuntime() {
|
||||
blueBubblesChannelRuntimePromise ??= import("./channel.runtime.js");
|
||||
return blueBubblesChannelRuntimePromise;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
@ -221,7 +224,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
idLabel: "bluebubblesSenderId",
|
||||
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
||||
await (
|
||||
await loadBlueBubblesChannelRuntime()
|
||||
).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
||||
cfg: cfg,
|
||||
});
|
||||
},
|
||||
@ -240,12 +245,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
||||
// Resolve short ID (e.g., "5") to full UUID
|
||||
const replyToMessageGuid = rawReplyToId
|
||||
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||
: "";
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||
@ -253,6 +259,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
return { channel: "bluebubbles", ...result };
|
||||
},
|
||||
sendMedia: async (ctx) => {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
|
||||
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
|
||||
mediaPath?: string;
|
||||
@ -262,7 +269,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
caption?: string;
|
||||
};
|
||||
const resolvedCaption = caption ?? text;
|
||||
const result = await sendBlueBubblesMedia({
|
||||
const result = await runtime.sendBlueBubblesMedia({
|
||||
cfg: cfg,
|
||||
to,
|
||||
mediaUrl,
|
||||
@ -290,7 +297,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
probeBlueBubbles({
|
||||
(await loadBlueBubblesChannelRuntime()).probeBlueBubbles({
|
||||
baseUrl: account.baseUrl,
|
||||
password: account.config.password ?? null,
|
||||
timeoutMs,
|
||||
@ -315,8 +322,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
const account = ctx.account;
|
||||
const webhookPath = resolveWebhookPathFromConfig(account.config);
|
||||
const webhookPath = runtime.resolveWebhookPathFromConfig(account.config);
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
@ -325,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
baseUrl: account.baseUrl,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
||||
return monitorBlueBubblesProvider({
|
||||
return runtime.monitorBlueBubblesProvider({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { discordPlugin } from "./src/channel.js";
|
||||
import { setDiscordRuntime } from "./src/runtime.js";
|
||||
import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -17,41 +17,45 @@ import {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
buildTokenChannelStatusSummary,
|
||||
collectDiscordAuditChannelIds,
|
||||
collectDiscordStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
DiscordConfigSchema,
|
||||
getChatChannelMeta,
|
||||
inspectDiscordAccount,
|
||||
listDiscordAccountIds,
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
looksLikeDiscordTargetId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
normalizeDiscordOutboundTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
resolveDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { normalizeMessageChannel } from "../../../src/utils/message-channel.js";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { collectDiscordAuditChannelIds } from "./audit.js";
|
||||
import {
|
||||
isDiscordExecApprovalClientEnabled,
|
||||
shouldSuppressLocalDiscordExecApprovalPrompt,
|
||||
} from "./exec-approvals.js";
|
||||
import {
|
||||
looksLikeDiscordTargetId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
normalizeDiscordOutboundTarget,
|
||||
} from "./normalize.js";
|
||||
import type { DiscordProbe } from "./probe.js";
|
||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
|
||||
import { collectDiscordStatusIssues } from "./status-issues.js";
|
||||
import { parseDiscordTarget } from "./targets.js";
|
||||
import { DiscordUiContainer } from "./ui.js";
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
|
||||
import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js";
|
||||
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import { processDiscordMessage } from "./message-handler.process.js";
|
||||
import {
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./provider.js";
|
||||
|
||||
describe("resolveDiscordRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: __testing.resolveDiscordRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open default when channels.discord is configured",
|
||||
defaultGroupPolicyUnderTest: "open",
|
||||
missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
||||
});
|
||||
|
||||
it("respects explicit provider policy", () => {
|
||||
const resolved = __testing.resolveDiscordRuntimeGroupPolicy({
|
||||
providerConfigPresent: false,
|
||||
groupPolicy: "disabled",
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("disabled");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,37 +0,0 @@
|
||||
import { describe, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { discordOutbound } from "./outbound-adapter.js";
|
||||
|
||||
function createHarness(params: {
|
||||
payload: ReplyPayload;
|
||||
sendResults?: Array<{ messageId: string }>;
|
||||
}) {
|
||||
const sendDiscord = vi.fn();
|
||||
primeSendMock(sendDiscord, { messageId: "dc-1", channelId: "123456" }, params.sendResults);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
deps: {
|
||||
sendDiscord,
|
||||
},
|
||||
};
|
||||
return {
|
||||
run: async () => await discordOutbound.sendPayload!(ctx),
|
||||
sendMock: sendDiscord,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
describe("discordOutbound sendPayload", () => {
|
||||
installSendPayloadContractSuite({
|
||||
channel: "discord",
|
||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
||||
createHarness,
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
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) {
|
||||
|
||||
@ -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/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { imessagePlugin } from "./src/channel.js";
|
||||
import { setIMessageRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -12,23 +12,25 @@ import {
|
||||
formatTrimmedAllowFromEntries,
|
||||
getChatChannelMeta,
|
||||
IMessageConfigSchema,
|
||||
listIMessageAccountIds,
|
||||
looksLikeIMessageTargetId,
|
||||
normalizeIMessageMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
resolveIMessageConfigAllowFrom,
|
||||
resolveIMessageConfigDefaultTo,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
type ResolvedIMessageAccount,
|
||||
} from "openclaw/plugin-sdk/imessage";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
|
||||
import {
|
||||
listIMessageAccountIds,
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
type ResolvedIMessageAccount,
|
||||
} from "./accounts.js";
|
||||
import { getIMessageRuntime } from "./runtime.js";
|
||||
import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js";
|
||||
import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./monitor-provider.js";
|
||||
|
||||
describe("resolveIMessageRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: __testing.resolveIMessageRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open fallback when channels.imessage is configured",
|
||||
defaultGroupPolicyUnderTest: "disabled",
|
||||
missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
import {
|
||||
buildOllamaProvider,
|
||||
emptyPluginConfigSchema,
|
||||
ensureOllamaModelPulled,
|
||||
OLLAMA_DEFAULT_BASE_URL,
|
||||
promptAndConfigureOllama,
|
||||
configureOllamaNonInteractive,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
@ -12,10 +7,15 @@ import {
|
||||
type ProviderDiscoveryContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "../../src/agents/ollama-defaults.js";
|
||||
|
||||
const PROVIDER_ID = "ollama";
|
||||
const DEFAULT_API_KEY = "ollama-local";
|
||||
|
||||
async function loadProviderSetup() {
|
||||
return await import("openclaw/plugin-sdk/provider-setup");
|
||||
}
|
||||
|
||||
const ollamaPlugin = {
|
||||
id: "ollama",
|
||||
name: "Ollama Provider",
|
||||
@ -34,7 +34,8 @@ const ollamaPlugin = {
|
||||
hint: "Cloud and local open models",
|
||||
kind: "custom",
|
||||
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||
const result = await promptAndConfigureOllama({
|
||||
const providerSetup = await loadProviderSetup();
|
||||
const result = await providerSetup.promptAndConfigureOllama({
|
||||
cfg: ctx.config,
|
||||
prompter: ctx.prompter,
|
||||
});
|
||||
@ -53,12 +54,14 @@ const ollamaPlugin = {
|
||||
defaultModel: `ollama/${result.defaultModelId}`,
|
||||
};
|
||||
},
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||
configureOllamaNonInteractive({
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
|
||||
const providerSetup = await loadProviderSetup();
|
||||
return await providerSetup.configureOllamaNonInteractive({
|
||||
nextConfig: ctx.config,
|
||||
opts: ctx.opts,
|
||||
runtime: ctx.runtime,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
discovery: {
|
||||
@ -81,7 +84,8 @@ const ollamaPlugin = {
|
||||
};
|
||||
}
|
||||
|
||||
const provider = await buildOllamaProvider(explicit?.baseUrl, {
|
||||
const providerSetup = await loadProviderSetup();
|
||||
const provider = await providerSetup.buildOllamaProvider(explicit?.baseUrl, {
|
||||
quiet: !ollamaKey && !explicit,
|
||||
});
|
||||
if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
|
||||
@ -115,7 +119,8 @@ const ollamaPlugin = {
|
||||
if (!model.startsWith("ollama/")) {
|
||||
return;
|
||||
}
|
||||
await ensureOllamaModelPulled({ config, prompter });
|
||||
const providerSetup = await loadProviderSetup();
|
||||
await providerSetup.ensureOllamaModelPulled({ config, prompter });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/core";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
createOpenShellSandboxBackendFactory,
|
||||
createOpenShellSandboxBackendManager,
|
||||
|
||||
@ -11,13 +11,13 @@ import type {
|
||||
SandboxBackendHandle,
|
||||
SandboxBackendManager,
|
||||
SshSandboxSession,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
createRemoteShellSandboxFsBridge,
|
||||
disposeSshSandboxSession,
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
runSshSandboxCommand,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
buildExecRemoteCommand,
|
||||
buildRemoteCommand,
|
||||
|
||||
@ -4,10 +4,10 @@ import {
|
||||
runPluginCommandWithTimeout,
|
||||
shellEscape,
|
||||
type SshSandboxSession,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import type { ResolvedOpenShellPluginConfig } from "./config.js";
|
||||
|
||||
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core";
|
||||
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
export type OpenShellExecContext = {
|
||||
config: ResolvedOpenShellPluginConfig;
|
||||
|
||||
@ -5,7 +5,7 @@ import type {
|
||||
SandboxFsBridge,
|
||||
SandboxFsStat,
|
||||
SandboxResolvedPath,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import type { OpenShellSandboxBackend } from "./backend.js";
|
||||
import { movePathWithCopyFallback } from "./mirror.js";
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
type RemoteShellSandboxHandle,
|
||||
type SandboxContext,
|
||||
type SandboxFsBridge,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
export function createOpenShellRemoteFsBridge(params: {
|
||||
sandbox: SandboxContext;
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
import {
|
||||
buildSglangProvider,
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
||||
discoverOpenAICompatibleSelfHostedProvider,
|
||||
emptyPluginConfigSchema,
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
SGLANG_DEFAULT_API_KEY_ENV_VAR,
|
||||
SGLANG_DEFAULT_BASE_URL,
|
||||
SGLANG_MODEL_PLACEHOLDER,
|
||||
SGLANG_PROVIDER_LABEL,
|
||||
} from "../../src/agents/sglang-defaults.js";
|
||||
|
||||
const PROVIDER_ID = "sglang";
|
||||
const DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1";
|
||||
|
||||
async function loadProviderSetup() {
|
||||
return await import("openclaw/plugin-sdk/provider-setup");
|
||||
}
|
||||
|
||||
const sglangPlugin = {
|
||||
id: "sglang",
|
||||
@ -25,38 +30,44 @@ const sglangPlugin = {
|
||||
auth: [
|
||||
{
|
||||
id: "custom",
|
||||
label: "SGLang",
|
||||
label: SGLANG_PROVIDER_LABEL,
|
||||
hint: "Fast self-hosted OpenAI-compatible server",
|
||||
kind: "custom",
|
||||
run: async (ctx) =>
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
||||
run: async (ctx) => {
|
||||
const providerSetup = await loadProviderSetup();
|
||||
return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
||||
cfg: ctx.config,
|
||||
prompter: ctx.prompter,
|
||||
providerId: PROVIDER_ID,
|
||||
providerLabel: "SGLang",
|
||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: "SGLANG_API_KEY",
|
||||
modelPlaceholder: "Qwen/Qwen3-8B",
|
||||
}),
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||
providerLabel: SGLANG_PROVIDER_LABEL,
|
||||
defaultBaseUrl: SGLANG_DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR,
|
||||
modelPlaceholder: SGLANG_MODEL_PLACEHOLDER,
|
||||
});
|
||||
},
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
|
||||
const providerSetup = await loadProviderSetup();
|
||||
return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||
ctx,
|
||||
providerId: PROVIDER_ID,
|
||||
providerLabel: "SGLang",
|
||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: "SGLANG_API_KEY",
|
||||
modelPlaceholder: "Qwen/Qwen3-8B",
|
||||
}),
|
||||
providerLabel: SGLANG_PROVIDER_LABEL,
|
||||
defaultBaseUrl: SGLANG_DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR,
|
||||
modelPlaceholder: SGLANG_MODEL_PLACEHOLDER,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
discovery: {
|
||||
order: "late",
|
||||
run: async (ctx) =>
|
||||
discoverOpenAICompatibleSelfHostedProvider({
|
||||
run: async (ctx) => {
|
||||
const providerSetup = await loadProviderSetup();
|
||||
return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({
|
||||
ctx,
|
||||
providerId: PROVIDER_ID,
|
||||
buildProvider: buildSglangProvider,
|
||||
}),
|
||||
buildProvider: providerSetup.buildSglangProvider,
|
||||
});
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
setup: {
|
||||
|
||||
@ -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/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { signalPlugin } from "./src/channel.js";
|
||||
import { setSignalRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
||||
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
|
||||
import { createSignalEventHandler } from "./event-handler.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
|
||||
|
||||
@ -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/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { slackPlugin } from "./src/channel.js";
|
||||
import { setSlackRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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<string, "channel" | "group" | "dm" | "unknown">();
|
||||
|
||||
334
extensions/slack/src/message-action-dispatch.ts
Normal file
334
extensions/slack/src/message-action-dispatch.ts
Normal file
@ -0,0 +1,334 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/core";
|
||||
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
|
||||
|
||||
type SlackActionInvoke = (
|
||||
action: Record<string, unknown>,
|
||||
cfg: ChannelMessageActionContext["cfg"],
|
||||
toolContext?: ChannelMessageActionContext["toolContext"],
|
||||
) => Promise<AgentToolResult<unknown>>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>) {
|
||||
return parseSlackBlocksInput(actionParams.blocks) as Record<string, unknown>[] | undefined;
|
||||
}
|
||||
|
||||
export async function handleSlackMessageAction(params: {
|
||||
providerId: string;
|
||||
ctx: ChannelMessageActionContext;
|
||||
invoke: SlackActionInvoke;
|
||||
normalizeChannelId?: (channelId: string) => string;
|
||||
includeReadThreadId?: boolean;
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
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<string, unknown> = {
|
||||
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}.`);
|
||||
}
|
||||
@ -3,10 +3,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { App } from "@slack/bolt";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js";
|
||||
import type { OpenClawConfig } from "../../../../../src/config/config.js";
|
||||
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js";
|
||||
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./provider.js";
|
||||
|
||||
describe("resolveSlackRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: __testing.resolveSlackRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open default when channels.slack is configured",
|
||||
defaultGroupPolicyUnderTest: "open",
|
||||
missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized");
|
||||
|
||||
@ -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/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { telegramPlugin } from "./src/channel.js";
|
||||
import { setTelegramRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@ -298,6 +298,66 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends error replies silently when silentErrorReplies is enabled", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
telegramCfg: { silentErrorReplies: true },
|
||||
});
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps error replies notifying by default", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({ context: createContext() });
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: false,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps fallback replies silent after an error reply is skipped", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
dispatcherOptions.onSkip?.(
|
||||
{ text: "oops", isError: true },
|
||||
{ kind: "final", reason: "empty" },
|
||||
);
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
telegramCfg: { silentErrorReplies: true },
|
||||
});
|
||||
|
||||
expect(deliverReplies).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ text: expect.any(String) })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps block streaming enabled when session reasoning level is on", async () => {
|
||||
loadSessionStore.mockReturnValue({
|
||||
s1: { reasoningLevel: "on" },
|
||||
|
||||
@ -465,6 +465,7 @@ export const dispatchTelegramMessage = async ({
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
replyQuoteText,
|
||||
};
|
||||
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||
const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
|
||||
if (payload.text === text) {
|
||||
return payload;
|
||||
@ -476,6 +477,7 @@ export const dispatchTelegramMessage = async ({
|
||||
...deliveryBaseOptions,
|
||||
replies: [payload],
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
silent: silentErrorReplies && payload.isError === true,
|
||||
});
|
||||
if (result.delivered) {
|
||||
deliveryState.markDelivered();
|
||||
@ -513,6 +515,7 @@ export const dispatchTelegramMessage = async ({
|
||||
});
|
||||
|
||||
let queuedFinal = false;
|
||||
let hadErrorReplyFailureOrSkip = false;
|
||||
|
||||
if (statusReactionController) {
|
||||
void statusReactionController.setThinking();
|
||||
@ -539,6 +542,9 @@ export const dispatchTelegramMessage = async ({
|
||||
...prefixOptions,
|
||||
typingCallbacks,
|
||||
deliver: async (payload, info) => {
|
||||
if (payload.isError === true) {
|
||||
hadErrorReplyFailureOrSkip = true;
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
// Assistant callbacks are fire-and-forget; ensure queued boundary
|
||||
// rotations/partials are applied before final delivery mapping.
|
||||
@ -652,7 +658,10 @@ export const dispatchTelegramMessage = async ({
|
||||
await flushBufferedFinalAnswer();
|
||||
}
|
||||
},
|
||||
onSkip: (_payload, info) => {
|
||||
onSkip: (payload, info) => {
|
||||
if (payload.isError === true) {
|
||||
hadErrorReplyFailureOrSkip = true;
|
||||
}
|
||||
if (info.reason !== "silent") {
|
||||
deliveryState.markNonSilentSkip();
|
||||
}
|
||||
@ -809,6 +818,7 @@ export const dispatchTelegramMessage = async ({
|
||||
const result = await deliverReplies({
|
||||
replies: [{ text: fallbackText }],
|
||||
...deliveryBaseOptions,
|
||||
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
|
||||
});
|
||||
sentFallback = result.delivered;
|
||||
}
|
||||
|
||||
@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params;
|
||||
const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params;
|
||||
return registerAndResolveCommandHandlerBase({
|
||||
commandName: "status",
|
||||
cfg,
|
||||
allowFrom: allowFrom ?? ["*"],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
useAccessGroups: true,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
});
|
||||
}
|
||||
@ -209,6 +211,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
useAccessGroups: boolean;
|
||||
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
@ -220,6 +223,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
} = params;
|
||||
const commandHandlers = new Map<string, TelegramCommandHandler>();
|
||||
@ -239,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
}),
|
||||
});
|
||||
@ -254,6 +259,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
@ -265,6 +271,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
} = params;
|
||||
return registerAndResolveCommandHandlerBase({
|
||||
@ -273,6 +280,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
allowFrom: allowFrom ?? [],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
useAccessGroups: useAccessGroups ?? true,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
});
|
||||
}
|
||||
@ -443,6 +451,31 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends native command error replies silently when silentErrorReplies is enabled", async () => {
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
|
||||
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
|
||||
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||
return dispatchReplyResult;
|
||||
},
|
||||
);
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
telegramCfg: { silentErrorReplies: true },
|
||||
});
|
||||
await handler(buildStatusCommandContext());
|
||||
|
||||
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
|
||||
| DeliverRepliesParams
|
||||
| undefined;
|
||||
expect(deliveredCall).toEqual(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||
|
||||
@ -290,4 +290,56 @@ describe("registerTelegramNativeCommands", () => {
|
||||
);
|
||||
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
|
||||
});
|
||||
|
||||
it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => {
|
||||
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
|
||||
|
||||
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
|
||||
{
|
||||
name: "plug",
|
||||
description: "Plugin command",
|
||||
},
|
||||
] as never);
|
||||
pluginCommandMocks.matchPluginCommand.mockReturnValue({
|
||||
command: { key: "plug", requireAuth: false },
|
||||
args: undefined,
|
||||
} as never);
|
||||
pluginCommandMocks.executePluginCommand.mockResolvedValue({
|
||||
text: "plugin failed",
|
||||
isError: true,
|
||||
} as never);
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...buildParams({}),
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
|
||||
commandHandlers.set(name, cb);
|
||||
}),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig,
|
||||
});
|
||||
|
||||
const handler = commandHandlers.get("plug");
|
||||
expect(handler).toBeTruthy();
|
||||
await handler?.({
|
||||
match: "",
|
||||
message: {
|
||||
message_id: 1,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 456, username: "alice" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -363,6 +363,7 @@ export const registerTelegramNativeCommands = ({
|
||||
shouldSkipUpdate,
|
||||
opts,
|
||||
}: RegisterTelegramNativeCommandsParams) => {
|
||||
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||
const boundRoute =
|
||||
nativeEnabled && nativeSkillsEnabled
|
||||
? resolveAgentRoute({ cfg, channel: "telegram", accountId })
|
||||
@ -734,7 +735,6 @@ export const registerTelegramNativeCommands = ({
|
||||
typeof telegramCfg.blockStreaming === "boolean"
|
||||
? !telegramCfg.blockStreaming
|
||||
: undefined;
|
||||
|
||||
const deliveryState = {
|
||||
delivered: false,
|
||||
skippedNonSilent: 0,
|
||||
@ -766,6 +766,7 @@ export const registerTelegramNativeCommands = ({
|
||||
const result = await deliverReplies({
|
||||
replies: [payload],
|
||||
...deliveryBaseOptions,
|
||||
silent: silentErrorReplies && payload.isError === true,
|
||||
});
|
||||
if (result.delivered) {
|
||||
deliveryState.delivered = true;
|
||||
@ -885,6 +886,7 @@ export const registerTelegramNativeCommands = ({
|
||||
await deliverReplies({
|
||||
replies: [result],
|
||||
...deliveryBaseOptions,
|
||||
silent: silentErrorReplies && result.isError === true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../src/channels/plugins/contracts/suites.js";
|
||||
import {
|
||||
clearPluginInteractiveHandlers,
|
||||
registerPluginInteractiveHandler,
|
||||
} from "../../../src/plugins/interactive.js";
|
||||
import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
|
||||
import {
|
||||
answerCallbackQuerySpy,
|
||||
commandSpy,
|
||||
|
||||
@ -103,6 +103,7 @@ async function deliverTextReply(params: {
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
@ -129,6 +130,7 @@ async function deliverTextReply(params: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
},
|
||||
);
|
||||
@ -149,6 +151,7 @@ async function sendPendingFollowUpText(params: {
|
||||
text: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
@ -167,6 +170,7 @@ async function sendPendingFollowUpText(params: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
@ -196,6 +200,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
replyToId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
}): Promise<number | undefined> {
|
||||
@ -213,6 +218,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
silent: opts.silent,
|
||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
@ -237,6 +243,7 @@ async function deliverMediaReply(params: {
|
||||
chunkText: ChunkTextFn;
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyQuoteText?: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyToId?: number;
|
||||
@ -282,6 +289,7 @@ async function deliverMediaReply(params: {
|
||||
...buildTelegramSendParams({
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
silent: params.silent,
|
||||
}),
|
||||
};
|
||||
if (isGif) {
|
||||
@ -375,6 +383,7 @@ async function deliverMediaReply(params: {
|
||||
replyToId: voiceFallbackReplyTo,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
});
|
||||
@ -404,6 +413,7 @@ async function deliverMediaReply(params: {
|
||||
replyToId: undefined,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
});
|
||||
}
|
||||
@ -451,6 +461,7 @@ async function deliverMediaReply(params: {
|
||||
text: pendingFollowUpText,
|
||||
replyMarkup: params.replyMarkup,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
@ -557,6 +568,8 @@ export async function deliverReplies(params: {
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
linkPreview?: boolean;
|
||||
/** When true, messages are sent with disable_notification. */
|
||||
silent?: boolean;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
@ -637,6 +650,7 @@ export async function deliverReplies(params: {
|
||||
replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
@ -654,6 +668,7 @@ export async function deliverReplies(params: {
|
||||
chunkText,
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyMarkup,
|
||||
replyToId,
|
||||
|
||||
@ -76,6 +76,7 @@ export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
export function buildTelegramSendParams(opts?: {
|
||||
replyToMessageId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
silent?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||
const params: Record<string, unknown> = {};
|
||||
@ -85,6 +86,9 @@ export function buildTelegramSendParams(opts?: {
|
||||
if (threadParams) {
|
||||
params.message_thread_id = threadParams.message_thread_id;
|
||||
}
|
||||
if (opts?.silent === true) {
|
||||
params.disable_notification = true;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@ -100,12 +104,14 @@ export async function sendTelegramText(
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
},
|
||||
): Promise<number> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
thread: opts?.thread,
|
||||
silent: opts?.silent,
|
||||
});
|
||||
// Add link_preview_options when link preview is disabled.
|
||||
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
||||
|
||||
@ -211,6 +211,30 @@ describe("deliverReplies", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets disable_notification when silent is true", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 5,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
disable_notification: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits internal message:sent when session hook context is available", async () => {
|
||||
const runtime = createRuntime(false);
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
|
||||
@ -645,6 +669,36 @@ describe("deliverReplies", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps disable_notification on voice fallback text when silent is true", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError());
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 5,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendVoice, sendMessage });
|
||||
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await deliverWith({
|
||||
replies: [
|
||||
{ mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true },
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.stringContaining("Hello there"),
|
||||
expect.objectContaining({
|
||||
disable_notification: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
|
||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||
voiceError: createVoiceMessagesForbiddenError(),
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js";
|
||||
|
||||
describe("resolveTelegramRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: resolveTelegramRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open fallback when channels.telegram is configured",
|
||||
defaultGroupPolicyUnderTest: "disabled",
|
||||
missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit defaults when provider config is missing",
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized");
|
||||
|
||||
@ -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 ?? [],
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
import {
|
||||
buildVllmProvider,
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
||||
discoverOpenAICompatibleSelfHostedProvider,
|
||||
emptyPluginConfigSchema,
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
VLLM_DEFAULT_API_KEY_ENV_VAR,
|
||||
VLLM_DEFAULT_BASE_URL,
|
||||
VLLM_MODEL_PLACEHOLDER,
|
||||
VLLM_PROVIDER_LABEL,
|
||||
} from "../../src/agents/vllm-defaults.js";
|
||||
|
||||
const PROVIDER_ID = "vllm";
|
||||
const DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1";
|
||||
|
||||
async function loadProviderSetup() {
|
||||
return await import("openclaw/plugin-sdk/provider-setup");
|
||||
}
|
||||
|
||||
const vllmPlugin = {
|
||||
id: "vllm",
|
||||
@ -25,38 +30,44 @@ const vllmPlugin = {
|
||||
auth: [
|
||||
{
|
||||
id: "custom",
|
||||
label: "vLLM",
|
||||
label: VLLM_PROVIDER_LABEL,
|
||||
hint: "Local/self-hosted OpenAI-compatible server",
|
||||
kind: "custom",
|
||||
run: async (ctx) =>
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
||||
run: async (ctx) => {
|
||||
const providerSetup = await loadProviderSetup();
|
||||
return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
||||
cfg: ctx.config,
|
||||
prompter: ctx.prompter,
|
||||
providerId: PROVIDER_ID,
|
||||
providerLabel: "vLLM",
|
||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: "VLLM_API_KEY",
|
||||
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
|
||||
}),
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||
providerLabel: VLLM_PROVIDER_LABEL,
|
||||
defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
|
||||
modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
|
||||
});
|
||||
},
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
|
||||
const providerSetup = await loadProviderSetup();
|
||||
return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||
ctx,
|
||||
providerId: PROVIDER_ID,
|
||||
providerLabel: "vLLM",
|
||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: "VLLM_API_KEY",
|
||||
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
|
||||
}),
|
||||
providerLabel: VLLM_PROVIDER_LABEL,
|
||||
defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
|
||||
modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
discovery: {
|
||||
order: "late",
|
||||
run: async (ctx) =>
|
||||
discoverOpenAICompatibleSelfHostedProvider({
|
||||
run: async (ctx) => {
|
||||
const providerSetup = await loadProviderSetup();
|
||||
return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({
|
||||
ctx,
|
||||
providerId: PROVIDER_ID,
|
||||
buildProvider: buildVllmProvider,
|
||||
}),
|
||||
buildProvider: providerSetup.buildVllmProvider,
|
||||
});
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
setup: {
|
||||
|
||||
@ -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/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { whatsappPlugin } from "./src/channel.js";
|
||||
import { setWhatsAppRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js";
|
||||
|
||||
let capturedCtx: unknown;
|
||||
let capturedDispatchParams: unknown;
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./access-control.js";
|
||||
|
||||
describe("resolveWhatsAppRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: __testing.resolveWhatsAppRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open fallback when channels.whatsapp is configured",
|
||||
defaultGroupPolicyUnderTest: "disabled",
|
||||
missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit default policy when provider config is missing",
|
||||
});
|
||||
});
|
||||
@ -1,40 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { whatsappOutbound } from "./outbound-adapter.js";
|
||||
|
||||
function createHarness(params: {
|
||||
payload: ReplyPayload;
|
||||
sendResults?: Array<{ messageId: string }>;
|
||||
}) {
|
||||
const sendWhatsApp = vi.fn();
|
||||
primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
},
|
||||
};
|
||||
return {
|
||||
run: async () => await whatsappOutbound.sendPayload!(ctx),
|
||||
sendMock: sendWhatsApp,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
describe("whatsappOutbound sendPayload", () => {
|
||||
installSendPayloadContractSuite({
|
||||
channel: "whatsapp",
|
||||
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
|
||||
createHarness,
|
||||
});
|
||||
|
||||
it("trims leading whitespace for direct text sends", async () => {
|
||||
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
|
||||
|
||||
21
extensions/zai/detect.ts
Normal file
21
extensions/zai/detect.ts
Normal file
@ -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<DetectZaiEndpointFn>
|
||||
): ReturnType<DetectZaiEndpointFn> {
|
||||
return await detectZaiEndpointImpl(...args);
|
||||
}
|
||||
|
||||
export type { ZaiDetectedEndpoint, ZaiEndpointId };
|
||||
@ -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<ZaiEndpointId> {
|
||||
return await ctx.prompter.select<ZaiEndpointId>({
|
||||
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: [
|
||||
{
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }),
|
||||
}));
|
||||
|
||||
function baseCtx(payload: ReplyPayload) {
|
||||
return {
|
||||
cfg: {},
|
||||
to: "123456789",
|
||||
text: "",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zaloPlugin outbound sendPayload", () => {
|
||||
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalo"]>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("./send.js");
|
||||
mockedSend = vi.mocked(mod.sendMessageZalo);
|
||||
mockedSend.mockClear();
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
|
||||
});
|
||||
|
||||
installSendPayloadContractSuite({
|
||||
channel: "zalo",
|
||||
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
||||
createHarness: ({ payload, sendResults }) => {
|
||||
primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults);
|
||||
return {
|
||||
run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
||||
sendMock: mockedSend,
|
||||
to: "123456789",
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./monitor.js";
|
||||
|
||||
describe("zalo group policy access", () => {
|
||||
it("defaults missing provider config to allowlist", () => {
|
||||
const resolved = __testing.resolveZaloRuntimeGroupPolicy({
|
||||
providerConfigPresent: false,
|
||||
groupPolicy: undefined,
|
||||
defaultGroupPolicy: "open",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks all group messages when policy is disabled", () => {
|
||||
const decision = __testing.evaluateZaloGroupAccess({
|
||||
providerConfigPresent: true,
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./accounts.test-mocks.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js";
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
|
||||
@ -36,8 +33,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
||||
} as never);
|
||||
const mod = await import("./send.js");
|
||||
mockedSend = vi.mocked(mod.sendMessageZalouser);
|
||||
mockedSend.mockClear();
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
|
||||
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
|
||||
});
|
||||
|
||||
it("group target delegates with isGroup=true and stripped threadId", async () => {
|
||||
@ -110,19 +106,6 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
|
||||
});
|
||||
|
||||
installSendPayloadContractSuite({
|
||||
channel: "zalouser",
|
||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
||||
createHarness: ({ payload, sendResults }) => {
|
||||
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
|
||||
return {
|
||||
run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
||||
sendMock: mockedSend,
|
||||
to: "987654321",
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouserPlugin messaging target normalization", () => {
|
||||
|
||||
21
openclaw.mjs
21
openclaw.mjs
@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import module from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MIN_NODE_MAJOR = 22;
|
||||
const MIN_NODE_MINOR = 12;
|
||||
@ -47,6 +48,20 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
|
||||
const isModuleNotFoundError = (err) =>
|
||||
err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND";
|
||||
|
||||
const isDirectModuleNotFoundError = (err, specifier) => {
|
||||
if (!isModuleNotFoundError(err)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedUrl = new URL(specifier, import.meta.url);
|
||||
if ("url" in err && err.url === expectedUrl.href) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = "message" in err && typeof err.message === "string" ? err.message : "";
|
||||
return message.includes(fileURLToPath(expectedUrl));
|
||||
};
|
||||
|
||||
const installProcessWarningFilter = async () => {
|
||||
// Keep bootstrap warnings consistent with the TypeScript runtime.
|
||||
for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) {
|
||||
@ -57,7 +72,7 @@ const installProcessWarningFilter = async () => {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isModuleNotFoundError(err)) {
|
||||
if (isDirectModuleNotFoundError(err, specifier)) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
@ -72,8 +87,8 @@ const tryImport = async (specifier) => {
|
||||
await import(specifier);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Only swallow missing-module errors; rethrow real runtime errors.
|
||||
if (isModuleNotFoundError(err)) {
|
||||
// Only swallow direct entry misses; rethrow transitive resolution failures.
|
||||
if (isDirectModuleNotFoundError(err, specifier)) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
|
||||
13
package.json
13
package.json
@ -29,6 +29,8 @@
|
||||
"assets/",
|
||||
"dist/",
|
||||
"docs/",
|
||||
"!docs/.generated/**",
|
||||
"!docs/.i18n/zh-CN.tm.jsonl",
|
||||
"extensions/",
|
||||
"skills/"
|
||||
],
|
||||
@ -48,6 +50,14 @@
|
||||
"types": "./dist/plugin-sdk/compat.d.ts",
|
||||
"default": "./dist/plugin-sdk/compat.js"
|
||||
},
|
||||
"./plugin-sdk/provider-setup": {
|
||||
"types": "./dist/plugin-sdk/provider-setup.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-setup.js"
|
||||
},
|
||||
"./plugin-sdk/sandbox": {
|
||||
"types": "./dist/plugin-sdk/sandbox.d.ts",
|
||||
"default": "./dist/plugin-sdk/sandbox.js"
|
||||
},
|
||||
"./plugin-sdk/routing": {
|
||||
"types": "./dist/plugin-sdk/routing.d.ts",
|
||||
"default": "./dist/plugin-sdk/routing.js"
|
||||
@ -311,6 +321,7 @@
|
||||
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
|
||||
"test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts",
|
||||
"test:channels": "vitest run --config vitest.channels.config.ts",
|
||||
"test:contracts": "pnpm test -- src/channels/plugins/contracts src/plugins/contracts",
|
||||
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
|
||||
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
|
||||
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
|
||||
@ -323,6 +334,7 @@
|
||||
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts",
|
||||
"test:extension": "node scripts/test-extension.mjs",
|
||||
"test:extensions": "vitest run --config vitest.extensions.config.ts",
|
||||
"test:fast": "vitest run --config vitest.unit.config.ts",
|
||||
"test:force": "node --import tsx scripts/test-force.ts",
|
||||
@ -382,6 +394,7 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"file-type": "^21.3.2",
|
||||
"gaxios": "^7.1.3",
|
||||
"gigachat": "^0.0.18",
|
||||
"grammy": "^1.41.1",
|
||||
"hono": "4.12.7",
|
||||
|
||||
5
pnpm-lock.yaml
generated
5
pnpm-lock.yaml
generated
@ -125,6 +125,9 @@ importers:
|
||||
file-type:
|
||||
specifier: 21.3.2
|
||||
version: 21.3.2
|
||||
gaxios:
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
gigachat:
|
||||
specifier: ^0.0.18
|
||||
version: 0.0.18
|
||||
@ -274,6 +277,8 @@ importers:
|
||||
specifier: 0.3.0
|
||||
version: 0.3.0(zod@4.3.6)
|
||||
|
||||
extensions/amazon-bedrock: {}
|
||||
|
||||
extensions/anthropic: {}
|
||||
|
||||
extensions/bluebubbles:
|
||||
|
||||
@ -63,11 +63,12 @@ const cases = [
|
||||
];
|
||||
|
||||
function parseMaxRssMb(stderr) {
|
||||
const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m"));
|
||||
if (!match) {
|
||||
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
|
||||
const lastMatch = matches.at(-1);
|
||||
if (!lastMatch) {
|
||||
return null;
|
||||
}
|
||||
return Number(match[1]) / 1024;
|
||||
return Number(lastMatch[1]) / 1024;
|
||||
}
|
||||
|
||||
function buildBenchEnv() {
|
||||
@ -98,6 +99,9 @@ function buildBenchEnv() {
|
||||
// one-shot compile cache overhead, which varies across runner builds.
|
||||
env.NODE_DISABLE_COMPILE_CACHE = "1";
|
||||
}
|
||||
// Keep the benchmark on a single process so RSS reflects the actual command
|
||||
// path rather than the warning-suppression respawn wrapper.
|
||||
env.OPENCLAW_NO_RESPAWN = "1";
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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 . .
|
||||
|
||||
@ -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" <<JSON
|
||||
{
|
||||
"name": "@openclaw/$id",
|
||||
"version": "$version",
|
||||
"openclaw": { "extensions": ["./index.js"] }
|
||||
}
|
||||
JSON
|
||||
cat > "$dir/index.js" <<JS
|
||||
module.exports = {
|
||||
id: "$id",
|
||||
name: "$name",
|
||||
register(api) {
|
||||
api.registerGatewayMethod("$method", async () => ({ 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" <<JSON
|
||||
{
|
||||
"claude-fixtures": {
|
||||
"installLocation": "$marketplace_root",
|
||||
"source": {
|
||||
"type": "github",
|
||||
"repo": "openclaw/fixture-marketplace"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
node "$OPENCLAW_ENTRY" plugins marketplace list claude-fixtures --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"
|
||||
|
||||
283
scripts/test-extension.mjs
Normal file
283
scripts/test-extension.mjs
Normal file
@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { channelTestRoots } from "../vitest.channel-paths.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const pnpm = "pnpm";
|
||||
|
||||
function normalizeRelative(inputPath) {
|
||||
return inputPath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isTestFile(filePath) {
|
||||
return filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx");
|
||||
}
|
||||
|
||||
function collectTestFiles(rootPath) {
|
||||
const results = [];
|
||||
const stack = [rootPath];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current || !fs.existsSync(current)) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") {
|
||||
continue;
|
||||
}
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && isTestFile(fullPath)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function listChangedPaths(base, head = "HEAD") {
|
||||
if (!base) {
|
||||
throw new Error("A git base revision is required to list changed extensions.");
|
||||
}
|
||||
|
||||
return execFileSync("git", ["diff", "--name-only", base, head], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function hasExtensionPackage(extensionId) {
|
||||
return fs.existsSync(path.join(repoRoot, "extensions", extensionId, "package.json"));
|
||||
}
|
||||
|
||||
export function detectChangedExtensionIds(changedPaths) {
|
||||
const extensionIds = new Set();
|
||||
|
||||
for (const rawPath of changedPaths) {
|
||||
const relativePath = normalizeRelative(String(rawPath).trim());
|
||||
if (!relativePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionMatch = relativePath.match(/^extensions\/([^/]+)(?:\/|$)/);
|
||||
if (extensionMatch) {
|
||||
extensionIds.add(extensionMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairedCoreMatch = relativePath.match(/^src\/([^/]+)(?:\/|$)/);
|
||||
if (pairedCoreMatch && hasExtensionPackage(pairedCoreMatch[1])) {
|
||||
extensionIds.add(pairedCoreMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...extensionIds].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function listChangedExtensionIds(params = {}) {
|
||||
const base = params.base;
|
||||
const head = params.head ?? "HEAD";
|
||||
return detectChangedExtensionIds(listChangedPaths(base, head));
|
||||
}
|
||||
|
||||
function resolveExtensionDirectory(targetArg, cwd = process.cwd()) {
|
||||
if (targetArg) {
|
||||
const asGiven = path.resolve(cwd, targetArg);
|
||||
if (fs.existsSync(path.join(asGiven, "package.json"))) {
|
||||
return asGiven;
|
||||
}
|
||||
|
||||
const byName = path.join(repoRoot, "extensions", targetArg);
|
||||
if (fs.existsSync(path.join(byName, "package.json"))) {
|
||||
return byName;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown extension target "${targetArg}". Use an extension name like "slack" or a path under extensions/.`,
|
||||
);
|
||||
}
|
||||
|
||||
let current = cwd;
|
||||
while (true) {
|
||||
if (
|
||||
normalizeRelative(path.relative(repoRoot, current)).startsWith("extensions/") &&
|
||||
fs.existsSync(path.join(current, "package.json"))
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"No extension target provided, and current working directory is not inside extensions/.",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExtensionTestPlan(params = {}) {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const targetArg = params.targetArg;
|
||||
const extensionDir = resolveExtensionDirectory(targetArg, cwd);
|
||||
const extensionId = path.basename(extensionDir);
|
||||
const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir));
|
||||
|
||||
const roots = [relativeExtensionDir];
|
||||
const pairedCoreRoot = path.join(repoRoot, "src", extensionId);
|
||||
if (fs.existsSync(pairedCoreRoot)) {
|
||||
const pairedRelativeRoot = normalizeRelative(path.relative(repoRoot, pairedCoreRoot));
|
||||
if (collectTestFiles(pairedCoreRoot).length > 0) {
|
||||
roots.push(pairedRelativeRoot);
|
||||
}
|
||||
}
|
||||
|
||||
const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root));
|
||||
const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts";
|
||||
const testFiles = roots.flatMap((root) => collectTestFiles(path.join(repoRoot, root)));
|
||||
|
||||
return {
|
||||
config,
|
||||
extensionDir: relativeExtensionDir,
|
||||
extensionId,
|
||||
roots,
|
||||
testFiles: testFiles.map((filePath) => normalizeRelative(path.relative(repoRoot, filePath))),
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.error("Usage: pnpm test:extension <extension-name|path> [vitest args...]");
|
||||
console.error(" node scripts/test-extension.mjs [extension-name|path] [vitest args...]");
|
||||
console.error(
|
||||
" node scripts/test-extension.mjs --list-changed --base <git-ref> [--head <git-ref>]",
|
||||
);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const dryRun = rawArgs.includes("--dry-run");
|
||||
const json = rawArgs.includes("--json");
|
||||
const listChanged = rawArgs.includes("--list-changed");
|
||||
const args = rawArgs.filter(
|
||||
(arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json" && arg !== "--list-changed",
|
||||
);
|
||||
|
||||
let base = "";
|
||||
let head = "HEAD";
|
||||
const passthroughArgs = [];
|
||||
|
||||
if (listChanged) {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--base") {
|
||||
base = args[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--head") {
|
||||
head = args[index + 1] ?? "HEAD";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
passthroughArgs.push(arg);
|
||||
}
|
||||
} else {
|
||||
passthroughArgs.push(...args);
|
||||
}
|
||||
|
||||
if (listChanged) {
|
||||
let extensionIds;
|
||||
try {
|
||||
extensionIds = listChangedExtensionIds({ base, head });
|
||||
} catch (error) {
|
||||
printUsage();
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify({ base, head, extensionIds }, null, 2)}\n`);
|
||||
} else {
|
||||
for (const extensionId of extensionIds) {
|
||||
console.log(extensionId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let targetArg;
|
||||
if (passthroughArgs[0] && !passthroughArgs[0].startsWith("-")) {
|
||||
targetArg = passthroughArgs.shift();
|
||||
}
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = resolveExtensionTestPlan({ cwd: process.cwd(), targetArg });
|
||||
} catch (error) {
|
||||
printUsage();
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (plan.testFiles.length === 0) {
|
||||
console.error(`No tests found for ${plan.extensionDir}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
||||
} else {
|
||||
console.log(`[test-extension] ${plan.extensionId}`);
|
||||
console.log(`config: ${plan.config}`);
|
||||
console.log(`roots: ${plan.roots.join(", ")}`);
|
||||
console.log(`tests: ${plan.testFiles.length}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[test-extension] Running ${plan.testFiles.length} test files for ${plan.extensionId} with ${plan.config}`,
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
pnpm,
|
||||
["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...passthroughArgs],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
}
|
||||
|
||||
const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
|
||||
|
||||
if (import.meta.url === entryHref) {
|
||||
await run();
|
||||
}
|
||||
@ -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 "";
|
||||
}
|
||||
|
||||
56
src/agents/auth-profiles/upsert-with-lock.ts
Normal file
56
src/agents/auth-profiles/upsert-with-lock.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { withFileLock } from "../../infra/file-lock.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION } from "./constants.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
|
||||
function coerceAuthProfileStore(raw: unknown): AuthProfileStore {
|
||||
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
||||
const profiles =
|
||||
record.profiles && typeof record.profiles === "object" && !Array.isArray(record.profiles)
|
||||
? { ...(record.profiles as Record<string, AuthProfileCredential>) }
|
||||
: {};
|
||||
const order =
|
||||
record.order && typeof record.order === "object" && !Array.isArray(record.order)
|
||||
? (record.order as Record<string, string[]>)
|
||||
: undefined;
|
||||
const lastGood =
|
||||
record.lastGood && typeof record.lastGood === "object" && !Array.isArray(record.lastGood)
|
||||
? (record.lastGood as Record<string, string>)
|
||||
: undefined;
|
||||
const usageStats =
|
||||
record.usageStats && typeof record.usageStats === "object" && !Array.isArray(record.usageStats)
|
||||
? (record.usageStats as Record<string, ProfileUsageStats>)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
version:
|
||||
typeof record.version === "number" && Number.isFinite(record.version)
|
||||
? record.version
|
||||
: AUTH_STORE_VERSION,
|
||||
profiles,
|
||||
...(order ? { order } : {}),
|
||||
...(lastGood ? { lastGood } : {}),
|
||||
...(usageStats ? { usageStats } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertAuthProfileWithLock(params: {
|
||||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
agentDir?: string;
|
||||
}): Promise<AuthProfileStore | null> {
|
||||
const authPath = resolveAuthStorePath(params.agentDir);
|
||||
ensureAuthStoreFile(authPath);
|
||||
|
||||
try {
|
||||
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
|
||||
const store = coerceAuthProfileStore(loadJsonFile(authPath));
|
||||
store.profiles[params.profileId] = params.credential;
|
||||
saveJsonFile(authPath, store);
|
||||
return store;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,12 @@ import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
|
||||
import {
|
||||
getApiKeyForModel,
|
||||
hasAvailableAuthForProvider,
|
||||
resolveApiKeyForProvider,
|
||||
resolveEnvApiKey,
|
||||
} from "./model-auth.js";
|
||||
|
||||
const envVar = (...parts: string[]) => parts.join("_");
|
||||
|
||||
@ -206,6 +211,40 @@ describe("getApiKeyForModel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
GEMINI_API_KEY: undefined,
|
||||
GOOGLE_API_KEY: "google-test-key", // pragma: allowlist secret
|
||||
},
|
||||
async () => {
|
||||
await expect(
|
||||
hasAvailableAuthForProvider({
|
||||
provider: "google",
|
||||
store: { version: 1, profiles: {} },
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("hasAvailableAuthForProvider returns false when no provider auth is available", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
ZAI_API_KEY: undefined,
|
||||
Z_AI_API_KEY: undefined,
|
||||
},
|
||||
async () => {
|
||||
await expect(
|
||||
hasAvailableAuthForProvider({
|
||||
provider: "zai",
|
||||
store: { version: 1, profiles: {} },
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Synthetic API key from env", async () => {
|
||||
await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => {
|
||||
// pragma: allowlist secret
|
||||
|
||||
@ -487,6 +487,56 @@ export function resolveModelAuthMode(
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function hasAvailableAuthForProvider(params: {
|
||||
provider: string;
|
||||
cfg?: OpenClawConfig;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
}): Promise<boolean> {
|
||||
const { provider, cfg, preferredProfile } = params;
|
||||
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
|
||||
|
||||
const authOverride = resolveProviderAuthOverride(cfg, provider);
|
||||
if (authOverride === "aws-sdk") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider,
|
||||
preferredProfile,
|
||||
});
|
||||
for (const candidate of order) {
|
||||
try {
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg,
|
||||
store,
|
||||
profileId: candidate,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (resolved) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolveEnvApiKey(provider)) {
|
||||
return true;
|
||||
}
|
||||
if (resolveUsableCustomProviderApiKey({ cfg, provider })) {
|
||||
return true;
|
||||
}
|
||||
if (resolveSyntheticLocalProviderAuth({ cfg, provider })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return authOverride === undefined && normalizeProviderId(provider) === "amazon-bedrock";
|
||||
}
|
||||
|
||||
export async function getApiKeyForModel(params: {
|
||||
model: Model<Api>;
|
||||
cfg?: OpenClawConfig;
|
||||
|
||||
@ -18,8 +18,15 @@ import {
|
||||
resolveOllamaApiBase,
|
||||
type OllamaTagsResponse,
|
||||
} from "./ollama-models.js";
|
||||
import {
|
||||
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
|
||||
SELF_HOSTED_DEFAULT_COST,
|
||||
SELF_HOSTED_DEFAULT_MAX_TOKENS,
|
||||
} from "./self-hosted-provider-defaults.js";
|
||||
import { SGLANG_DEFAULT_BASE_URL, SGLANG_PROVIDER_LABEL } from "./sglang-defaults.js";
|
||||
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
|
||||
import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
|
||||
import { VLLM_DEFAULT_BASE_URL, VLLM_PROVIDER_LABEL } from "./vllm-defaults.js";
|
||||
|
||||
export { resolveOllamaApiBase } from "./ollama-models.js";
|
||||
|
||||
@ -31,19 +38,6 @@ const log = createSubsystemLogger("agents/model-providers");
|
||||
const OLLAMA_SHOW_CONCURRENCY = 8;
|
||||
const OLLAMA_SHOW_MAX_MODELS = 200;
|
||||
|
||||
const OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
const OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS = 8192;
|
||||
const OPENAI_COMPAT_LOCAL_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const SGLANG_BASE_URL = "http://127.0.0.1:30000/v1";
|
||||
|
||||
const VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
|
||||
|
||||
type OpenAICompatModelsResponse = {
|
||||
data?: Array<{
|
||||
id?: string;
|
||||
@ -140,9 +134,9 @@ async function discoverOpenAICompatibleLocalModels(params: {
|
||||
name: modelId,
|
||||
reasoning: isReasoningModelHeuristic(modelId),
|
||||
input: ["text"],
|
||||
cost: OPENAI_COMPAT_LOCAL_DEFAULT_COST,
|
||||
contextWindow: params.contextWindow ?? OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: params.maxTokens ?? OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS,
|
||||
cost: SELF_HOSTED_DEFAULT_COST,
|
||||
contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS,
|
||||
} satisfies ModelDefinitionConfig;
|
||||
});
|
||||
} catch (error) {
|
||||
@ -197,11 +191,11 @@ export async function buildVllmProvider(params?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
}): Promise<ProviderConfig> {
|
||||
const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, "");
|
||||
const baseUrl = (params?.baseUrl?.trim() || VLLM_DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||||
const models = await discoverOpenAICompatibleLocalModels({
|
||||
baseUrl,
|
||||
apiKey: params?.apiKey,
|
||||
label: "vLLM",
|
||||
label: VLLM_PROVIDER_LABEL,
|
||||
});
|
||||
return {
|
||||
baseUrl,
|
||||
@ -214,11 +208,11 @@ export async function buildSglangProvider(params?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
}): Promise<ProviderConfig> {
|
||||
const baseUrl = (params?.baseUrl?.trim() || SGLANG_BASE_URL).replace(/\/+$/, "");
|
||||
const baseUrl = (params?.baseUrl?.trim() || SGLANG_DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||||
const models = await discoverOpenAICompatibleLocalModels({
|
||||
baseUrl,
|
||||
apiKey: params?.apiKey,
|
||||
label: "SGLang",
|
||||
label: SGLANG_PROVIDER_LABEL,
|
||||
});
|
||||
return {
|
||||
baseUrl,
|
||||
|
||||
1
src/agents/ollama-defaults.ts
Normal file
1
src/agents/ollama-defaults.ts
Normal file
@ -0,0 +1 @@
|
||||
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ModelDefinitionConfig } from "../config/types.models.js";
|
||||
import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "./ollama-defaults.js";
|
||||
|
||||
export const OLLAMA_DEFAULT_BASE_URL = OLLAMA_NATIVE_BASE_URL;
|
||||
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
export const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
|
||||
export const OLLAMA_DEFAULT_COST = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user