Merge branch 'main' into fix/sandbox-fakeowner-write-data-loss

This commit is contained in:
0x4C33 2026-03-17 00:24:57 +08:00 committed by GitHub
commit 031cd958af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
268 changed files with 8003 additions and 2119 deletions

View File

@ -78,6 +78,50 @@ jobs:
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD 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 dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts: build-artifacts:
needs: [docs-scope, changed-scope] needs: [docs-scope, changed-scope]
@ -205,6 +249,29 @@ jobs:
if: matrix.runtime != 'bun' || github.event_name != 'pull_request' if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
run: ${{ matrix.command }} 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. # Types, lint, and format check.
check: check:
name: "check" name: "check"

View File

@ -1 +1,2 @@
**/node_modules/ **/node_modules/
docs/.generated/

View File

@ -13,49 +13,52 @@ Docs: https://docs.openclaw.ai
- 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/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/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. - 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. - Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. (#47630) Thanks @vincentkoc.
- 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. - 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. - 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) - 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) Thanks @Takhoffman.
- 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. - 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. (#48058) 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/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) Thanks @Takhoffman.
- 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/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) Thanks @day253.
- 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/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. - 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. - 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/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - 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. - 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. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
- Browser/existing-session: support `browser.profiles.<name>.userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark.
- Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese.
### Breaking ### Breaking
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. Thanks @vincentkoc. - Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
### Fixes ### Fixes
- 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. - 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. - 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. (#47560) Thanks @ngutman.
- 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. - 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. - 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.
- 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. - 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. (#47902) Fixes #46924 and #47041. Thanks @steipete.
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc. - Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. (#46800) 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. - 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.
- 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/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. - 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/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/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/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: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc. - 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`) - 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. - 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. - 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. (#46802) Thanks @vincentkoc.
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc. - Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. (#46816) Thanks @vincentkoc.
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. - Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. (#46803) Thanks @vincentkoc.
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. - Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. (#46799) 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/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. - 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. - 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. (#46801) 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. - 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/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. - 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.
@ -63,35 +66,41 @@ Docs: https://docs.openclaw.ai
- 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. - 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. - 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.
- 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. (#47968) Thanks @Takhoffman.
- 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/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/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. (#47968) Thanks @Takhoffman.
- 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/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. - 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 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) - Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) Thanks @obviyus.
- 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: 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) Thanks @obviyus.
- 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. - 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 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. (#46663) Fixes #40146. Thanks @Takhoffman.
- 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. - 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/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`. (#46722) Thanks @Takhoffman.
- 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. - 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. (#47413) 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: 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. - 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.
- 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/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: 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. - Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46580) Fixes #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. - 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. - 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. - 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`. (#46596) Fixes #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. - 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. - 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. - 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. - 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. - 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) - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk.
- 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. - 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.
- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305.
- 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.
- 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. (#47601) Thanks @ngutman.
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk.
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk.
## 2026.3.13 ## 2026.3.13

View File

@ -89,6 +89,9 @@ Welcome to the lobster tank! 🦞
- Test locally with your OpenClaw instance - Test locally with your OpenClaw instance
- Run tests: `pnpm build && pnpm check && pnpm test` - 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. - 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 - Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Keep PRs focused (one thing per PR; do not mix unrelated concerns)

View File

@ -18,14 +18,13 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels() private val viewModel: MainViewModel by viewModels()
private lateinit var permissionRequester: PermissionRequester private lateinit var permissionRequester: PermissionRequester
private var didAttachRuntimeUi = false
private var didStartNodeService = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this) permissionRequester = PermissionRequester(this)
viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
@ -39,6 +38,20 @@ class MainActivity : ComponentActivity() {
} }
} }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.runtimeInitialized.collect { ready ->
if (!ready || didAttachRuntimeUi) return@collect
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
didAttachRuntimeUi = true
if (!didStartNodeService) {
NodeForegroundService.start(this@MainActivity)
didStartNodeService = true
}
}
}
}
setContent { setContent {
OpenClawTheme { OpenClawTheme {
Surface(modifier = Modifier) { Surface(modifier = Modifier) {
@ -46,9 +59,6 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
// Keep startup path lean: start foreground service after first frame.
window.decorView.post { NodeForegroundService.start(this) }
} }
override fun onStart() { override fun onStart() {

View File

@ -2,209 +2,268 @@ package ai.openclaw.app
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import ai.openclaw.app.gateway.GatewayEndpoint import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewModelScope
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.node.CameraCaptureManager import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.voice.VoiceConversationEntry import ai.openclaw.app.voice.VoiceConversationEntry
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModel(app: Application) : AndroidViewModel(app) { class MainViewModel(app: Application) : AndroidViewModel(app) {
private val runtime: NodeRuntime = (app as NodeApp).runtime private val nodeApp = app as NodeApp
private val prefs = nodeApp.prefs
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
private var foreground = true
val canvas: CanvasController = runtime.canvas private fun ensureRuntime(): NodeRuntime {
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl runtimeRef.value?.let { return it }
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated val runtime = nodeApp.ensureRuntime()
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending runtime.setForeground(foreground)
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText runtimeRef.value = runtime
val camera: CameraCaptureManager = runtime.camera return runtime
val sms: SmsManager = runtime.sms }
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways private fun <T> runtimeState(
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText initial: T,
selector: (NodeRuntime) -> StateFlow<T>,
): StateFlow<T> =
runtimeRef
.flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) }
.stateIn(viewModelScope, SharingStarted.Eagerly, initial)
val isConnected: StateFlow<Boolean> = runtime.isConnected val runtimeInitialized: StateFlow<Boolean> =
val isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected runtimeRef
val statusText: StateFlow<String> = runtime.statusText .flatMapLatest { runtime -> flowOf(runtime != null) }
val serverName: StateFlow<String?> = runtime.serverName .stateIn(viewModelScope, SharingStarted.Eagerly, false)
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud val canvasCurrentUrl: StateFlow<String?> = runtimeState(initial = null) { it.canvas.currentUrl }
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken val canvasA2uiHydrated: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasA2uiHydrated }
val canvasRehydratePending: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasRehydratePending }
val canvasRehydrateErrorText: StateFlow<String?> = runtimeState(initial = null) { it.canvasRehydrateErrorText }
val instanceId: StateFlow<String> = runtime.instanceId val gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
val displayName: StateFlow<String> = runtime.displayName val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
val micCooldown: StateFlow<Boolean> = runtime.micCooldown
val micStatusText: StateFlow<String> = runtime.micStatusText
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
val micQueuedMessages: StateFlow<List<String>> = runtime.micQueuedMessages
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val gatewayToken: StateFlow<String> = runtime.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = runtime.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val chatSessionId: StateFlow<String?> = runtime.chatSessionId val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val chatMessages = runtime.chatMessages val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val chatError: StateFlow<String?> = runtime.chatError val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
val chatPendingToolCalls = runtime.chatPendingToolCalls val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
val chatSessions = runtime.chatSessions
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount val cameraHud: StateFlow<CameraHudState?> = runtimeState(initial = null) { it.cameraHud }
val cameraFlashToken: StateFlow<Long> = runtimeState(initial = 0L) { it.cameraFlashToken }
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val locationMode: StateFlow<LocationMode> = prefs.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
val micEnabled: StateFlow<Boolean> = prefs.talkEnabled
val micCooldown: StateFlow<Boolean> = runtimeState(initial = false) { it.micCooldown }
val micStatusText: StateFlow<String> = runtimeState(initial = "Mic off") { it.micStatusText }
val micLiveTranscript: StateFlow<String?> = runtimeState(initial = null) { it.micLiveTranscript }
val micIsListening: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsListening }
val micQueuedMessages: StateFlow<List<String>> = runtimeState(initial = emptyList()) { it.micQueuedMessages }
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtimeState(initial = emptyList()) { it.micConversation }
val micInputLevel: StateFlow<Float> = runtimeState(initial = 0f) { it.micInputLevel }
val micIsSending: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsSending }
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
val chatMessages: StateFlow<List<ChatMessage>> = runtimeState(initial = emptyList()) { it.chatMessages }
val chatError: StateFlow<String?> = runtimeState(initial = null) { it.chatError }
val chatHealthOk: StateFlow<Boolean> = runtimeState(initial = false) { it.chatHealthOk }
val chatThinkingLevel: StateFlow<String> = runtimeState(initial = "off") { it.chatThinkingLevel }
val chatStreamingAssistantText: StateFlow<String?> = runtimeState(initial = null) { it.chatStreamingAssistantText }
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
init {
if (prefs.onboardingCompleted.value) {
ensureRuntime()
}
}
val canvas: CanvasController
get() = ensureRuntime().canvas
val camera: CameraCaptureManager
get() = ensureRuntime().camera
val sms: SmsManager
get() = ensureRuntime().sms
fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) {
val runtime = runtimeRef.value ?: return
runtime.camera.attachLifecycleOwner(owner)
runtime.camera.attachPermissionRequester(permissionRequester)
runtime.sms.attachPermissionRequester(permissionRequester)
}
fun setForeground(value: Boolean) { fun setForeground(value: Boolean) {
runtime.setForeground(value) foreground = value
runtimeRef.value?.setForeground(value)
} }
fun setDisplayName(value: String) { fun setDisplayName(value: String) {
runtime.setDisplayName(value) prefs.setDisplayName(value)
} }
fun setCameraEnabled(value: Boolean) { fun setCameraEnabled(value: Boolean) {
runtime.setCameraEnabled(value) prefs.setCameraEnabled(value)
} }
fun setLocationMode(mode: LocationMode) { fun setLocationMode(mode: LocationMode) {
runtime.setLocationMode(mode) prefs.setLocationMode(mode)
} }
fun setLocationPreciseEnabled(value: Boolean) { fun setLocationPreciseEnabled(value: Boolean) {
runtime.setLocationPreciseEnabled(value) prefs.setLocationPreciseEnabled(value)
} }
fun setPreventSleep(value: Boolean) { fun setPreventSleep(value: Boolean) {
runtime.setPreventSleep(value) prefs.setPreventSleep(value)
} }
fun setManualEnabled(value: Boolean) { fun setManualEnabled(value: Boolean) {
runtime.setManualEnabled(value) prefs.setManualEnabled(value)
} }
fun setManualHost(value: String) { fun setManualHost(value: String) {
runtime.setManualHost(value) prefs.setManualHost(value)
} }
fun setManualPort(value: Int) { fun setManualPort(value: Int) {
runtime.setManualPort(value) prefs.setManualPort(value)
} }
fun setManualTls(value: Boolean) { fun setManualTls(value: Boolean) {
runtime.setManualTls(value) prefs.setManualTls(value)
} }
fun setGatewayToken(value: String) { fun setGatewayToken(value: String) {
runtime.setGatewayToken(value) prefs.setGatewayToken(value)
} }
fun setGatewayBootstrapToken(value: String) { fun setGatewayBootstrapToken(value: String) {
runtime.setGatewayBootstrapToken(value) prefs.setGatewayBootstrapToken(value)
} }
fun setGatewayPassword(value: String) { fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value) prefs.setGatewayPassword(value)
} }
fun setOnboardingCompleted(value: Boolean) { fun setOnboardingCompleted(value: Boolean) {
runtime.setOnboardingCompleted(value) if (value) {
ensureRuntime()
}
prefs.setOnboardingCompleted(value)
} }
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value) prefs.setCanvasDebugStatusEnabled(value)
} }
fun setVoiceScreenActive(active: Boolean) { fun setVoiceScreenActive(active: Boolean) {
runtime.setVoiceScreenActive(active) ensureRuntime().setVoiceScreenActive(active)
} }
fun setMicEnabled(enabled: Boolean) { fun setMicEnabled(enabled: Boolean) {
runtime.setMicEnabled(enabled) ensureRuntime().setMicEnabled(enabled)
} }
fun setSpeakerEnabled(enabled: Boolean) { fun setSpeakerEnabled(enabled: Boolean) {
runtime.setSpeakerEnabled(enabled) ensureRuntime().setSpeakerEnabled(enabled)
} }
fun refreshGatewayConnection() { fun refreshGatewayConnection() {
runtime.refreshGatewayConnection() ensureRuntime().refreshGatewayConnection()
} }
fun connect(endpoint: GatewayEndpoint) { fun connect(endpoint: GatewayEndpoint) {
runtime.connect(endpoint) ensureRuntime().connect(endpoint)
} }
fun connectManual() { fun connectManual() {
runtime.connectManual() ensureRuntime().connectManual()
} }
fun disconnect() { fun disconnect() {
runtime.disconnect() runtimeRef.value?.disconnect()
} }
fun acceptGatewayTrustPrompt() { fun acceptGatewayTrustPrompt() {
runtime.acceptGatewayTrustPrompt() runtimeRef.value?.acceptGatewayTrustPrompt()
} }
fun declineGatewayTrustPrompt() { fun declineGatewayTrustPrompt() {
runtime.declineGatewayTrustPrompt() runtimeRef.value?.declineGatewayTrustPrompt()
} }
fun handleCanvasA2UIActionFromWebView(payloadJson: String) { fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson) ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson)
} }
fun requestCanvasRehydrate(source: String = "screen_tab") { fun requestCanvasRehydrate(source: String = "screen_tab") {
runtime.requestCanvasRehydrate(source = source, force = true) ensureRuntime().requestCanvasRehydrate(source = source, force = true)
} }
fun refreshHomeCanvasOverviewIfConnected() { fun refreshHomeCanvasOverviewIfConnected() {
runtime.refreshHomeCanvasOverviewIfConnected() ensureRuntime().refreshHomeCanvasOverviewIfConnected()
} }
fun loadChat(sessionKey: String) { fun loadChat(sessionKey: String) {
runtime.loadChat(sessionKey) ensureRuntime().loadChat(sessionKey)
} }
fun refreshChat() { fun refreshChat() {
runtime.refreshChat() ensureRuntime().refreshChat()
} }
fun refreshChatSessions(limit: Int? = null) { fun refreshChatSessions(limit: Int? = null) {
runtime.refreshChatSessions(limit = limit) ensureRuntime().refreshChatSessions(limit = limit)
} }
fun setChatThinkingLevel(level: String) { fun setChatThinkingLevel(level: String) {
runtime.setChatThinkingLevel(level) ensureRuntime().setChatThinkingLevel(level)
} }
fun switchChatSession(sessionKey: String) { fun switchChatSession(sessionKey: String) {
runtime.switchChatSession(sessionKey) ensureRuntime().switchChatSession(sessionKey)
} }
fun abortChat() { fun abortChat() {
runtime.abortChat() ensureRuntime().abortChat()
} }
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) { fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
runtime.sendChat(message = message, thinking = thinking, attachments = attachments) ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
} }
} }

View File

@ -4,7 +4,18 @@ import android.app.Application
import android.os.StrictMode import android.os.StrictMode
class NodeApp : Application() { class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) } val prefs: SecurePrefs by lazy { SecurePrefs(this) }
@Volatile private var runtimeInstance: NodeRuntime? = null
fun ensureRuntime(): NodeRuntime {
runtimeInstance?.let { return it }
return synchronized(this) {
runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it }
}
}
fun peekRuntime(): NodeRuntime? = runtimeInstance
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View File

@ -28,7 +28,11 @@ class NodeForegroundService : Service() {
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…") val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
startForegroundWithTypes(notification = initial) startForegroundWithTypes(notification = initial)
val runtime = (application as NodeApp).runtime val runtime = (application as NodeApp).peekRuntime()
if (runtime == null) {
stopSelf()
return
}
notificationJob = notificationJob =
scope.launch { scope.launch {
combine( combine(
@ -59,7 +63,7 @@ class NodeForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
(application as NodeApp).runtime.disconnect() (application as NodeApp).peekRuntime()?.disconnect()
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }

View File

@ -43,11 +43,12 @@ import kotlinx.serialization.json.buildJsonObject
import java.util.UUID import java.util.UUID
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
class NodeRuntime(context: Context) { class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs) private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController() val canvas = CanvasController()
val camera = CameraCaptureManager(appContext) val camera = CameraCaptureManager(appContext)

View File

@ -265,7 +265,7 @@ class ChatController(
} }
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
val history = parseHistory(historyJson, sessionKey = key) val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
_messages.value = history.messages _messages.value = history.messages
_sessionId.value = history.sessionId _sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
@ -336,7 +336,7 @@ class ChatController(
try { try {
val historyJson = val historyJson =
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
val history = parseHistory(historyJson, sessionKey = _sessionKey.value) val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
_messages.value = history.messages _messages.value = history.messages
_sessionId.value = history.sessionId _sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
@ -450,7 +450,11 @@ class ChatController(
} }
} }
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { private fun parseHistory(
historyJson: String,
sessionKey: String,
previousMessages: List<ChatMessage>,
): ChatHistory {
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
val sid = root["sessionId"].asStringOrNull() val sid = root["sessionId"].asStringOrNull()
val thinkingLevel = root["thinkingLevel"].asStringOrNull() val thinkingLevel = root["thinkingLevel"].asStringOrNull()
@ -470,7 +474,12 @@ class ChatController(
) )
} }
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) return ChatHistory(
sessionKey = sessionKey,
sessionId = sid,
thinkingLevel = thinkingLevel,
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
)
} }
private fun parseMessageContent(el: JsonElement): ChatMessageContent? { private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
@ -519,6 +528,47 @@ class ChatController(
} }
} }
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
if (previous.isEmpty() || incoming.isEmpty()) return incoming
val idsByKey = LinkedHashMap<String, ArrayDeque<String>>()
for (message in previous) {
val key = messageIdentityKey(message) ?: continue
idsByKey.getOrPut(key) { ArrayDeque() }.addLast(message.id)
}
return incoming.map { message ->
val key = messageIdentityKey(message) ?: return@map message
val ids = idsByKey[key] ?: return@map message
val reusedId = ids.removeFirstOrNull() ?: return@map message
if (ids.isEmpty()) {
idsByKey.remove(key)
}
if (reusedId == message.id) return@map message
message.copy(id = reusedId)
}
}
internal fun messageIdentityKey(message: ChatMessage): String? {
val role = message.role.trim().lowercase()
if (role.isEmpty()) return null
val timestamp = message.timestampMs?.toString().orEmpty()
val contentFingerprint =
message.content.joinToString(separator = "\u001E") { part ->
listOf(
part.type.trim().lowercase(),
part.text?.trim().orEmpty(),
part.mimeType?.trim()?.lowercase().orEmpty(),
part.fileName?.trim().orEmpty(),
part.base64?.hashCode()?.toString().orEmpty(),
).joinToString(separator = "\u001F")
}
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray

View File

@ -1,7 +1,5 @@
package ai.openclaw.app.ui.chat package ai.openclaw.app.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -28,8 +26,7 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState {
image = image =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
try { try {
val bytes = Base64.decode(base64, Base64.DEFAULT) val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
bitmap.asImageBitmap() bitmap.asImageBitmap()
} catch (_: Throwable) { } catch (_: Throwable) {
null null

View File

@ -0,0 +1,150 @@
package ai.openclaw.app.ui.chat
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import android.util.LruCache
import androidx.core.graphics.scale
import ai.openclaw.app.node.JpegSizeLimiter
import java.io.ByteArrayOutputStream
import kotlin.math.max
import kotlin.math.roundToInt
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
private const val CHAT_ATTACHMENT_START_QUALITY = 85
private const val CHAT_DECODE_MAX_DIMENSION = 1600
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
private val decodedBitmapCache =
object : LruCache<String, Bitmap>(CHAT_IMAGE_CACHE_BYTES) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount.coerceAtLeast(1)
}
internal fun loadSizedImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
val fileName = normalizeAttachmentFileName((uri.lastPathSegment ?: "image").substringAfterLast('/'))
val bitmap = decodeScaledBitmap(resolver, uri, maxDimension = CHAT_ATTACHMENT_MAX_WIDTH)
if (bitmap == null) {
throw IllegalStateException("unsupported attachment")
}
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
val encoded =
JpegSizeLimiter.compressToLimit(
initialWidth = bitmap.width,
initialHeight = bitmap.height,
startQuality = CHAT_ATTACHMENT_START_QUALITY,
maxBytes = maxBytes,
minSize = 240,
encode = { width, height, quality ->
val working =
if (width == bitmap.width && height == bitmap.height) {
bitmap
} else {
bitmap.scale(width, height, true)
}
try {
val out = ByteArrayOutputStream()
if (!working.compress(Bitmap.CompressFormat.JPEG, quality, out)) {
throw IllegalStateException("attachment encode failed")
}
out.toByteArray()
} finally {
if (working !== bitmap) {
working.recycle()
}
}
},
)
val base64 = Base64.encodeToString(encoded.bytes, Base64.NO_WRAP)
return PendingImageAttachment(
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
fileName = fileName,
mimeType = "image/jpeg",
base64 = base64,
)
}
internal fun decodeBase64Bitmap(base64: String, maxDimension: Int = CHAT_DECODE_MAX_DIMENSION): Bitmap? {
val cacheKey = "$maxDimension:${base64.length}:${base64.hashCode()}"
decodedBitmapCache.get(cacheKey)?.let { return it }
val bytes = Base64.decode(base64, Base64.DEFAULT)
if (bytes.isEmpty()) return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val bitmap =
BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply {
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
inPreferredConfig = Bitmap.Config.RGB_565
},
) ?: return null
decodedBitmapCache.put(cacheKey, bitmap)
return bitmap
}
internal fun computeInSampleSize(width: Int, height: Int, maxDimension: Int): Int {
if (width <= 0 || height <= 0 || maxDimension <= 0) return 1
var sample = 1
var longestEdge = max(width, height)
while (longestEdge > maxDimension && sample < 64) {
sample *= 2
longestEdge = max(width / sample, height / sample)
}
return sample.coerceAtLeast(1)
}
internal fun normalizeAttachmentFileName(raw: String): String {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "image.jpg"
val stem = trimmed.substringBeforeLast('.', missingDelimiterValue = trimmed).ifEmpty { "image" }
return "$stem.jpg"
}
private fun decodeScaledBitmap(
resolver: ContentResolver,
uri: Uri,
maxDimension: Int,
): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri).use { input ->
if (input == null) return null
BitmapFactory.decodeStream(input, null, bounds)
}
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val decoded =
resolver.openInputStream(uri).use { input ->
if (input == null) return null
BitmapFactory.decodeStream(
input,
null,
BitmapFactory.Options().apply {
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
inPreferredConfig = Bitmap.Config.ARGB_8888
},
)
} ?: return null
val longestEdge = max(decoded.width, decoded.height)
if (longestEdge <= maxDimension) return decoded
val scale = maxDimension.toDouble() / longestEdge.toDouble()
val targetWidth = max(1, (decoded.width * scale).roundToInt())
val targetHeight = max(1, (decoded.height * scale).roundToInt())
val scaled = decoded.scale(targetWidth, targetHeight, true)
if (scaled !== decoded) {
decoded.recycle()
}
return scaled
}

View File

@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -34,11 +36,19 @@ fun ChatMessageListCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val displayMessages = remember(messages) { messages.asReversed() }
val stream = streamingAssistantText?.trim()
// With reverseLayout the newest item is at index 0 (bottom of screen). // New list items/tool rows should animate into view, but token streaming should not restart
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) { // that animation on every delta.
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
listState.animateScrollToItem(index = 0) listState.animateScrollToItem(index = 0)
} }
LaunchedEffect(stream) {
if (!stream.isNullOrEmpty()) {
listState.scrollToItem(index = 0)
}
}
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
LazyColumn( LazyColumn(
@ -50,8 +60,6 @@ fun ChatMessageListCard(
) { ) {
// With reverseLayout = true, index 0 renders at the BOTTOM. // With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest). // So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
val stream = streamingAssistantText?.trim()
if (!stream.isNullOrEmpty()) { if (!stream.isNullOrEmpty()) {
item(key = "stream") { item(key = "stream") {
ChatStreamingAssistantBubble(text = stream) ChatStreamingAssistantBubble(text = stream)
@ -70,8 +78,8 @@ fun ChatMessageListCard(
} }
} }
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> items(items = displayMessages, key = { it.id }) { message ->
ChatMessageBubble(message = messages[messages.size - 1 - idx]) ChatMessageBubble(message = message)
} }
} }

View File

@ -1,8 +1,5 @@
package ai.openclaw.app.ui.chat package ai.openclaw.app.ui.chat
import android.content.ContentResolver
import android.net.Uri
import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
@ -47,7 +44,6 @@ import ai.openclaw.app.ui.mobileDanger
import ai.openclaw.app.ui.mobileDangerSoft import ai.openclaw.app.ui.mobileDangerSoft
import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary import ai.openclaw.app.ui.mobileTextSecondary
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -83,7 +79,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val next = val next =
uris.take(8).mapNotNull { uri -> uris.take(8).mapNotNull { uri ->
try { try {
loadImageAttachment(resolver, uri) loadSizedImageAttachment(resolver, uri)
} catch (_: Throwable) { } catch (_: Throwable) {
null null
} }
@ -160,7 +156,10 @@ private fun ChatThreadSelector(
mainSessionKey: String, mainSessionKey: String,
onSelectSession: (String) -> Unit, onSelectSession: (String) -> Unit,
) { ) {
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) val sessionOptions =
remember(sessionKey, sessions, mainSessionKey) {
resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
}
Row( Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
@ -214,24 +213,3 @@ data class PendingImageAttachment(
val mimeType: String, val mimeType: String,
val base64: String, val base64: String,
) )
private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
val mimeType = resolver.getType(uri) ?: "image/*"
val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/')
val bytes =
withContext(Dispatchers.IO) {
resolver.openInputStream(uri)?.use { input ->
val out = ByteArrayOutputStream()
input.copyTo(out)
out.toByteArray()
} ?: ByteArray(0)
}
if (bytes.isEmpty()) throw IllegalStateException("empty attachment")
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
return PendingImageAttachment(
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
fileName = fileName,
mimeType = mimeType,
base64 = base64,
)
}

View File

@ -0,0 +1,81 @@
package ai.openclaw.app.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ChatControllerMessageIdentityTest {
@Test
fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() {
val previous =
listOf(
ChatMessage(
id = "msg-1",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
),
ChatMessage(
id = "msg-2",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hi")),
timestampMs = 2000L,
),
)
val incoming =
listOf(
ChatMessage(
id = "new-1",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
),
ChatMessage(
id = "new-2",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hi")),
timestampMs = 2000L,
),
)
val reconciled = reconcileMessageIds(previous = previous, incoming = incoming)
assertEquals(listOf("msg-1", "msg-2"), reconciled.map { it.id })
}
@Test
fun reconcileMessageIdsLeavesNewMessagesUntouched() {
val previous =
listOf(
ChatMessage(
id = "msg-1",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
),
)
val incoming =
listOf(
ChatMessage(
id = "new-1",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
),
ChatMessage(
id = "new-2",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "new reply")),
timestampMs = 3000L,
),
)
val reconciled = reconcileMessageIds(previous = previous, incoming = incoming)
assertEquals("msg-1", reconciled[0].id)
assertEquals("new-2", reconciled[1].id)
assertNotEquals(reconciled[0].id, reconciled[1].id)
}
}

View File

@ -0,0 +1,18 @@
package ai.openclaw.app.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatImageCodecTest {
@Test
fun computeInSampleSizeCapsLongestEdge() {
assertEquals(4, computeInSampleSize(width = 4032, height = 3024, maxDimension = 1600))
assertEquals(1, computeInSampleSize(width = 800, height = 600, maxDimension = 1600))
}
@Test
fun normalizeAttachmentFileNameForcesJpegExtension() {
assertEquals("photo.jpg", normalizeAttachmentFileName("photo.png"))
assertEquals("image.jpg", normalizeAttachmentFileName(""))
}
}

View File

@ -8035,7 +8035,21 @@
"storage" "storage"
], ],
"label": "Browser Profile Driver", "label": "Browser Profile Driver",
"help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.", "help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome DevTools MCP attachment.",
"hasChildren": false
},
{
"path": "browser.profiles.*.userDataDir",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"storage"
],
"label": "Browser Profile User Data Dir",
"help": "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.",
"hasChildren": false "hasChildren": false
}, },
{ {
@ -32140,6 +32154,16 @@
"tags": [], "tags": [],
"hasChildren": false "hasChildren": false
}, },
{
"path": "channels.telegram.accounts.*.silentErrorReplies",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{ {
"path": "channels.telegram.accounts.*.streaming", "path": "channels.telegram.accounts.*.streaming",
"kind": "channel", "kind": "channel",
@ -34137,6 +34161,21 @@
"help": "Minimum retry delay in ms for Telegram outbound calls.", "help": "Minimum retry delay in ms for Telegram outbound calls.",
"hasChildren": false "hasChildren": false
}, },
{
"path": "channels.telegram.silentErrorReplies",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"channels",
"network"
],
"label": "Telegram Silent Error Replies",
"help": "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.",
"hasChildren": false
},
{ {
"path": "channels.telegram.streaming", "path": "channels.telegram.streaming",
"kind": "channel", "kind": "channel",
@ -51938,6 +51977,48 @@
"help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).", "help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).",
"hasChildren": false "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", "path": "plugins.installs.*.resolvedAt",
"kind": "core", "kind": "core",

View File

@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5098} {"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5104}
{"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","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":"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} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@ -707,7 +707,8 @@
{"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false}
{"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false}
{"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false}
{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.","hasChildren":false} {"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome DevTools MCP attachment.","hasChildren":false}
{"recordType":"path","path":"browser.profiles.*.userDataDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile User Data Dir","help":"Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.","hasChildren":false}
{"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false}
{"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false} {"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false}
{"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true} {"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true}
@ -2903,6 +2904,7 @@
{"recordType":"path","path":"channels.telegram.accounts.*.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.silentErrorReplies","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@ -3080,6 +3082,7 @@
{"recordType":"path","path":"channels.telegram.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays.","hasChildren":false} {"recordType":"path","path":"channels.telegram.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays.","hasChildren":false}
{"recordType":"path","path":"channels.telegram.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","performance","reliability"],"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls.","hasChildren":false} {"recordType":"path","path":"channels.telegram.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","performance","reliability"],"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls.","hasChildren":false}
{"recordType":"path","path":"channels.telegram.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls.","hasChildren":false} {"recordType":"path","path":"channels.telegram.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls.","hasChildren":false}
{"recordType":"path","path":"channels.telegram.silentErrorReplies","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.","hasChildren":false}
{"recordType":"path","path":"channels.telegram.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.","hasChildren":false} {"recordType":"path","path":"channels.telegram.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.","hasChildren":false}
{"recordType":"path","path":"channels.telegram.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@ -4506,6 +4509,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.*.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.*.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.*.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.*.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.*.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} {"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}

View File

@ -91,6 +91,7 @@ Use the built-in `user` profile, or create your own `existing-session` profile:
```bash ```bash
openclaw browser --browser-profile user tabs openclaw browser --browser-profile user tabs
openclaw browser create-profile --name chrome-live --driver existing-session openclaw browser create-profile --name chrome-live --driver existing-session
openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser"
openclaw browser --browser-profile chrome-live tabs openclaw browser --browser-profile chrome-live tabs
``` ```

View File

@ -284,7 +284,8 @@ Manage extensions and their config:
- `openclaw plugins list` — discover plugins (use `--json` for machine output). - `openclaw plugins list` — discover plugins (use `--json` for machine output).
- `openclaw plugins info <id>` — show details for a plugin. - `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 enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
- `openclaw plugins doctor` — report plugin load errors. - `openclaw plugins doctor` — report plugin load errors.

View File

@ -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: read_when:
- You want to install or manage Gateway plugins or compatible bundles - You want to install or manage Gateway plugins or compatible bundles
- You want to debug plugin load failures - You want to debug plugin load failures
@ -28,6 +28,7 @@ openclaw plugins uninstall <id>
openclaw plugins doctor openclaw plugins doctor
openclaw plugins update <id> openclaw plugins update <id>
openclaw plugins update --all openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
``` ```
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
@ -46,6 +47,8 @@ capabilities.
```bash ```bash
openclaw plugins install <path-or-spec> openclaw plugins install <path-or-spec>
openclaw plugins install <npm-spec> --pin 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. 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`. 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: For local paths and archives, OpenClaw auto-detects:
- native OpenClaw plugins (`openclaw.plugin.json`) - native OpenClaw plugins (`openclaw.plugin.json`)
@ -114,7 +142,8 @@ openclaw plugins update --all
openclaw plugins update <id> --dry-run 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, When a stored integrity hash exists and the fetched artifact hash changes,
OpenClaw prints a warning and asks for confirmation before proceeding. Use OpenClaw prints a warning and asks for confirmation before proceeding. Use

View File

@ -2443,6 +2443,12 @@ See [Plugins](/tools/plugin).
openclaw: { cdpPort: 18800, color: "#FF4500" }, openclaw: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" }, work: { cdpPort: 18801, color: "#0066CC" },
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
brave: {
driver: "existing-session",
attachOnly: true,
userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
color: "#FB542B",
},
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
}, },
color: "#FF4500", color: "#FF4500",
@ -2463,6 +2469,8 @@ See [Plugins](/tools/plugin).
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
- Remote profiles are attach-only (start/stop/reset disabled). - Remote profiles are attach-only (start/stop/reset disabled).
- `existing-session` profiles are host-only and use Chrome MCP instead of CDP. - `existing-session` profiles are host-only and use Chrome MCP instead of CDP.
- `existing-session` profiles can set `userDataDir` to target a specific
Chromium-based browser profile such as Brave or Edge.
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
- Control service: loopback only (port derived from `gateway.port`, default `18791`). - Control service: loopback only (port derived from `gateway.port`, default `18791`).
- `extraArgs` appends extra launch flags to local Chromium startup (for example - `extraArgs` appends extra launch flags to local Chromium startup (for example

View File

@ -155,18 +155,20 @@ normalizes it to the current host-local Chrome MCP attach model:
Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: Doctor also audits the host-local Chrome MCP path when you use `defaultProfile:
"user"` or a configured `existing-session` profile: "user"` or a configured `existing-session` profile:
- checks whether Google Chrome is installed on the same host - checks whether Google Chrome is installed on the same host for default
auto-connect profiles
- checks the detected Chrome version and warns when it is below Chrome 144 - checks the detected Chrome version and warns when it is below Chrome 144
- reminds you to enable remote debugging in Chrome at - reminds you to enable remote debugging in the browser inspect page (for
`chrome://inspect/#remote-debugging` example `chrome://inspect/#remote-debugging`, `brave://inspect/#remote-debugging`,
or `edge://inspect/#remote-debugging`)
Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP
still requires: still requires:
- Google Chrome 144+ on the gateway/node host - a Chromium-based browser 144+ on the gateway/node host
- Chrome running locally - the browser running locally
- remote debugging enabled in Chrome - remote debugging enabled in that browser
- approving the first attach consent prompt in Chrome - approving the first attach consent prompt in the browser
This check does **not** apply to Docker, sandbox, remote-browser, or other This check does **not** apply to Docker, sandbox, remote-browser, or other
headless flows. Those continue to use raw CDP. headless flows. Those continue to use raw CDP.

View File

@ -259,12 +259,19 @@ openclaw plugins install ./my-codex-bundle
openclaw plugins install ./my-claude-bundle openclaw plugins install ./my-claude-bundle
openclaw plugins install ./my-cursor-bundle openclaw plugins install ./my-cursor-bundle
openclaw plugins install ./my-bundle.tgz openclaw plugins install ./my-bundle.tgz
openclaw plugins marketplace list <marketplace-name>
openclaw plugins install <plugin-name>@<marketplace-name>
openclaw plugins info my-bundle openclaw plugins info my-bundle
``` ```
If the directory is a native OpenClaw plugin/package, the native install path If the directory is a native OpenClaw plugin/package, the native install path
still wins. 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 ## Troubleshooting
### Bundle is detected but capabilities do not run ### Bundle is detected but capabilities do not run

View File

@ -88,6 +88,12 @@ Browser settings live in `~/.openclaw/openclaw.json`.
attachOnly: true, attachOnly: true,
color: "#00AA00", color: "#00AA00",
}, },
brave: {
driver: "existing-session",
attachOnly: true,
userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
color: "#FB542B",
},
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
}, },
}, },
@ -114,6 +120,8 @@ Notes:
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
- `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do - `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do
not set `cdpUrl` for that driver. not set `cdpUrl` for that driver.
- Set `browser.profiles.<name>.userDataDir` when an existing-session profile
should attach to a non-default Chromium user profile such as Brave or Edge.
## Use Brave (or another Chromium-based browser) ## Use Brave (or another Chromium-based browser)
@ -289,11 +297,11 @@ Defaults:
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`. All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
## Chrome existing-session via MCP ## Existing-session via Chrome DevTools MCP
OpenClaw can also attach to a running Chrome profile through the official OpenClaw can also attach to a running Chromium-based browser profile through the
Chrome DevTools MCP server. This reuses the tabs and login state already open in official Chrome DevTools MCP server. This reuses the tabs and login state
that Chrome profile. already open in that browser profile.
Official background and setup references: Official background and setup references:
@ -305,13 +313,41 @@ Built-in profile:
- `user` - `user`
Optional: create your own custom existing-session profile if you want a Optional: create your own custom existing-session profile if you want a
different name or color. different name, color, or browser data directory.
Then in Chrome: Default behavior:
1. Open `chrome://inspect/#remote-debugging` - The built-in `user` profile uses Chrome MCP auto-connect, which targets the
2. Enable remote debugging default local Google Chrome profile.
3. Keep Chrome running and approve the connection prompt when OpenClaw attaches
Use `userDataDir` for Brave, Edge, Chromium, or a non-default Chrome profile:
```json5
{
browser: {
profiles: {
brave: {
driver: "existing-session",
attachOnly: true,
userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
color: "#FB542B",
},
},
},
}
```
Then in the matching browser:
1. Open that browser's inspect page for remote debugging.
2. Enable remote debugging.
3. Keep the browser running and approve the connection prompt when OpenClaw attaches.
Common inspect pages:
- Chrome: `chrome://inspect/#remote-debugging`
- Brave: `brave://inspect/#remote-debugging`
- Edge: `edge://inspect/#remote-debugging`
Live attach smoke test: Live attach smoke test:
@ -327,17 +363,17 @@ What success looks like:
- `status` shows `driver: existing-session` - `status` shows `driver: existing-session`
- `status` shows `transport: chrome-mcp` - `status` shows `transport: chrome-mcp`
- `status` shows `running: true` - `status` shows `running: true`
- `tabs` lists your already-open Chrome tabs - `tabs` lists your already-open browser tabs
- `snapshot` returns refs from the selected live tab - `snapshot` returns refs from the selected live tab
What to check if attach does not work: What to check if attach does not work:
- Chrome is version `144+` - the target Chromium-based browser is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging` - remote debugging is enabled in that browser's inspect page
- Chrome showed and you accepted the attach consent prompt - the browser showed and you accepted the attach consent prompt
- `openclaw doctor` migrates old extension-based browser config and checks that - `openclaw doctor` migrates old extension-based browser config and checks that
Chrome is installed locally with a compatible version, but it cannot enable Chrome is installed locally for default auto-connect profiles, but it cannot
Chrome-side remote debugging for you enable browser-side remote debugging for you
Agent use: Agent use:
@ -351,10 +387,11 @@ Notes:
- This path is higher-risk than the isolated `openclaw` profile because it can - This path is higher-risk than the isolated `openclaw` profile because it can
act inside your signed-in browser session. act inside your signed-in browser session.
- OpenClaw does not launch Chrome for this driver; it attaches to an existing - OpenClaw does not launch the browser for this driver; it attaches to an
session only. existing session only.
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not - OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here. If
the legacy default-profile remote debugging port workflow. `userDataDir` is set, OpenClaw passes it through to target that explicit
Chromium user data directory.
- Existing-session screenshots support page captures and `--ref` element - Existing-session screenshots support page captures and `--ref` element
captures from snapshots, but not CSS `--element` selectors. captures from snapshots, but not CSS `--element` selectors.
- Existing-session `wait --url` supports exact, substring, and glob patterns - Existing-session `wait --url` supports exact, substring, and glob patterns

View File

@ -57,6 +57,18 @@ openclaw plugins install ./my-bundle
openclaw plugins install ./my-bundle.tgz 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 ## Architecture
OpenClaw's plugin system has four layers: OpenClaw's plugin system has four layers:
@ -94,6 +106,10 @@ OpenClaw also recognizes two compatible external bundle layouts:
component layout without a manifest component layout without a manifest
- Cursor-style bundles: `.cursor-plugin/plugin.json` - 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 They are shown in the plugin list as `format=bundle`, with a subtype of
`codex` or `claude` in verbose/info output. `codex` or `claude` in verbose/info output.
@ -826,6 +842,37 @@ instead of the full plugin entry. This keeps startup and setup lighter
when your main plugin entry also wires tools, hooks, or other runtime-only when your main plugin entry also wires tools, hooks, or other runtime-only
code. code.
Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen`
can opt a channel plugin into the same `setupEntry` path during the gateway's
pre-listen startup phase, even when the channel is already configured.
Use this only when `setupEntry` fully covers the startup surface that must exist
before the gateway starts listening. In practice, that means the setup entry
must register every channel-owned capability that startup depends on, such as:
- channel registration itself
- any HTTP routes that must be available before the gateway starts listening
- any gateway methods, tools, or services that must exist during that same window
If your full entry still owns any required startup capability, do not enable
this flag. Keep the plugin on the default behavior and let OpenClaw load the
full entry during startup.
Example:
```json
{
"name": "@scope/my-channel",
"openclaw": {
"extensions": ["./index.ts"],
"setupEntry": "./setup-entry.ts",
"startup": {
"deferConfiguredChannelFullLoadUntilAfterListen": true
}
}
}
```
### Channel catalog metadata ### Channel catalog metadata
Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and
@ -1736,6 +1783,7 @@ Publishing contract:
- Plugin `package.json` must include `openclaw.extensions` with one or more entry files. - Plugin `package.json` must include `openclaw.extensions` with one or more entry files.
- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup. - Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup.
- Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` may opt a channel plugin into using `setupEntry` during pre-listen gateway startup, but only when that setup entry completely covers the plugin's startup-critical surface.
- Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - Entry files can be `.js` or `.ts` (jiti loads TS at runtime).
- `openclaw plugins install <npm-spec>` uses `npm pack`, extracts into `~/.openclaw/extensions/<id>/`, and enables it in config. - `openclaw plugins install <npm-spec>` uses `npm pack`, extracts into `~/.openclaw/extensions/<id>/`, and enables it in config.
- Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`.

View 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";

View File

@ -12,24 +12,18 @@ import {
type ChannelMessageActionName, type ChannelMessageActionName,
} from "openclaw/plugin-sdk/bluebubbles"; } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesAccount } from "./accounts.js"; 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 { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { normalizeSecretInputString } from "./secret-input.js"; import { normalizeSecretInputString } from "./secret-input.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.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"; const providerId = "bluebubbles";
function mapTarget(raw: string): BlueBubblesSendTarget { function mapTarget(raw: string): BlueBubblesSendTarget {
@ -99,6 +93,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({ action, params, cfg, accountId, toolContext }) => { handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const runtime = await loadBlueBubblesActionsRuntime();
const account = resolveBlueBubblesAccount({ const account = resolveBlueBubblesAccount({
cfg: cfg, cfg: cfg,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
@ -147,7 +142,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`); 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) { if (!resolved) {
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`); 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 // 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 partIndex = readNumberParam(params, "partIndex", { integer: true });
const resolvedChatGuid = await resolveChatGuid(); const resolvedChatGuid = await resolveChatGuid();
await sendBlueBubblesReaction({ await runtime.sendBlueBubblesReaction({
chatGuid: resolvedChatGuid, chatGuid: resolvedChatGuid,
messageGuid: messageId, messageGuid: messageId,
emoji, emoji,
@ -218,11 +215,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
); );
} }
// Resolve short ID (e.g., "1", "2") to full UUID // 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 partIndex = readNumberParam(params, "partIndex", { integer: true });
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
await editBlueBubblesMessage(messageId, newText, { await runtime.editBlueBubblesMessage(messageId, newText, {
...opts, ...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined, partIndex: typeof partIndex === "number" ? partIndex : undefined,
backwardsCompatMessage: backwardsCompatMessage ?? undefined, backwardsCompatMessage: backwardsCompatMessage ?? undefined,
@ -242,10 +241,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
); );
} }
// Resolve short ID (e.g., "1", "2") to full UUID // 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 partIndex = readNumberParam(params, "partIndex", { integer: true });
await unsendBlueBubblesMessage(messageId, { await runtime.unsendBlueBubblesMessage(messageId, {
...opts, ...opts,
partIndex: typeof partIndex === "number" ? partIndex : undefined, partIndex: typeof partIndex === "number" ? partIndex : undefined,
}); });
@ -276,10 +277,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
); );
} }
// Resolve short ID (e.g., "1", "2") to full UUID // 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 partIndex = readNumberParam(params, "partIndex", { integer: true });
const result = await sendMessageBlueBubbles(to, text, { const result = await runtime.sendMessageBlueBubbles(to, text, {
...opts, ...opts,
replyToMessageGuid: messageId, replyToMessageGuid: messageId,
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined, 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, ...opts,
effectId, effectId,
}); });
@ -330,7 +333,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
throw new Error("BlueBubbles renameGroup requires displayName or name parameter."); 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 }); return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
} }
@ -355,7 +358,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Decode base64 to buffer // Decode base64 to buffer
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, { await runtime.setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
...opts, ...opts,
contentType: contentType ?? undefined, contentType: contentType ?? undefined,
}); });
@ -372,7 +375,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
throw new Error("BlueBubbles addParticipant requires address or participant parameter."); 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 }); 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."); 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 }); return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
} }
@ -396,7 +399,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
assertPrivateApiEnabled(); assertPrivateApiEnabled();
const resolvedChatGuid = await resolveChatGuid(); const resolvedChatGuid = await resolveChatGuid();
await leaveBlueBubblesChat(resolvedChatGuid, opts); await runtime.leaveBlueBubblesChat(resolvedChatGuid, opts);
return jsonResult({ ok: true, left: resolvedChatGuid }); return jsonResult({ ok: true, left: resolvedChatGuid });
} }
@ -427,7 +430,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter."); throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
} }
const result = await sendBlueBubblesAttachment({ const result = await runtime.sendBlueBubblesAttachment({
to, to,
buffer, buffer,
filename, filename,

View 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";

View File

@ -25,12 +25,8 @@ import {
resolveDefaultBlueBubblesAccountId, resolveDefaultBlueBubblesAccountId,
} from "./accounts.js"; } from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js"; import { bluebubblesMessageActions } from "./actions.js";
import type { BlueBubblesProbe } from "./channel.runtime.js";
import { BlueBubblesConfigSchema } from "./config-schema.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 { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.js"; import { blueBubblesSetupWizard } from "./setup-surface.js";
import { import {
@ -41,6 +37,13 @@ import {
parseBlueBubblesTarget, parseBlueBubblesTarget,
} from "./targets.js"; } from "./targets.js";
let blueBubblesChannelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
function loadBlueBubblesChannelRuntime() {
blueBubblesChannelRuntimePromise ??= import("./channel.runtime.js");
return blueBubblesChannelRuntimePromise;
}
const meta = { const meta = {
id: "bluebubbles", id: "bluebubbles",
label: "BlueBubbles", label: "BlueBubbles",
@ -221,7 +224,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
idLabel: "bluebubblesSenderId", idLabel: "bluebubblesSenderId",
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => { notifyApproval: async ({ cfg, id }) => {
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { await (
await loadBlueBubblesChannelRuntime()
).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
cfg: cfg, cfg: cfg,
}); });
}, },
@ -240,12 +245,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
return { ok: true, to: trimmed }; return { ok: true, to: trimmed };
}, },
sendText: async ({ cfg, to, text, accountId, replyToId }) => { sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID // Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId 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, cfg: cfg,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined, replyToMessageGuid: replyToMessageGuid || undefined,
@ -253,6 +259,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
return { channel: "bluebubbles", ...result }; return { channel: "bluebubbles", ...result };
}, },
sendMedia: async (ctx) => { sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string; mediaPath?: string;
@ -262,7 +269,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
caption?: string; caption?: string;
}; };
const resolvedCaption = caption ?? text; const resolvedCaption = caption ?? text;
const result = await sendBlueBubblesMedia({ const result = await runtime.sendBlueBubblesMedia({
cfg: cfg, cfg: cfg,
to, to,
mediaUrl, mediaUrl,
@ -290,7 +297,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
buildChannelSummary: ({ snapshot }) => buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
probeAccount: async ({ account, timeoutMs }) => probeAccount: async ({ account, timeoutMs }) =>
probeBlueBubbles({ (await loadBlueBubblesChannelRuntime()).probeBlueBubbles({
baseUrl: account.baseUrl, baseUrl: account.baseUrl,
password: account.config.password ?? null, password: account.config.password ?? null,
timeoutMs, timeoutMs,
@ -315,8 +322,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}, },
gateway: { gateway: {
startAccount: async (ctx) => { startAccount: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const account = ctx.account; const account = ctx.account;
const webhookPath = resolveWebhookPathFromConfig(account.config); const webhookPath = runtime.resolveWebhookPathFromConfig(account.config);
const statusSink = createAccountStatusSink({ const statusSink = createAccountStatusSink({
accountId: ctx.accountId, accountId: ctx.accountId,
setStatus: ctx.setStatus, setStatus: ctx.setStatus,
@ -325,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
baseUrl: account.baseUrl, baseUrl: account.baseUrl,
}); });
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`); ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
return monitorBlueBubblesProvider({ return runtime.monitorBlueBubblesProvider({
account, account,
config: ctx.cfg, config: ctx.cfg,
runtime: ctx.runtime, runtime: ctx.runtime,

View File

@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { discordPlugin } from "./src/channel.js"; import { discordPlugin } from "./src/channel.js";
import { setDiscordRuntime } from "./src/runtime.js"; import { setDiscordRuntime } from "./src/runtime.js";
import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js"; import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; 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 { 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 type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { processDiscordMessage } from "./message-handler.process.js"; import { processDiscordMessage } from "./message-handler.process.js";
import { import {

View File

@ -21,7 +21,6 @@ import {
} from "../../../../src/acp/persistent-bindings.route.js"; } from "../../../../src/acp/persistent-bindings.route.js";
import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
import { resolveCommandAuthorization } from "../../../../src/auto-reply/command-auth.js";
import type { import type {
ChatCommandDefinition, ChatCommandDefinition,
CommandArgDefinition, CommandArgDefinition,
@ -61,8 +60,10 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js"; import { chunkDiscordTextWithMode } from "../chunk.js";
import { import {
isDiscordGroupAllowedByPolicy, isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug, normalizeDiscordSlug,
resolveDiscordChannelConfigWithFallback, resolveDiscordChannelConfigWithFallback,
resolveDiscordAllowListMatch,
resolveDiscordGuildEntry, resolveDiscordGuildEntry,
resolveDiscordMemberAccessState, resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess, resolveDiscordOwnerAccess,
@ -108,33 +109,26 @@ function resolveDiscordNativeCommandAllowlistAccess(params: {
if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") { if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") {
return { configured: false, allowed: false } as const; return { configured: false, allowed: false } as const;
} }
const configured = const rawAllowList = Array.isArray(commandsAllowFrom.discord)
Array.isArray(commandsAllowFrom.discord) || Array.isArray(commandsAllowFrom["*"]); ? commandsAllowFrom.discord
if (!configured) { : commandsAllowFrom["*"];
if (!Array.isArray(rawAllowList)) {
return { configured: false, allowed: false } as const; return { configured: false, allowed: false } as const;
} }
const allowList = normalizeDiscordAllowList(rawAllowList.map(String), [
const from = "discord:",
params.chatType === "direct" "user:",
? `discord:${params.sender.id}` "pk:",
: `discord:${params.chatType}:${params.conversationId ?? "unknown"}`; ]);
const auth = resolveCommandAuthorization({ if (!allowList) {
ctx: { return { configured: true, allowed: false } as const;
Provider: "discord", }
Surface: "discord", const match = resolveDiscordAllowListMatch({
OriginatingChannel: "discord", allowList,
AccountId: params.accountId ?? undefined, candidate: params.sender,
ChatType: params.chatType, allowNameMatching: false,
From: from,
SenderId: params.sender.id,
SenderUsername: params.sender.name,
SenderTag: params.sender.tag,
},
cfg: params.cfg,
// We only want explicit commands.allowFrom authorization here.
commandAuthorized: false,
}); });
return { configured: true, allowed: auth.isAuthorizedSender } as const; return { configured: true, allowed: match.allowed } as const;
} }
function buildDiscordCommandOptions(params: { function buildDiscordCommandOptions(params: {

View File

@ -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);
});
});

View File

@ -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,
});
});

View File

@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized"); createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");

View File

@ -35,8 +35,10 @@ const hookMocks = vi.hoisted(() => ({
unbindThreadBindingsBySessionKey: vi.fn(() => []), unbindThreadBindingsBySessionKey: vi.fn(() => []),
})); }));
vi.mock("openclaw/plugin-sdk/discord", () => ({ vi.mock("./accounts.js", () => ({
resolveDiscordAccount: hookMocks.resolveDiscordAccount, resolveDiscordAccount: hookMocks.resolveDiscordAccount,
}));
vi.mock("./monitor/thread-bindings.js", () => ({
autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent, autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent,
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey, listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,

View File

@ -1,4 +1,4 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { resolveDiscordAccount } from "./accounts.js"; import { resolveDiscordAccount } from "./accounts.js";
import { import {
autoBindSpawnedDiscordSubagent, autoBindSpawnedDiscordSubagent,

View File

@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { imessagePlugin } from "./src/channel.js"; import { imessagePlugin } from "./src/channel.js";
import { setIMessageRuntime } from "./src/runtime.js"; import { setIMessageRuntime } from "./src/runtime.js";

View File

@ -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",
});
});

View File

@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized"); createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");

View File

@ -1,10 +1,5 @@
import { import {
buildOllamaProvider,
emptyPluginConfigSchema, emptyPluginConfigSchema,
ensureOllamaModelPulled,
OLLAMA_DEFAULT_BASE_URL,
promptAndConfigureOllama,
configureOllamaNonInteractive,
type OpenClawPluginApi, type OpenClawPluginApi,
type ProviderAuthContext, type ProviderAuthContext,
type ProviderAuthMethodNonInteractiveContext, type ProviderAuthMethodNonInteractiveContext,
@ -12,10 +7,15 @@ import {
type ProviderDiscoveryContext, type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/core"; } from "openclaw/plugin-sdk/core";
import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js"; 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 PROVIDER_ID = "ollama";
const DEFAULT_API_KEY = "ollama-local"; const DEFAULT_API_KEY = "ollama-local";
async function loadProviderSetup() {
return await import("openclaw/plugin-sdk/ollama-setup");
}
const ollamaPlugin = { const ollamaPlugin = {
id: "ollama", id: "ollama",
name: "Ollama Provider", name: "Ollama Provider",
@ -34,7 +34,8 @@ const ollamaPlugin = {
hint: "Cloud and local open models", hint: "Cloud and local open models",
kind: "custom", kind: "custom",
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => { run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
const result = await promptAndConfigureOllama({ const providerSetup = await loadProviderSetup();
const result = await providerSetup.promptAndConfigureOllama({
cfg: ctx.config, cfg: ctx.config,
prompter: ctx.prompter, prompter: ctx.prompter,
}); });
@ -53,12 +54,14 @@ const ollamaPlugin = {
defaultModel: `ollama/${result.defaultModelId}`, defaultModel: `ollama/${result.defaultModelId}`,
}; };
}, },
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
configureOllamaNonInteractive({ const providerSetup = await loadProviderSetup();
return await providerSetup.configureOllamaNonInteractive({
nextConfig: ctx.config, nextConfig: ctx.config,
opts: ctx.opts, opts: ctx.opts,
runtime: ctx.runtime, runtime: ctx.runtime,
}), });
},
}, },
], ],
discovery: { 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, quiet: !ollamaKey && !explicit,
}); });
if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) { if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
@ -115,7 +119,8 @@ const ollamaPlugin = {
if (!model.startsWith("ollama/")) { if (!model.startsWith("ollama/")) {
return; return;
} }
await ensureOllamaModelPulled({ config, prompter }); const providerSetup = await loadProviderSetup();
await providerSetup.ensureOllamaModelPulled({ config, prompter });
}, },
}); });
}, },

View File

@ -10,8 +10,8 @@ import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"
import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js"; import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js";
import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { normalizeProviderId } from "../../src/agents/model-selection.js";
import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js";
import { normalizeProviderId } from "../../src/agents/provider-id.js";
import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js"; import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js";
import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js";
import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js";

View File

@ -3,7 +3,7 @@ import {
type ProviderRuntimeModel, type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core"; } from "openclaw/plugin-sdk/core";
import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { normalizeProviderId } from "../../src/agents/provider-id.js";
import { import {
applyOpenAIConfig, applyOpenAIConfig,
OPENAI_DEFAULT_MODEL, OPENAI_DEFAULT_MODEL,

View File

@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { registerSandboxBackend } from "openclaw/plugin-sdk/core"; import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import { import {
createOpenShellSandboxBackendFactory, createOpenShellSandboxBackendFactory,
createOpenShellSandboxBackendManager, createOpenShellSandboxBackendManager,

View File

@ -11,13 +11,13 @@ import type {
SandboxBackendHandle, SandboxBackendHandle,
SandboxBackendManager, SandboxBackendManager,
SshSandboxSession, SshSandboxSession,
} from "openclaw/plugin-sdk/core"; } from "openclaw/plugin-sdk/sandbox";
import { import {
createRemoteShellSandboxFsBridge, createRemoteShellSandboxFsBridge,
disposeSshSandboxSession, disposeSshSandboxSession,
resolvePreferredOpenClawTmpDir, resolvePreferredOpenClawTmpDir,
runSshSandboxCommand, runSshSandboxCommand,
} from "openclaw/plugin-sdk/core"; } from "openclaw/plugin-sdk/sandbox";
import { import {
buildExecRemoteCommand, buildExecRemoteCommand,
buildRemoteCommand, buildRemoteCommand,

View File

@ -4,10 +4,10 @@ import {
runPluginCommandWithTimeout, runPluginCommandWithTimeout,
shellEscape, shellEscape,
type SshSandboxSession, type SshSandboxSession,
} from "openclaw/plugin-sdk/core"; } from "openclaw/plugin-sdk/sandbox";
import type { ResolvedOpenShellPluginConfig } from "./config.js"; 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 = { export type OpenShellExecContext = {
config: ResolvedOpenShellPluginConfig; config: ResolvedOpenShellPluginConfig;

View File

@ -5,7 +5,7 @@ import type {
SandboxFsBridge, SandboxFsBridge,
SandboxFsStat, SandboxFsStat,
SandboxResolvedPath, SandboxResolvedPath,
} from "openclaw/plugin-sdk/core"; } from "openclaw/plugin-sdk/sandbox";
import type { OpenShellSandboxBackend } from "./backend.js"; import type { OpenShellSandboxBackend } from "./backend.js";
import { movePathWithCopyFallback } from "./mirror.js"; import { movePathWithCopyFallback } from "./mirror.js";

View File

@ -3,7 +3,7 @@ import {
type RemoteShellSandboxHandle, type RemoteShellSandboxHandle,
type SandboxContext, type SandboxContext,
type SandboxFsBridge, type SandboxFsBridge,
} from "openclaw/plugin-sdk/core"; } from "openclaw/plugin-sdk/sandbox";
export function createOpenShellRemoteFsBridge(params: { export function createOpenShellRemoteFsBridge(params: {
sandbox: SandboxContext; sandbox: SandboxContext;

View File

@ -1,15 +1,20 @@
import { import {
buildSglangProvider,
configureOpenAICompatibleSelfHostedProviderNonInteractive,
discoverOpenAICompatibleSelfHostedProvider,
emptyPluginConfigSchema, emptyPluginConfigSchema,
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
type OpenClawPluginApi, type OpenClawPluginApi,
type ProviderAuthMethodNonInteractiveContext, type ProviderAuthMethodNonInteractiveContext,
} from "openclaw/plugin-sdk/core"; } 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 PROVIDER_ID = "sglang";
const DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1";
async function loadProviderSetup() {
return await import("openclaw/plugin-sdk/self-hosted-provider-setup");
}
const sglangPlugin = { const sglangPlugin = {
id: "sglang", id: "sglang",
@ -25,38 +30,44 @@ const sglangPlugin = {
auth: [ auth: [
{ {
id: "custom", id: "custom",
label: "SGLang", label: SGLANG_PROVIDER_LABEL,
hint: "Fast self-hosted OpenAI-compatible server", hint: "Fast self-hosted OpenAI-compatible server",
kind: "custom", kind: "custom",
run: async (ctx) => run: async (ctx) => {
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ const providerSetup = await loadProviderSetup();
return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
cfg: ctx.config, cfg: ctx.config,
prompter: ctx.prompter, prompter: ctx.prompter,
providerId: PROVIDER_ID, providerId: PROVIDER_ID,
providerLabel: "SGLang", providerLabel: SGLANG_PROVIDER_LABEL,
defaultBaseUrl: DEFAULT_BASE_URL, defaultBaseUrl: SGLANG_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "SGLANG_API_KEY", defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: "Qwen/Qwen3-8B", modelPlaceholder: SGLANG_MODEL_PLACEHOLDER,
}), });
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => },
configureOpenAICompatibleSelfHostedProviderNonInteractive({ runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx, ctx,
providerId: PROVIDER_ID, providerId: PROVIDER_ID,
providerLabel: "SGLang", providerLabel: SGLANG_PROVIDER_LABEL,
defaultBaseUrl: DEFAULT_BASE_URL, defaultBaseUrl: SGLANG_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "SGLANG_API_KEY", defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: "Qwen/Qwen3-8B", modelPlaceholder: SGLANG_MODEL_PLACEHOLDER,
}), });
},
}, },
], ],
discovery: { discovery: {
order: "late", order: "late",
run: async (ctx) => run: async (ctx) => {
discoverOpenAICompatibleSelfHostedProvider({ const providerSetup = await loadProviderSetup();
return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({
ctx, ctx,
providerId: PROVIDER_ID, providerId: PROVIDER_ID,
buildProvider: buildSglangProvider, buildProvider: providerSetup.buildSglangProvider,
}), });
},
}, },
wizard: { wizard: {
setup: { setup: {

View File

@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { signalPlugin } from "./src/channel.js"; import { signalPlugin } from "./src/channel.js";
import { setSignalRuntime } from "./src/runtime.js"; import { setSignalRuntime } from "./src/runtime.js";

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../../../../src/auto-reply/templating.js"; 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 { createSignalEventHandler } from "./event-handler.js";
import { import {
createBaseSignalEventHandlerDeps, createBaseSignalEventHandlerDeps,

View File

@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized"); createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");

View File

@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { slackPlugin } from "./src/channel.js"; import { slackPlugin } from "./src/channel.js";
import { setSlackRuntime } from "./src/runtime.js"; import { setSlackRuntime } from "./src/runtime.js";

View File

@ -1,5 +1,5 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/core";
import { parseSlackBlocksInput } from "./blocks-input.js"; import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks } from "./blocks-render.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js";

View File

@ -3,10 +3,10 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { App } from "@slack/bolt"; import type { App } from "@slack/bolt";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; 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 type { OpenClawConfig } from "../../../../../src/config/config.js";
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.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 { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js"; import type { SlackMessageEvent } from "../../types.js";
import type { SlackMonitorContext } from "../context.js"; import type { SlackMonitorContext } from "../context.js";

View File

@ -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",
});
});

View File

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./provider.js";
describe("resolveSlackBoltInterop", () => {
class FakeApp {}
class FakeHTTPReceiver {}
it("uses the default import when it already exposes named exports", () => {
const resolved = __testing.resolveSlackBoltInterop({
defaultImport: {
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
},
namespaceImport: {},
});
expect(resolved).toEqual({
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
});
});
it("uses nested default export when the default import is a wrapper object", () => {
const resolved = __testing.resolveSlackBoltInterop({
defaultImport: {
default: {
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
},
},
namespaceImport: {},
});
expect(resolved).toEqual({
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
});
});
it("uses the namespace receiver when the default import is the App constructor itself", () => {
const resolved = __testing.resolveSlackBoltInterop({
defaultImport: FakeApp,
namespaceImport: {
HTTPReceiver: FakeHTTPReceiver,
},
});
expect(resolved).toEqual({
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
});
});
it("uses namespace.default when it exposes named exports", () => {
const resolved = __testing.resolveSlackBoltInterop({
defaultImport: undefined,
namespaceImport: {
default: {
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
},
},
});
expect(resolved).toEqual({
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
});
});
it("falls back to the namespace import when it exposes named exports", () => {
const resolved = __testing.resolveSlackBoltInterop({
defaultImport: undefined,
namespaceImport: {
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
},
});
expect(resolved).toEqual({
App: FakeApp,
HTTPReceiver: FakeHTTPReceiver,
});
});
it("throws when the module cannot be resolved", () => {
expect(() =>
__testing.resolveSlackBoltInterop({
defaultImport: null,
namespaceImport: {},
}),
).toThrow("Unable to resolve @slack/bolt App/HTTPReceiver exports");
});
});

View File

@ -1,5 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import SlackBolt from "@slack/bolt"; import SlackBolt, * as SlackBoltNamespace from "@slack/bolt";
import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js";
import { import {
@ -46,14 +46,77 @@ import {
import { registerSlackMonitorSlashCommands } from "./slash.js"; import { registerSlackMonitorSlashCommands } from "./slash.js";
import type { MonitorSlackOpts } from "./types.js"; import type { MonitorSlackOpts } from "./types.js";
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & { type SlackAppConstructor = typeof import("@slack/bolt").App;
default?: typeof import("@slack/bolt"); type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver;
type SlackBoltResolvedExports = {
App: SlackAppConstructor;
HTTPReceiver: SlackHttpReceiverConstructor;
}; };
// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility. type Constructor = abstract new (...args: never[]) => unknown;
// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue)
const slackBolt = function isConstructorFunction<T extends Constructor>(value: unknown): value is T {
(slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule; return typeof value === "function";
const { App, HTTPReceiver } = slackBolt; }
function resolveSlackBoltModule(value: unknown): SlackBoltResolvedExports | null {
if (!value || typeof value !== "object") {
return null;
}
const app = Reflect.get(value, "App");
const httpReceiver = Reflect.get(value, "HTTPReceiver");
if (
!isConstructorFunction<SlackAppConstructor>(app) ||
!isConstructorFunction<SlackHttpReceiverConstructor>(httpReceiver)
) {
return null;
}
return {
App: app,
HTTPReceiver: httpReceiver,
};
}
function resolveSlackBoltInterop(params: {
defaultImport: unknown;
namespaceImport: unknown;
}): SlackBoltResolvedExports {
const { defaultImport, namespaceImport } = params;
const nestedDefault =
defaultImport && typeof defaultImport === "object"
? Reflect.get(defaultImport, "default")
: undefined;
const namespaceDefault =
namespaceImport && typeof namespaceImport === "object"
? Reflect.get(namespaceImport, "default")
: undefined;
const namespaceReceiver =
namespaceImport && typeof namespaceImport === "object"
? Reflect.get(namespaceImport, "HTTPReceiver")
: undefined;
const directModule =
resolveSlackBoltModule(defaultImport) ??
resolveSlackBoltModule(nestedDefault) ??
resolveSlackBoltModule(namespaceDefault) ??
resolveSlackBoltModule(namespaceImport);
if (directModule) {
return directModule;
}
if (
isConstructorFunction<SlackAppConstructor>(defaultImport) &&
isConstructorFunction<SlackHttpReceiverConstructor>(namespaceReceiver)
) {
return {
App: defaultImport,
HTTPReceiver: namespaceReceiver,
};
}
throw new TypeError("Unable to resolve @slack/bolt App/HTTPReceiver exports");
}
const { App, HTTPReceiver } = resolveSlackBoltInterop({
defaultImport: SlackBolt,
namespaceImport: SlackBoltNamespace,
});
const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000; const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
@ -515,6 +578,7 @@ export const __testing = {
publishSlackDisconnectedStatus, publishSlackDisconnectedStatus,
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
resolveSlackBoltInterop,
getSocketEmitter, getSocketEmitter,
waitForSlackSocketDisconnect, waitForSlackSocketDisconnect,
}; };

View File

@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized"); createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized");

View File

@ -5,14 +5,6 @@ vi.mock("./webhook-handler.js", () => ({
createWebhookHandler: vi.fn(() => vi.fn()), createWebhookHandler: vi.fn(() => vi.fn()),
})); }));
vi.mock("zod", () => ({
z: {
object: vi.fn(() => ({
passthrough: vi.fn(() => ({ _type: "zod-schema" })),
})),
},
}));
const { createSynologyChatPlugin } = await import("./channel.js"); const { createSynologyChatPlugin } = await import("./channel.js");
describe("createSynologyChatPlugin", () => { describe("createSynologyChatPlugin", () => {

View File

@ -1,5 +1,5 @@
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { telegramPlugin } from "./src/channel.js"; import { telegramPlugin } from "./src/channel.js";
import { setTelegramRuntime } from "./src/runtime.js"; import { setTelegramRuntime } from "./src/runtime.js";

View File

@ -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 () => { it("keeps block streaming enabled when session reasoning level is on", async () => {
loadSessionStore.mockReturnValue({ loadSessionStore.mockReturnValue({
s1: { reasoningLevel: "on" }, s1: { reasoningLevel: "on" },

View File

@ -465,6 +465,7 @@ export const dispatchTelegramMessage = async ({
linkPreview: telegramCfg.linkPreview, linkPreview: telegramCfg.linkPreview,
replyQuoteText, replyQuoteText,
}; };
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => { const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
if (payload.text === text) { if (payload.text === text) {
return payload; return payload;
@ -476,6 +477,7 @@ export const dispatchTelegramMessage = async ({
...deliveryBaseOptions, ...deliveryBaseOptions,
replies: [payload], replies: [payload],
onVoiceRecording: sendRecordVoice, onVoiceRecording: sendRecordVoice,
silent: silentErrorReplies && payload.isError === true,
}); });
if (result.delivered) { if (result.delivered) {
deliveryState.markDelivered(); deliveryState.markDelivered();
@ -513,6 +515,7 @@ export const dispatchTelegramMessage = async ({
}); });
let queuedFinal = false; let queuedFinal = false;
let hadErrorReplyFailureOrSkip = false;
if (statusReactionController) { if (statusReactionController) {
void statusReactionController.setThinking(); void statusReactionController.setThinking();
@ -539,6 +542,9 @@ export const dispatchTelegramMessage = async ({
...prefixOptions, ...prefixOptions,
typingCallbacks, typingCallbacks,
deliver: async (payload, info) => { deliver: async (payload, info) => {
if (payload.isError === true) {
hadErrorReplyFailureOrSkip = true;
}
if (info.kind === "final") { if (info.kind === "final") {
// Assistant callbacks are fire-and-forget; ensure queued boundary // Assistant callbacks are fire-and-forget; ensure queued boundary
// rotations/partials are applied before final delivery mapping. // rotations/partials are applied before final delivery mapping.
@ -652,7 +658,10 @@ export const dispatchTelegramMessage = async ({
await flushBufferedFinalAnswer(); await flushBufferedFinalAnswer();
} }
}, },
onSkip: (_payload, info) => { onSkip: (payload, info) => {
if (payload.isError === true) {
hadErrorReplyFailureOrSkip = true;
}
if (info.reason !== "silent") { if (info.reason !== "silent") {
deliveryState.markNonSilentSkip(); deliveryState.markNonSilentSkip();
} }
@ -809,6 +818,7 @@ export const dispatchTelegramMessage = async ({
const result = await deliverReplies({ const result = await deliverReplies({
replies: [{ text: fallbackText }], replies: [{ text: fallbackText }],
...deliveryBaseOptions, ...deliveryBaseOptions,
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
}); });
sentFallback = result.delivered; sentFallback = result.delivered;
} }

View File

@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
allowFrom?: string[]; allowFrom?: string[];
groupAllowFrom?: string[]; groupAllowFrom?: string[];
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): { }): {
handler: TelegramCommandHandler; handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>; sendMessage: ReturnType<typeof vi.fn>;
} { } {
const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params; const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params;
return registerAndResolveCommandHandlerBase({ return registerAndResolveCommandHandlerBase({
commandName: "status", commandName: "status",
cfg, cfg,
allowFrom: allowFrom ?? ["*"], allowFrom: allowFrom ?? ["*"],
groupAllowFrom: groupAllowFrom ?? [], groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: true, useAccessGroups: true,
telegramCfg,
resolveTelegramGroupConfig, resolveTelegramGroupConfig,
}); });
} }
@ -209,6 +211,7 @@ function registerAndResolveCommandHandlerBase(params: {
allowFrom: string[]; allowFrom: string[];
groupAllowFrom: string[]; groupAllowFrom: string[];
useAccessGroups: boolean; useAccessGroups: boolean;
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): { }): {
handler: TelegramCommandHandler; handler: TelegramCommandHandler;
@ -220,6 +223,7 @@ function registerAndResolveCommandHandlerBase(params: {
allowFrom, allowFrom,
groupAllowFrom, groupAllowFrom,
useAccessGroups, useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig, resolveTelegramGroupConfig,
} = params; } = params;
const commandHandlers = new Map<string, TelegramCommandHandler>(); const commandHandlers = new Map<string, TelegramCommandHandler>();
@ -239,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: {
allowFrom, allowFrom,
groupAllowFrom, groupAllowFrom,
useAccessGroups, useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig, resolveTelegramGroupConfig,
}), }),
}); });
@ -254,6 +259,7 @@ function registerAndResolveCommandHandler(params: {
allowFrom?: string[]; allowFrom?: string[];
groupAllowFrom?: string[]; groupAllowFrom?: string[];
useAccessGroups?: boolean; useAccessGroups?: boolean;
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): { }): {
handler: TelegramCommandHandler; handler: TelegramCommandHandler;
@ -265,6 +271,7 @@ function registerAndResolveCommandHandler(params: {
allowFrom, allowFrom,
groupAllowFrom, groupAllowFrom,
useAccessGroups, useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig, resolveTelegramGroupConfig,
} = params; } = params;
return registerAndResolveCommandHandlerBase({ return registerAndResolveCommandHandlerBase({
@ -273,6 +280,7 @@ function registerAndResolveCommandHandler(params: {
allowFrom: allowFrom ?? [], allowFrom: allowFrom ?? [],
groupAllowFrom: groupAllowFrom ?? [], groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: useAccessGroups ?? true, useAccessGroups: useAccessGroups ?? true,
telegramCfg,
resolveTelegramGroupConfig, resolveTelegramGroupConfig,
}); });
} }
@ -443,6 +451,31 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); 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 () => { it("routes Telegram native commands through configured ACP topic bindings", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(

View File

@ -12,6 +12,13 @@ type GetPluginCommandSpecsFn =
type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand; type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand;
type ExecutePluginCommandFn = type ExecutePluginCommandFn =
typeof import("../../../src/plugins/commands.js").executePluginCommand; typeof import("../../../src/plugins/commands.js").executePluginCommand;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type RecordInboundSessionMetaSafeFn =
typeof import("../../../src/channels/session-meta.js").recordInboundSessionMetaSafe;
type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
type NativeCommandHarness = { type NativeCommandHarness = {
@ -43,6 +50,37 @@ vi.mock("../../../src/plugins/commands.js", () => ({
executePluginCommand: pluginCommandMocks.executePluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand,
})); }));
const replyPipelineMocks = vi.hoisted(() => {
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
queuedFinal: false,
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
};
return {
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
async () => dispatchReplyResult,
),
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
recordInboundSessionMetaSafe: vi.fn<RecordInboundSessionMetaSafeFn>(async () => undefined),
};
});
export const dispatchReplyWithBufferedBlockDispatcher =
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher;
vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({
finalizeInboundContext: replyPipelineMocks.finalizeInboundContext,
}));
vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher:
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher,
}));
vi.mock("../../../src/channels/reply-prefix.js", () => ({
createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions,
}));
vi.mock("../../../src/channels/session-meta.js", () => ({
recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe,
}));
const deliveryMocks = vi.hoisted(() => ({ const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => {}), deliverReplies: vi.fn(async () => {}),
})); }));

View File

@ -290,4 +290,56 @@ describe("registerTelegramNativeCommands", () => {
); );
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); 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 })],
}),
);
});
}); });

View File

@ -363,6 +363,7 @@ export const registerTelegramNativeCommands = ({
shouldSkipUpdate, shouldSkipUpdate,
opts, opts,
}: RegisterTelegramNativeCommandsParams) => { }: RegisterTelegramNativeCommandsParams) => {
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
const boundRoute = const boundRoute =
nativeEnabled && nativeSkillsEnabled nativeEnabled && nativeSkillsEnabled
? resolveAgentRoute({ cfg, channel: "telegram", accountId }) ? resolveAgentRoute({ cfg, channel: "telegram", accountId })
@ -734,7 +735,6 @@ export const registerTelegramNativeCommands = ({
typeof telegramCfg.blockStreaming === "boolean" typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming ? !telegramCfg.blockStreaming
: undefined; : undefined;
const deliveryState = { const deliveryState = {
delivered: false, delivered: false,
skippedNonSilent: 0, skippedNonSilent: 0,
@ -766,6 +766,7 @@ export const registerTelegramNativeCommands = ({
const result = await deliverReplies({ const result = await deliverReplies({
replies: [payload], replies: [payload],
...deliveryBaseOptions, ...deliveryBaseOptions,
silent: silentErrorReplies && payload.isError === true,
}); });
if (result.delivered) { if (result.delivered) {
deliveryState.delivered = true; deliveryState.delivered = true;
@ -885,6 +886,7 @@ export const registerTelegramNativeCommands = ({
await deliverReplies({ await deliverReplies({
replies: [result], replies: [result],
...deliveryBaseOptions, ...deliveryBaseOptions,
silent: silentErrorReplies && result.isError === true,
}); });
} }
}); });

View File

@ -1,12 +1,12 @@
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../src/channels/plugins/contracts/suites.js";
import { import {
clearPluginInteractiveHandlers, clearPluginInteractiveHandlers,
registerPluginInteractiveHandler, registerPluginInteractiveHandler,
} from "../../../src/plugins/interactive.js"; } from "../../../src/plugins/interactive.js";
import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js"; import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
import { import {
answerCallbackQuerySpy, answerCallbackQuerySpy,
commandSpy, commandSpy,

View File

@ -103,6 +103,7 @@ async function deliverTextReply(params: {
replyMarkup?: ReturnType<typeof buildInlineKeyboard>; replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string; replyQuoteText?: string;
linkPreview?: boolean; linkPreview?: boolean;
silent?: boolean;
replyToId?: number; replyToId?: number;
replyToMode: ReplyToMode; replyToMode: ReplyToMode;
progress: DeliveryProgress; progress: DeliveryProgress;
@ -129,6 +130,7 @@ async function deliverTextReply(params: {
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview: params.linkPreview, linkPreview: params.linkPreview,
silent: params.silent,
replyMarkup, replyMarkup,
}, },
); );
@ -149,6 +151,7 @@ async function sendPendingFollowUpText(params: {
text: string; text: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>; replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
linkPreview?: boolean; linkPreview?: boolean;
silent?: boolean;
replyToId?: number; replyToId?: number;
replyToMode: ReplyToMode; replyToMode: ReplyToMode;
progress: DeliveryProgress; progress: DeliveryProgress;
@ -167,6 +170,7 @@ async function sendPendingFollowUpText(params: {
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview: params.linkPreview, linkPreview: params.linkPreview,
silent: params.silent,
replyMarkup, replyMarkup,
}); });
}, },
@ -196,6 +200,7 @@ async function sendTelegramVoiceFallbackText(opts: {
replyToId?: number; replyToId?: number;
thread?: TelegramThreadSpec | null; thread?: TelegramThreadSpec | null;
linkPreview?: boolean; linkPreview?: boolean;
silent?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>; replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string; replyQuoteText?: string;
}): Promise<number | undefined> { }): Promise<number | undefined> {
@ -213,6 +218,7 @@ async function sendTelegramVoiceFallbackText(opts: {
textMode: "html", textMode: "html",
plainText: chunk.text, plainText: chunk.text,
linkPreview: opts.linkPreview, linkPreview: opts.linkPreview,
silent: opts.silent,
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined, replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
}); });
if (firstDeliveredMessageId == null) { if (firstDeliveredMessageId == null) {
@ -237,6 +243,7 @@ async function deliverMediaReply(params: {
chunkText: ChunkTextFn; chunkText: ChunkTextFn;
onVoiceRecording?: () => Promise<void> | void; onVoiceRecording?: () => Promise<void> | void;
linkPreview?: boolean; linkPreview?: boolean;
silent?: boolean;
replyQuoteText?: string; replyQuoteText?: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>; replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyToId?: number; replyToId?: number;
@ -282,6 +289,7 @@ async function deliverMediaReply(params: {
...buildTelegramSendParams({ ...buildTelegramSendParams({
replyToMessageId, replyToMessageId,
thread: params.thread, thread: params.thread,
silent: params.silent,
}), }),
}; };
if (isGif) { if (isGif) {
@ -375,6 +383,7 @@ async function deliverMediaReply(params: {
replyToId: voiceFallbackReplyTo, replyToId: voiceFallbackReplyTo,
thread: params.thread, thread: params.thread,
linkPreview: params.linkPreview, linkPreview: params.linkPreview,
silent: params.silent,
replyMarkup: params.replyMarkup, replyMarkup: params.replyMarkup,
replyQuoteText: params.replyQuoteText, replyQuoteText: params.replyQuoteText,
}); });
@ -404,6 +413,7 @@ async function deliverMediaReply(params: {
replyToId: undefined, replyToId: undefined,
thread: params.thread, thread: params.thread,
linkPreview: params.linkPreview, linkPreview: params.linkPreview,
silent: params.silent,
replyMarkup: params.replyMarkup, replyMarkup: params.replyMarkup,
}); });
} }
@ -451,6 +461,7 @@ async function deliverMediaReply(params: {
text: pendingFollowUpText, text: pendingFollowUpText,
replyMarkup: params.replyMarkup, replyMarkup: params.replyMarkup,
linkPreview: params.linkPreview, linkPreview: params.linkPreview,
silent: params.silent,
replyToId: params.replyToId, replyToId: params.replyToId,
replyToMode: params.replyToMode, replyToMode: params.replyToMode,
progress: params.progress, progress: params.progress,
@ -557,6 +568,8 @@ export async function deliverReplies(params: {
onVoiceRecording?: () => Promise<void> | void; onVoiceRecording?: () => Promise<void> | void;
/** Controls whether link previews are shown. Default: true (previews enabled). */ /** Controls whether link previews are shown. Default: true (previews enabled). */
linkPreview?: boolean; linkPreview?: boolean;
/** When true, messages are sent with disable_notification. */
silent?: boolean;
/** Optional quote text for Telegram reply_parameters. */ /** Optional quote text for Telegram reply_parameters. */
replyQuoteText?: string; replyQuoteText?: string;
}): Promise<{ delivered: boolean }> { }): Promise<{ delivered: boolean }> {
@ -637,6 +650,7 @@ export async function deliverReplies(params: {
replyMarkup, replyMarkup,
replyQuoteText: params.replyQuoteText, replyQuoteText: params.replyQuoteText,
linkPreview: params.linkPreview, linkPreview: params.linkPreview,
silent: params.silent,
replyToId, replyToId,
replyToMode: params.replyToMode, replyToMode: params.replyToMode,
progress, progress,
@ -654,6 +668,7 @@ export async function deliverReplies(params: {
chunkText, chunkText,
onVoiceRecording: params.onVoiceRecording, onVoiceRecording: params.onVoiceRecording,
linkPreview: params.linkPreview, linkPreview: params.linkPreview,
silent: params.silent,
replyQuoteText: params.replyQuoteText, replyQuoteText: params.replyQuoteText,
replyMarkup, replyMarkup,
replyToId, replyToId,

View File

@ -76,6 +76,7 @@ export async function sendTelegramWithThreadFallback<T>(params: {
export function buildTelegramSendParams(opts?: { export function buildTelegramSendParams(opts?: {
replyToMessageId?: number; replyToMessageId?: number;
thread?: TelegramThreadSpec | null; thread?: TelegramThreadSpec | null;
silent?: boolean;
}): Record<string, unknown> { }): Record<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.thread); const threadParams = buildTelegramThreadParams(opts?.thread);
const params: Record<string, unknown> = {}; const params: Record<string, unknown> = {};
@ -85,6 +86,9 @@ export function buildTelegramSendParams(opts?: {
if (threadParams) { if (threadParams) {
params.message_thread_id = threadParams.message_thread_id; params.message_thread_id = threadParams.message_thread_id;
} }
if (opts?.silent === true) {
params.disable_notification = true;
}
return params; return params;
} }
@ -100,12 +104,14 @@ export async function sendTelegramText(
textMode?: "markdown" | "html"; textMode?: "markdown" | "html";
plainText?: string; plainText?: string;
linkPreview?: boolean; linkPreview?: boolean;
silent?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>; replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
}, },
): Promise<number> { ): Promise<number> {
const baseParams = buildTelegramSendParams({ const baseParams = buildTelegramSendParams({
replyToMessageId: opts?.replyToMessageId, replyToMessageId: opts?.replyToMessageId,
thread: opts?.thread, thread: opts?.thread,
silent: opts?.silent,
}); });
// Add link_preview_options when link preview is disabled. // Add link_preview_options when link preview is disabled.
const linkPreviewEnabled = opts?.linkPreview ?? true; const linkPreviewEnabled = opts?.linkPreview ?? true;

View File

@ -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 () => { it("emits internal message:sent when session hook context is available", async () => {
const runtime = createRuntime(false); const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } }); 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 () => { it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
voiceError: createVoiceMessagesForbiddenError(), voiceError: createVoiceMessagesForbiddenError(),

View File

@ -4,10 +4,13 @@ import type {
OpenClawConfig, OpenClawConfig,
PluginRuntime, PluginRuntime,
} from "openclaw/plugin-sdk/telegram"; } from "openclaw/plugin-sdk/telegram";
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import type { ResolvedTelegramAccount } from "./accounts.js"; import type { ResolvedTelegramAccount } from "./accounts.js";
import * as auditModule from "./audit.js";
import { telegramPlugin } from "./channel.js"; import { telegramPlugin } from "./channel.js";
import * as monitorModule from "./monitor.js";
import * as probeModule from "./probe.js";
import { setTelegramRuntime } from "./runtime.js"; import { setTelegramRuntime } from "./runtime.js";
function createCfg(): OpenClawConfig { function createCfg(): OpenClawConfig {
@ -53,32 +56,34 @@ function createStartAccountCtx(params: {
} }
function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) { function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) {
const monitorTelegramProvider = vi.fn(async () => undefined); const monitorTelegramProvider = vi
const probeTelegram = vi.fn(async () => .spyOn(monitorModule, "monitorTelegramProvider")
params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false }, .mockImplementation(async () => undefined);
); const probeTelegram = vi
const collectUnmentionedGroupIds = vi.fn(() => ({ .spyOn(probeModule, "probeTelegram")
groupIds: [] as string[], .mockImplementation(async () =>
unresolvedGroups: 0, params?.probeOk
hasWildcardUnmentionedGroups: false, ? { ok: true, bot: { username: params.botUsername ?? "bot" }, elapsedMs: 0 }
})); : { ok: false, elapsedMs: 0 },
const auditGroupMembership = vi.fn(async () => ({ );
ok: true, const collectUnmentionedGroupIds = vi
checkedGroups: 0, .spyOn(auditModule, "collectTelegramUnmentionedGroupIds")
unresolvedGroups: 0, .mockImplementation(() => ({
hasWildcardUnmentionedGroups: false, groupIds: [] as string[],
groups: [], unresolvedGroups: 0,
elapsedMs: 0, hasWildcardUnmentionedGroups: false,
})); }));
const auditGroupMembership = vi
.spyOn(auditModule, "auditTelegramGroupMembership")
.mockImplementation(async () => ({
ok: true,
checkedGroups: 0,
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
groups: [],
elapsedMs: 0,
}));
setTelegramRuntime({ setTelegramRuntime({
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
collectUnmentionedGroupIds,
auditGroupMembership,
},
},
logging: { logging: {
shouldLogVerbose: () => false, shouldLogVerbose: () => false,
}, },
@ -115,6 +120,10 @@ function installSendMessageRuntime(
return sendMessageTelegram; return sendMessageTelegram;
} }
afterEach(() => {
vi.restoreAllMocks();
});
describe("telegramPlugin duplicate token guard", () => { describe("telegramPlugin duplicate token guard", () => {
it("marks secondary account as not configured when token is shared", async () => { it("marks secondary account as not configured when token is shared", async () => {
const cfg = createCfg(); const cfg = createCfg();

View File

@ -47,15 +47,17 @@ import {
type ResolvedTelegramAccount, type ResolvedTelegramAccount,
} from "./accounts.js"; } from "./accounts.js";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
import { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./audit.js";
import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js";
import { import {
isTelegramExecApprovalClientEnabled, isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalTarget, resolveTelegramExecApprovalTarget,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
import { monitorTelegramProvider } from "./monitor.js";
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js";
import { sendTelegramPayloadMessages } from "./outbound-adapter.js"; import { sendTelegramPayloadMessages } from "./outbound-adapter.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
import type { TelegramProbe } from "./probe.js"; import { probeTelegram, type TelegramProbe } from "./probe.js";
import { getTelegramRuntime } from "./runtime.js"; import { getTelegramRuntime } from "./runtime.js";
import { sendTypingTelegram } from "./send.js"; import { sendTypingTelegram } from "./send.js";
import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupAdapter } from "./setup-core.js";
@ -697,7 +699,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
collectStatusIssues: collectTelegramStatusIssues, collectStatusIssues: collectTelegramStatusIssues,
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) => probeAccount: async ({ account, timeoutMs }) =>
getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, { probeTelegram(account.token, timeoutMs, {
accountId: account.accountId, accountId: account.accountId,
proxyUrl: account.config.proxy, proxyUrl: account.config.proxy,
network: account.config.network, network: account.config.network,
@ -731,7 +733,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
cfg.channels?.telegram?.groups; cfg.channels?.telegram?.groups;
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
getTelegramRuntime().channel.telegram.collectUnmentionedGroupIds(groups); collectTelegramUnmentionedGroupIds(groups);
if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) { if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) {
return undefined; return undefined;
} }
@ -746,7 +748,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
elapsedMs: 0, elapsedMs: 0,
}; };
} }
const audit = await getTelegramRuntime().channel.telegram.auditGroupMembership({ const audit = await auditTelegramGroupMembership({
token: account.token, token: account.token,
botId, botId,
groupIds, groupIds,
@ -815,7 +817,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
const token = (account.token ?? "").trim(); const token = (account.token ?? "").trim();
let telegramBotLabel = ""; let telegramBotLabel = "";
try { try {
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(token, 2500, { const probe = await probeTelegram(token, 2500, {
accountId: account.accountId, accountId: account.accountId,
proxyUrl: account.config.proxy, proxyUrl: account.config.proxy,
network: account.config.network, network: account.config.network,
@ -830,7 +832,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
} }
} }
ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`); ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`);
return getTelegramRuntime().channel.telegram.monitorTelegramProvider({ return monitorTelegramProvider({
token, token,
accountId: account.accountId, accountId: account.accountId,
config: ctx.cfg, config: ctx.cfg,

View File

@ -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",
});
});

View File

@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized"); createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized");

View File

@ -1,15 +1,20 @@
import { import {
buildVllmProvider,
configureOpenAICompatibleSelfHostedProviderNonInteractive,
discoverOpenAICompatibleSelfHostedProvider,
emptyPluginConfigSchema, emptyPluginConfigSchema,
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
type OpenClawPluginApi, type OpenClawPluginApi,
type ProviderAuthMethodNonInteractiveContext, type ProviderAuthMethodNonInteractiveContext,
} from "openclaw/plugin-sdk/core"; } 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 PROVIDER_ID = "vllm";
const DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1";
async function loadProviderSetup() {
return await import("openclaw/plugin-sdk/self-hosted-provider-setup");
}
const vllmPlugin = { const vllmPlugin = {
id: "vllm", id: "vllm",
@ -25,38 +30,44 @@ const vllmPlugin = {
auth: [ auth: [
{ {
id: "custom", id: "custom",
label: "vLLM", label: VLLM_PROVIDER_LABEL,
hint: "Local/self-hosted OpenAI-compatible server", hint: "Local/self-hosted OpenAI-compatible server",
kind: "custom", kind: "custom",
run: async (ctx) => run: async (ctx) => {
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ const providerSetup = await loadProviderSetup();
return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
cfg: ctx.config, cfg: ctx.config,
prompter: ctx.prompter, prompter: ctx.prompter,
providerId: PROVIDER_ID, providerId: PROVIDER_ID,
providerLabel: "vLLM", providerLabel: VLLM_PROVIDER_LABEL,
defaultBaseUrl: DEFAULT_BASE_URL, defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "VLLM_API_KEY", defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
}), });
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => },
configureOpenAICompatibleSelfHostedProviderNonInteractive({ runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx, ctx,
providerId: PROVIDER_ID, providerId: PROVIDER_ID,
providerLabel: "vLLM", providerLabel: VLLM_PROVIDER_LABEL,
defaultBaseUrl: DEFAULT_BASE_URL, defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
defaultApiKeyEnvVar: "VLLM_API_KEY", defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
}), });
},
}, },
], ],
discovery: { discovery: {
order: "late", order: "late",
run: async (ctx) => run: async (ctx) => {
discoverOpenAICompatibleSelfHostedProvider({ const providerSetup = await loadProviderSetup();
return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({
ctx, ctx,
providerId: PROVIDER_ID, providerId: PROVIDER_ID,
buildProvider: buildVllmProvider, buildProvider: providerSetup.buildVllmProvider,
}), });
},
}, },
wizard: { wizard: {
setup: { setup: {

View File

@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { whatsappPlugin } from "./src/channel.js"; import { whatsappPlugin } from "./src/channel.js";
import { setWhatsAppRuntime } from "./src/runtime.js"; import { setWhatsAppRuntime } from "./src/runtime.js";

View File

@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 capturedCtx: unknown;
let capturedDispatchParams: unknown; let capturedDispatchParams: unknown;

View File

@ -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",
});
});

View File

@ -1,40 +1,7 @@
import { describe, expect, it, vi } from "vitest"; 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"; 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", () => { describe("whatsappOutbound sendPayload", () => {
installSendPayloadContractSuite({
channel: "whatsapp",
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
createHarness,
});
it("trims leading whitespace for direct text sends", async () => { it("trims leading whitespace for direct text sends", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));

View File

@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized"); createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");

View File

@ -1,4 +1,4 @@
import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { normalizeProviderId } from "../../src/agents/provider-id.js";
import { import {
createPluginBackedWebSearchProvider, createPluginBackedWebSearchProvider,
getScopedCredentialValue, getScopedCredentialValue,

View File

@ -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",
};
},
});
});

View File

@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest";
import { __testing } from "./monitor.js"; import { __testing } from "./monitor.js";
describe("zalo group policy access", () => { 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", () => { it("blocks all group messages when policy is disabled", () => {
const decision = __testing.evaluateZaloGroupAccess({ const decision = __testing.evaluateZaloGroupAccess({
providerConfigPresent: true, providerConfigPresent: true,

View File

@ -1,10 +1,7 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser"; import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import "./accounts.test-mocks.js"; import "./accounts.test-mocks.js";
import { import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js";
installSendPayloadContractSuite,
primeSendMock,
} from "../../../src/test-utils/send-payload-contract.js";
import { zalouserPlugin } from "./channel.js"; import { zalouserPlugin } from "./channel.js";
import { setZalouserRuntime } from "./runtime.js"; import { setZalouserRuntime } from "./runtime.js";
@ -36,8 +33,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
} as never); } as never);
const mod = await import("./send.js"); const mod = await import("./send.js");
mockedSend = vi.mocked(mod.sendMessageZalouser); mockedSend = vi.mocked(mod.sendMessageZalouser);
mockedSend.mockClear(); primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
}); });
it("group target delegates with isGroup=true and stripped threadId", async () => { 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" }); 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", () => { describe("zalouserPlugin messaging target normalization", () => {

View File

@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import module from "node:module"; import module from "node:module";
import { fileURLToPath } from "node:url";
const MIN_NODE_MAJOR = 22; const MIN_NODE_MAJOR = 22;
const MIN_NODE_MINOR = 12; const MIN_NODE_MINOR = 12;
@ -47,6 +48,20 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
const isModuleNotFoundError = (err) => const isModuleNotFoundError = (err) =>
err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND"; 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 () => { const installProcessWarningFilter = async () => {
// Keep bootstrap warnings consistent with the TypeScript runtime. // Keep bootstrap warnings consistent with the TypeScript runtime.
for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) { for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) {
@ -57,7 +72,7 @@ const installProcessWarningFilter = async () => {
return; return;
} }
} catch (err) { } catch (err) {
if (isModuleNotFoundError(err)) { if (isDirectModuleNotFoundError(err, specifier)) {
continue; continue;
} }
throw err; throw err;
@ -72,8 +87,8 @@ const tryImport = async (specifier) => {
await import(specifier); await import(specifier);
return true; return true;
} catch (err) { } catch (err) {
// Only swallow missing-module errors; rethrow real runtime errors. // Only swallow direct entry misses; rethrow transitive resolution failures.
if (isModuleNotFoundError(err)) { if (isDirectModuleNotFoundError(err, specifier)) {
return false; return false;
} }
throw err; throw err;

View File

@ -29,6 +29,8 @@
"assets/", "assets/",
"dist/", "dist/",
"docs/", "docs/",
"!docs/.generated/**",
"!docs/.i18n/zh-CN.tm.jsonl",
"extensions/", "extensions/",
"skills/" "skills/"
], ],
@ -48,6 +50,22 @@
"types": "./dist/plugin-sdk/compat.d.ts", "types": "./dist/plugin-sdk/compat.d.ts",
"default": "./dist/plugin-sdk/compat.js" "default": "./dist/plugin-sdk/compat.js"
}, },
"./plugin-sdk/ollama-setup": {
"types": "./dist/plugin-sdk/ollama-setup.d.ts",
"default": "./dist/plugin-sdk/ollama-setup.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/self-hosted-provider-setup": {
"types": "./dist/plugin-sdk/self-hosted-provider-setup.d.ts",
"default": "./dist/plugin-sdk/self-hosted-provider-setup.js"
},
"./plugin-sdk/routing": { "./plugin-sdk/routing": {
"types": "./dist/plugin-sdk/routing.d.ts", "types": "./dist/plugin-sdk/routing.d.ts",
"default": "./dist/plugin-sdk/routing.js" "default": "./dist/plugin-sdk/routing.js"
@ -311,6 +329,7 @@
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", "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: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: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: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: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", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
@ -323,6 +342,7 @@
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:e2e": "vitest run --config vitest.e2e.config.ts", "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: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:extensions": "vitest run --config vitest.extensions.config.ts",
"test:fast": "vitest run --config vitest.unit.config.ts", "test:fast": "vitest run --config vitest.unit.config.ts",
"test:force": "node --import tsx scripts/test-force.ts", "test:force": "node --import tsx scripts/test-force.ts",

View File

@ -63,11 +63,12 @@ const cases = [
]; ];
function parseMaxRssMb(stderr) { function parseMaxRssMb(stderr) {
const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m")); const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
if (!match) { const lastMatch = matches.at(-1);
if (!lastMatch) {
return null; return null;
} }
return Number(match[1]) / 1024; return Number(lastMatch[1]) / 1024;
} }
function buildBenchEnv() { function buildBenchEnv() {
@ -98,6 +99,9 @@ function buildBenchEnv() {
// one-shot compile cache overhead, which varies across runner builds. // one-shot compile cache overhead, which varies across runner builds.
env.NODE_DISABLE_COMPILE_CACHE = "1"; 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; return env;
} }

View File

@ -2,6 +2,10 @@
"index", "index",
"core", "core",
"compat", "compat",
"ollama-setup",
"provider-setup",
"sandbox",
"self-hosted-provider-setup",
"routing", "routing",
"telegram", "telegram",
"discord", "discord",

283
scripts/test-extension.mjs Normal file
View 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();
}

View File

@ -3,14 +3,13 @@ import { resolveStateDir } from "../config/paths.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
export function resolveOpenClawAgentDir(): string { export function resolveOpenClawAgentDir(env: NodeJS.ProcessEnv = process.env): string {
const override = const override = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim();
process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
if (override) { if (override) {
return resolveUserPath(override); return resolveUserPath(override, env);
} }
const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent"); const defaultAgentDir = path.join(resolveStateDir(env), "agents", DEFAULT_AGENT_ID, "agent");
return resolveUserPath(defaultAgentDir); return resolveUserPath(defaultAgentDir, env);
} }
export function ensureOpenClawAgentEnv(): string { export function ensureOpenClawAgentEnv(): string {

View File

@ -327,12 +327,16 @@ export function resolveAgentIdByWorkspacePath(
return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0]; return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0];
} }
export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) { export function resolveAgentDir(
cfg: OpenClawConfig,
agentId: string,
env: NodeJS.ProcessEnv = process.env,
) {
const id = normalizeAgentId(agentId); const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim(); const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
if (configured) { if (configured) {
return resolveUserPath(configured); return resolveUserPath(configured, env);
} }
const root = resolveStateDir(process.env); const root = resolveStateDir(env);
return path.join(root, "agents", id, "agent"); return path.join(root, "agents", id, "agent");
} }

View File

@ -47,7 +47,10 @@ describe("auth profiles read-only external CLI sync", () => {
const loaded = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); const loaded = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true });
expect(mocks.syncExternalCliCredentials).toHaveBeenCalled(); expect(mocks.syncExternalCliCredentials).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ log: false }),
);
expect(loaded.profiles["qwen-portal:default"]).toMatchObject({ expect(loaded.profiles["qwen-portal:default"]).toMatchObject({
type: "oauth", type: "oauth",
provider: "qwen-portal", provider: "qwen-portal",

View File

@ -14,6 +14,10 @@ import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from ".
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
type ExternalCliSyncOptions = {
log?: boolean;
};
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean { function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
if (!a) { if (!a) {
return false; return false;
@ -60,6 +64,7 @@ function syncExternalCliCredentialsForProvider(
provider: string, provider: string,
readCredentials: () => OAuthCredential | null, readCredentials: () => OAuthCredential | null,
now: number, now: number,
options: ExternalCliSyncOptions,
): boolean { ): boolean {
const existing = store.profiles[profileId]; const existing = store.profiles[profileId];
const shouldSync = const shouldSync =
@ -78,10 +83,12 @@ function syncExternalCliCredentialsForProvider(
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) { if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) {
store.profiles[profileId] = creds; store.profiles[profileId] = creds;
log.info(`synced ${provider} credentials from external cli`, { if (options.log !== false) {
profileId, log.info(`synced ${provider} credentials from external cli`, {
expires: new Date(creds.expires).toISOString(), profileId,
}); expires: new Date(creds.expires).toISOString(),
});
}
return true; return true;
} }
@ -94,7 +101,10 @@ function syncExternalCliCredentialsForProvider(
* *
* Returns true if any credentials were updated. * Returns true if any credentials were updated.
*/ */
export function syncExternalCliCredentials(store: AuthProfileStore): boolean { export function syncExternalCliCredentials(
store: AuthProfileStore,
options: ExternalCliSyncOptions = {},
): boolean {
let mutated = false; let mutated = false;
const now = Date.now(); const now = Date.now();
@ -119,10 +129,12 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) { if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds; store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
mutated = true; mutated = true;
log.info("synced qwen credentials from qwen cli", { if (options.log !== false) {
profileId: QWEN_CLI_PROFILE_ID, log.info("synced qwen credentials from qwen cli", {
expires: new Date(qwenCreds.expires).toISOString(), profileId: QWEN_CLI_PROFILE_ID,
}); expires: new Date(qwenCreds.expires).toISOString(),
});
}
} }
} }
@ -134,6 +146,7 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
"minimax-portal", "minimax-portal",
() => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
now, now,
options,
) )
) { ) {
mutated = true; mutated = true;
@ -145,6 +158,7 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
"openai-codex", "openai-codex",
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
now, now,
options,
) )
) { ) {
mutated = true; mutated = true;

View File

@ -1,6 +1,6 @@
import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { normalizeProviderId, normalizeProviderIdForAuth } from "../model-selection.js"; import { normalizeProviderId, normalizeProviderIdForAuth } from "../provider-id.js";
import { import {
ensureAuthProfileStore, ensureAuthProfileStore,
saveAuthProfileStore, saveAuthProfileStore,

View File

@ -381,7 +381,7 @@ function loadAuthProfileStoreForAgent(
if (asStore) { if (asStore) {
// Runtime secret activation must remain read-only: // Runtime secret activation must remain read-only:
// sync external CLI credentials in-memory, but never persist while readOnly. // sync external CLI credentials in-memory, but never persist while readOnly.
const synced = syncExternalCliCredentials(asStore); const synced = syncExternalCliCredentials(asStore, { log: !readOnly });
if (synced && !readOnly) { if (synced && !readOnly) {
saveJsonFile(authPath, asStore); saveJsonFile(authPath, asStore);
} }
@ -413,7 +413,7 @@ function loadAuthProfileStoreForAgent(
const mergedOAuth = mergeOAuthFileIntoStore(store); const mergedOAuth = mergeOAuthFileIntoStore(store);
// Keep external CLI credentials visible in runtime even during read-only loads. // Keep external CLI credentials visible in runtime even during read-only loads.
const syncedCli = syncExternalCliCredentials(store); const syncedCli = syncExternalCliCredentials(store, { log: !readOnly });
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1"; const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli); const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
if (shouldWrite) { if (shouldWrite) {

View 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;
}
}

View File

@ -5,7 +5,12 @@ import type { Api, Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js"; import { withEnvAsync } from "../test-utils/env.js";
import { ensureAuthProfileStore } from "./auth-profiles.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("_"); 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 () => { it("resolves Synthetic API key from env", async () => {
await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => { await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => {
// pragma: allowlist secret // pragma: allowlist secret

View File

@ -487,6 +487,56 @@ export function resolveModelAuthMode(
return "unknown"; 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: { export async function getApiKeyForModel(params: {
model: Model<Api>; model: Model<Api>;
cfg?: OpenClawConfig; cfg?: OpenClawConfig;

Some files were not shown because too many files have changed in this diff Show More