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
changed-extensions:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
submodules: false
- name: Ensure changed-extensions base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
use-sticky-disk: "false"
- name: Detect changed extensions
id: changed
env:
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import { listChangedExtensionIds } from "./scripts/test-extension.mjs";
const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" });
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
EOF
# Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts:
needs: [docs-scope, changed-scope]
@ -205,6 +249,29 @@ jobs:
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
run: ${{ matrix.command }}
extension-fast:
name: "extension-fast (${{ matrix.extension }})"
needs: [docs-scope, changed-scope, changed-extensions]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_extensions_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run changed extension tests
run: pnpm test:extension ${{ matrix.extension }}
# Types, lint, and format check.
check:
name: "check"

View File

@ -1 +1,2 @@
**/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/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy.
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs.
- 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.
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819)
- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. Thanks @vincentkoc.
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873)
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
- Feishu/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. (#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) 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) 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.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) Thanks @scoootscooob.
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
- 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
- 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
- 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.
- 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/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#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. (#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.
- 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/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: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
- 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. (#46816) 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. (#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: 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.
- 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.
@ -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.
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly.
- Feishu/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/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied.
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
- 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/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
- 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) 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.
- 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.
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc.
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras.
- 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. (#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: 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/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.
- 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.
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- 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.
- 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

View File

@ -89,6 +89,9 @@ Welcome to the lobster tank! 🦞
- Test locally with your OpenClaw instance
- Run tests: `pnpm build && pnpm check && pnpm test`
- For extension/plugin changes, run the fast local lane first:
- `pnpm test:extension <extension-name>`
- If you changed shared plugin or channel surfaces, still run the broader relevant lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)

View File

@ -18,14 +18,13 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var permissionRequester: PermissionRequester
private var didAttachRuntimeUi = false
private var didStartNodeService = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
lifecycleScope.launch {
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 {
OpenClawTheme {
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() {

View File

@ -2,209 +2,268 @@ package ai.openclaw.app
import android.app.Application
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.gateway.GatewayEndpoint
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
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.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
@OptIn(ExperimentalCoroutinesApi::class)
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
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
val camera: CameraCaptureManager = runtime.camera
val sms: SmsManager = runtime.sms
private fun ensureRuntime(): NodeRuntime {
runtimeRef.value?.let { return it }
val runtime = nodeApp.ensureRuntime()
runtime.setForeground(foreground)
runtimeRef.value = runtime
return runtime
}
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
private fun <T> runtimeState(
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 isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected
val statusText: StateFlow<String> = runtime.statusText
val serverName: StateFlow<String?> = runtime.serverName
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 runtimeInitialized: StateFlow<Boolean> =
runtimeRef
.flatMapLatest { runtime -> flowOf(runtime != null) }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
val canvasCurrentUrl: StateFlow<String?> = runtimeState(initial = null) { it.canvas.currentUrl }
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 displayName: StateFlow<String> = runtime.displayName
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 gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
val chatMessages = runtime.chatMessages
val chatError: StateFlow<String?> = runtime.chatError
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
val chatPendingToolCalls = runtime.chatPendingToolCalls
val chatSessions = runtime.chatSessions
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
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) {
runtime.setForeground(value)
foreground = value
runtimeRef.value?.setForeground(value)
}
fun setDisplayName(value: String) {
runtime.setDisplayName(value)
prefs.setDisplayName(value)
}
fun setCameraEnabled(value: Boolean) {
runtime.setCameraEnabled(value)
prefs.setCameraEnabled(value)
}
fun setLocationMode(mode: LocationMode) {
runtime.setLocationMode(mode)
prefs.setLocationMode(mode)
}
fun setLocationPreciseEnabled(value: Boolean) {
runtime.setLocationPreciseEnabled(value)
prefs.setLocationPreciseEnabled(value)
}
fun setPreventSleep(value: Boolean) {
runtime.setPreventSleep(value)
prefs.setPreventSleep(value)
}
fun setManualEnabled(value: Boolean) {
runtime.setManualEnabled(value)
prefs.setManualEnabled(value)
}
fun setManualHost(value: String) {
runtime.setManualHost(value)
prefs.setManualHost(value)
}
fun setManualPort(value: Int) {
runtime.setManualPort(value)
prefs.setManualPort(value)
}
fun setManualTls(value: Boolean) {
runtime.setManualTls(value)
prefs.setManualTls(value)
}
fun setGatewayToken(value: String) {
runtime.setGatewayToken(value)
prefs.setGatewayToken(value)
}
fun setGatewayBootstrapToken(value: String) {
runtime.setGatewayBootstrapToken(value)
prefs.setGatewayBootstrapToken(value)
}
fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value)
prefs.setGatewayPassword(value)
}
fun setOnboardingCompleted(value: Boolean) {
runtime.setOnboardingCompleted(value)
if (value) {
ensureRuntime()
}
prefs.setOnboardingCompleted(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
prefs.setCanvasDebugStatusEnabled(value)
}
fun setVoiceScreenActive(active: Boolean) {
runtime.setVoiceScreenActive(active)
ensureRuntime().setVoiceScreenActive(active)
}
fun setMicEnabled(enabled: Boolean) {
runtime.setMicEnabled(enabled)
ensureRuntime().setMicEnabled(enabled)
}
fun setSpeakerEnabled(enabled: Boolean) {
runtime.setSpeakerEnabled(enabled)
ensureRuntime().setSpeakerEnabled(enabled)
}
fun refreshGatewayConnection() {
runtime.refreshGatewayConnection()
ensureRuntime().refreshGatewayConnection()
}
fun connect(endpoint: GatewayEndpoint) {
runtime.connect(endpoint)
ensureRuntime().connect(endpoint)
}
fun connectManual() {
runtime.connectManual()
ensureRuntime().connectManual()
}
fun disconnect() {
runtime.disconnect()
runtimeRef.value?.disconnect()
}
fun acceptGatewayTrustPrompt() {
runtime.acceptGatewayTrustPrompt()
runtimeRef.value?.acceptGatewayTrustPrompt()
}
fun declineGatewayTrustPrompt() {
runtime.declineGatewayTrustPrompt()
runtimeRef.value?.declineGatewayTrustPrompt()
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson)
}
fun requestCanvasRehydrate(source: String = "screen_tab") {
runtime.requestCanvasRehydrate(source = source, force = true)
ensureRuntime().requestCanvasRehydrate(source = source, force = true)
}
fun refreshHomeCanvasOverviewIfConnected() {
runtime.refreshHomeCanvasOverviewIfConnected()
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
}
fun loadChat(sessionKey: String) {
runtime.loadChat(sessionKey)
ensureRuntime().loadChat(sessionKey)
}
fun refreshChat() {
runtime.refreshChat()
ensureRuntime().refreshChat()
}
fun refreshChatSessions(limit: Int? = null) {
runtime.refreshChatSessions(limit = limit)
ensureRuntime().refreshChatSessions(limit = limit)
}
fun setChatThinkingLevel(level: String) {
runtime.setChatThinkingLevel(level)
ensureRuntime().setChatThinkingLevel(level)
}
fun switchChatSession(sessionKey: String) {
runtime.switchChatSession(sessionKey)
ensureRuntime().switchChatSession(sessionKey)
}
fun abortChat() {
runtime.abortChat()
ensureRuntime().abortChat()
}
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
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() {
super.onCreate()

View File

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

View File

@ -43,11 +43,12 @@ import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)

View File

@ -265,7 +265,7 @@ class ChatController(
}
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
_sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
@ -336,7 +336,7 @@ class ChatController(
try {
val historyJson =
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
_sessionId.value = history.sessionId
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 sid = root["sessionId"].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? {
@ -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?.asArrayOrNull(): JsonArray? = this as? JsonArray

View File

@ -1,7 +1,5 @@
package ai.openclaw.app.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -28,8 +26,7 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState {
image =
withContext(Dispatchers.Default) {
try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null
bitmap.asImageBitmap()
} catch (_: Throwable) {
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@ -34,11 +36,19 @@ fun ChatMessageListCard(
modifier: Modifier = Modifier,
) {
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).
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) {
// New list items/tool rows should animate into view, but token streaming should not restart
// that animation on every delta.
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
listState.animateScrollToItem(index = 0)
}
LaunchedEffect(stream) {
if (!stream.isNullOrEmpty()) {
listState.scrollToItem(index = 0)
}
}
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
@ -50,8 +60,6 @@ fun ChatMessageListCard(
) {
// With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
val stream = streamingAssistantText?.trim()
if (!stream.isNullOrEmpty()) {
item(key = "stream") {
ChatStreamingAssistantBubble(text = stream)
@ -70,8 +78,8 @@ fun ChatMessageListCard(
}
}
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
ChatMessageBubble(message = messages[messages.size - 1 - idx])
items(items = displayMessages, key = { it.id }) { message ->
ChatMessageBubble(message = message)
}
}

View File

@ -1,8 +1,5 @@
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.result.contract.ActivityResultContracts
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.mobileText
import ai.openclaw.app.ui.mobileTextSecondary
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -83,7 +79,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val next =
uris.take(8).mapNotNull { uri ->
try {
loadImageAttachment(resolver, uri)
loadSizedImageAttachment(resolver, uri)
} catch (_: Throwable) {
null
}
@ -160,7 +156,10 @@ private fun ChatThreadSelector(
mainSessionKey: String,
onSelectSession: (String) -> Unit,
) {
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val sessionOptions =
remember(sessionKey, sessions, mainSessionKey) {
resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
@ -214,24 +213,3 @@ data class PendingImageAttachment(
val mimeType: 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"
],
"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
},
{
@ -32140,6 +32154,16 @@
"tags": [],
"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",
"kind": "channel",
@ -34137,6 +34161,21 @@
"help": "Minimum retry delay in ms for Telegram outbound calls.",
"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",
"kind": "channel",
@ -51938,6 +51977,48 @@
"help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).",
"hasChildren": false
},
{
"path": "plugins.installs.*.marketplaceName",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Marketplace Name",
"help": "Marketplace display name recorded for marketplace-backed plugin installs (if available).",
"hasChildren": false
},
{
"path": "plugins.installs.*.marketplacePlugin",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Marketplace Plugin",
"help": "Plugin entry name inside the source marketplace, used for later updates.",
"hasChildren": false
},
{
"path": "plugins.installs.*.marketplaceSource",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Marketplace Source",
"help": "Original marketplace source used to resolve the install (for example a repo path or Git URL).",
"hasChildren": false
},
{
"path": "plugins.installs.*.resolvedAt",
"kind": "core",

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.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}
@ -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.*.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.*.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.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}
@ -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.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.*.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.*.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}
@ -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.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.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.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}
@ -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.*.installPath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Plugin Install Path","help":"Resolved install directory (usually ~/.openclaw/extensions/<id>).","hasChildren":false}
{"recordType":"path","path":"plugins.installs.*.integrity","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Integrity","help":"Resolved npm dist integrity hash for the fetched artifact (if reported by npm).","hasChildren":false}
{"recordType":"path","path":"plugins.installs.*.marketplaceName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Name","help":"Marketplace display name recorded for marketplace-backed plugin installs (if available).","hasChildren":false}
{"recordType":"path","path":"plugins.installs.*.marketplacePlugin","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Plugin","help":"Plugin entry name inside the source marketplace, used for later updates.","hasChildren":false}
{"recordType":"path","path":"plugins.installs.*.marketplaceSource","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Source","help":"Original marketplace source used to resolve the install (for example a repo path or Git URL).","hasChildren":false}
{"recordType":"path","path":"plugins.installs.*.resolvedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolution Time","help":"ISO timestamp when npm package metadata was last resolved for this install record.","hasChildren":false}
{"recordType":"path","path":"plugins.installs.*.resolvedName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Name","help":"Resolved npm package name from the fetched artifact.","hasChildren":false}
{"recordType":"path","path":"plugins.installs.*.resolvedSpec","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Spec","help":"Resolved exact npm spec (<name>@<version>) from the fetched artifact.","hasChildren":false}

View File

@ -91,6 +91,7 @@ Use the built-in `user` profile, or create your own `existing-session` profile:
```bash
openclaw browser --browser-profile user tabs
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
```

View File

@ -284,7 +284,8 @@ Manage extensions and their config:
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
- `openclaw plugins info <id>` — show details for a plugin.
- `openclaw plugins install <path|.tgz|npm-spec>` — install a plugin (or add a plugin path to `plugins.load.paths`).
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
- `openclaw plugins doctor` — report plugin load errors.

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:
- You want to install or manage Gateway plugins or compatible bundles
- You want to debug plugin load failures
@ -28,6 +28,7 @@ openclaw plugins uninstall <id>
openclaw plugins doctor
openclaw plugins update <id>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
```
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
@ -46,6 +47,8 @@ capabilities.
```bash
openclaw plugins install <path-or-spec>
openclaw plugins install <npm-spec> --pin
openclaw plugins install <plugin>@<marketplace>
openclaw plugins install <plugin> --marketplace <marketplace>
```
Security note: treat plugin installs like running code. Prefer pinned versions.
@ -65,6 +68,31 @@ name, use an explicit scoped spec (for example `@scope/diffs`).
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Claude marketplace installs are also supported.
Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's
local registry cache at `~/.claude/plugins/known_marketplaces.json`:
```bash
openclaw plugins marketplace list <marketplace-name>
openclaw plugins install <plugin-name>@<marketplace-name>
```
Use `--marketplace` when you want to pass the marketplace source explicitly:
```bash
openclaw plugins install <plugin-name> --marketplace <marketplace-name>
openclaw plugins install <plugin-name> --marketplace <owner/repo>
openclaw plugins install <plugin-name> --marketplace ./my-marketplace
```
Marketplace sources can be:
- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json`
- a local marketplace root or `marketplace.json` path
- a GitHub repo shorthand such as `owner/repo`
- a git URL
For local paths and archives, OpenClaw auto-detects:
- native OpenClaw plugins (`openclaw.plugin.json`)
@ -114,7 +142,8 @@ openclaw plugins update --all
openclaw plugins update <id> --dry-run
```
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
Updates apply to tracked installs in `plugins.installs`, currently npm and
marketplace installs.
When a stored integrity hash exists and the fetched artifact hash changes,
OpenClaw prints a warning and asks for confirmation before proceeding. Use

View File

@ -2443,6 +2443,12 @@ See [Plugins](/tools/plugin).
openclaw: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
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" },
},
color: "#FF4500",
@ -2463,6 +2469,8 @@ See [Plugins](/tools/plugin).
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
- 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 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.
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
- `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:
"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
- reminds you to enable remote debugging in Chrome at
`chrome://inspect/#remote-debugging`
- reminds you to enable remote debugging in the browser inspect page (for
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
still requires:
- Google Chrome 144+ on the gateway/node host
- Chrome running locally
- remote debugging enabled in Chrome
- approving the first attach consent prompt in Chrome
- a Chromium-based browser 144+ on the gateway/node host
- the browser running locally
- remote debugging enabled in that browser
- approving the first attach consent prompt in the browser
This check does **not** apply to Docker, sandbox, remote-browser, or other
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-cursor-bundle
openclaw plugins install ./my-bundle.tgz
openclaw plugins marketplace list <marketplace-name>
openclaw plugins install <plugin-name>@<marketplace-name>
openclaw plugins info my-bundle
```
If the directory is a native OpenClaw plugin/package, the native install path
still wins.
For Claude marketplace names, OpenClaw reads the local Claude known-marketplace
registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries
can resolve to bundle-compatible directories/archives or to native plugin
sources; after resolution, the normal install rules still apply.
## Troubleshooting
### Bundle is detected but capabilities do not run

View File

@ -88,6 +88,12 @@ Browser settings live in `~/.openclaw/openclaw.json`.
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" },
},
},
@ -114,6 +120,8 @@ Notes:
- 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
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)
@ -289,11 +297,11 @@ Defaults:
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
Chrome DevTools MCP server. This reuses the tabs and login state already open in
that Chrome profile.
OpenClaw can also attach to a running Chromium-based browser profile through the
official Chrome DevTools MCP server. This reuses the tabs and login state
already open in that browser profile.
Official background and setup references:
@ -305,13 +313,41 @@ Built-in profile:
- `user`
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`
2. Enable remote debugging
3. Keep Chrome running and approve the connection prompt when OpenClaw attaches
- The built-in `user` profile uses Chrome MCP auto-connect, which targets the
default local Google Chrome profile.
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:
@ -327,17 +363,17 @@ What success looks like:
- `status` shows `driver: existing-session`
- `status` shows `transport: chrome-mcp`
- `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
What to check if attach does not work:
- Chrome is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
- Chrome showed and you accepted the attach consent prompt
- the target Chromium-based browser is version `144+`
- remote debugging is enabled in that browser's inspect page
- the browser showed and you accepted the attach consent prompt
- `openclaw doctor` migrates old extension-based browser config and checks that
Chrome is installed locally with a compatible version, but it cannot enable
Chrome-side remote debugging for you
Chrome is installed locally for default auto-connect profiles, but it cannot
enable browser-side remote debugging for you
Agent use:
@ -351,10 +387,11 @@ Notes:
- This path is higher-risk than the isolated `openclaw` profile because it can
act inside your signed-in browser session.
- OpenClaw does not launch Chrome for this driver; it attaches to an existing
session only.
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not
the legacy default-profile remote debugging port workflow.
- OpenClaw does not launch the browser for this driver; it attaches to an
existing session only.
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here. If
`userDataDir` is set, OpenClaw passes it through to target that explicit
Chromium user data directory.
- Existing-session screenshots support page captures and `--ref` element
captures from snapshots, but not CSS `--element` selectors.
- 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
```
For Claude marketplace installs, list the marketplace first, then install by
marketplace entry name:
```bash
openclaw plugins marketplace list <marketplace-name>
openclaw plugins install <plugin-name>@<marketplace-name>
```
OpenClaw resolves known Claude marketplace names from
`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit
marketplace source with `--marketplace`.
## Architecture
OpenClaw's plugin system has four layers:
@ -94,6 +106,10 @@ OpenClaw also recognizes two compatible external bundle layouts:
component layout without a manifest
- Cursor-style bundles: `.cursor-plugin/plugin.json`
Claude marketplace entries can point at any of these compatible bundles, or at
native OpenClaw plugin sources. OpenClaw resolves the marketplace entry first,
then runs the normal install path for the resolved source.
They are shown in the plugin list as `format=bundle`, with a subtype of
`codex` or `claude` in verbose/info output.
@ -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
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 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.
- 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).
- `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.*`.

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

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

View File

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

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js";
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { processDiscordMessage } from "./message-handler.process.js";
import {

View File

@ -21,7 +21,6 @@ import {
} from "../../../../src/acp/persistent-bindings.route.js";
import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
import { resolveCommandAuthorization } from "../../../../src/auto-reply/command-auth.js";
import type {
ChatCommandDefinition,
CommandArgDefinition,
@ -61,8 +60,10 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordChannelConfigWithFallback,
resolveDiscordAllowListMatch,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
@ -108,33 +109,26 @@ function resolveDiscordNativeCommandAllowlistAccess(params: {
if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") {
return { configured: false, allowed: false } as const;
}
const configured =
Array.isArray(commandsAllowFrom.discord) || Array.isArray(commandsAllowFrom["*"]);
if (!configured) {
const rawAllowList = Array.isArray(commandsAllowFrom.discord)
? commandsAllowFrom.discord
: commandsAllowFrom["*"];
if (!Array.isArray(rawAllowList)) {
return { configured: false, allowed: false } as const;
}
const from =
params.chatType === "direct"
? `discord:${params.sender.id}`
: `discord:${params.chatType}:${params.conversationId ?? "unknown"}`;
const auth = resolveCommandAuthorization({
ctx: {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
AccountId: params.accountId ?? undefined,
ChatType: params.chatType,
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,
const allowList = normalizeDiscordAllowList(rawAllowList.map(String), [
"discord:",
"user:",
"pk:",
]);
if (!allowList) {
return { configured: true, allowed: false } as const;
}
const match = resolveDiscordAllowListMatch({
allowList,
candidate: params.sender,
allowNameMatching: false,
});
return { configured: true, allowed: auth.isAuthorizedSender } as const;
return { configured: true, allowed: match.allowed } as const;
}
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 type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");

View File

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

View File

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

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 type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");

View File

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

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 { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.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 { normalizeProviderId } from "../../src/agents/provider-id.js";
import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js";
import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js";
import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js";

View File

@ -3,7 +3,7 @@ import {
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
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 {
applyOpenAIConfig,
OPENAI_DEFAULT_MODEL,

View File

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

View File

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

View File

@ -4,10 +4,10 @@ import {
runPluginCommandWithTimeout,
shellEscape,
type SshSandboxSession,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/sandbox";
import type { ResolvedOpenShellPluginConfig } from "./config.js";
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core";
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox";
export type OpenShellExecContext = {
config: ResolvedOpenShellPluginConfig;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
import { createSignalEventHandler } from "./event-handler.js";
import {
createBaseSignalEventHandlerDeps,

View File

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

View File

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

View File

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

View File

@ -3,10 +3,10 @@ import os from "node:os";
import path from "node:path";
import type { App } from "@slack/bolt";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js";
import type { OpenClawConfig } from "../../../../../src/config/config.js";
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js";
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
import type { SlackMonitorContext } from "../context.js";

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 SlackBolt from "@slack/bolt";
import SlackBolt, * as SlackBoltNamespace from "@slack/bolt";
import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js";
import {
@ -46,14 +46,77 @@ import {
import { registerSlackMonitorSlashCommands } from "./slash.js";
import type { MonitorSlackOpts } from "./types.js";
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
default?: typeof import("@slack/bolt");
type SlackAppConstructor = typeof import("@slack/bolt").App;
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.
// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue)
const slackBolt =
(slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule;
const { App, HTTPReceiver } = slackBolt;
type Constructor = abstract new (...args: never[]) => unknown;
function isConstructorFunction<T extends Constructor>(value: unknown): value is T {
return typeof value === "function";
}
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_BODY_TIMEOUT_MS = 30_000;
@ -515,6 +578,7 @@ export const __testing = {
publishSlackDisconnectedStatus,
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSlackBoltInterop,
getSocketEmitter,
waitForSlackSocketDisconnect,
};

View File

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

View File

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

View File

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

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 () => {
loadSessionStore.mockReturnValue({
s1: { reasoningLevel: "on" },

View File

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

View File

@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params;
const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params;
return registerAndResolveCommandHandlerBase({
commandName: "status",
cfg,
allowFrom: allowFrom ?? ["*"],
groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: true,
telegramCfg,
resolveTelegramGroupConfig,
});
}
@ -209,6 +211,7 @@ function registerAndResolveCommandHandlerBase(params: {
allowFrom: string[];
groupAllowFrom: string[];
useAccessGroups: boolean;
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
@ -220,6 +223,7 @@ function registerAndResolveCommandHandlerBase(params: {
allowFrom,
groupAllowFrom,
useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig,
} = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
@ -239,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: {
allowFrom,
groupAllowFrom,
useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig,
}),
});
@ -254,6 +259,7 @@ function registerAndResolveCommandHandler(params: {
allowFrom?: string[];
groupAllowFrom?: string[];
useAccessGroups?: boolean;
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
@ -265,6 +271,7 @@ function registerAndResolveCommandHandler(params: {
allowFrom,
groupAllowFrom,
useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig,
} = params;
return registerAndResolveCommandHandlerBase({
@ -273,6 +280,7 @@ function registerAndResolveCommandHandler(params: {
allowFrom: allowFrom ?? [],
groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: useAccessGroups ?? true,
telegramCfg,
resolveTelegramGroupConfig,
});
}
@ -443,6 +451,31 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("sends native command error replies silently when silentErrorReplies is enabled", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {},
telegramCfg: { silentErrorReplies: true },
});
await handler(buildStatusCommandContext());
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
| DeliverRepliesParams
| undefined;
expect(deliveredCall).toEqual(
expect.objectContaining({
silent: true,
replies: [expect.objectContaining({ isError: true })],
}),
);
});
it("routes Telegram native commands through configured ACP topic bindings", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(

View File

@ -12,6 +12,13 @@ type GetPluginCommandSpecsFn =
type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand;
type ExecutePluginCommandFn =
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 AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
type NativeCommandHarness = {
@ -43,6 +50,37 @@ vi.mock("../../../src/plugins/commands.js", () => ({
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(() => ({
deliverReplies: vi.fn(async () => {}),
}));

View File

@ -290,4 +290,56 @@ describe("registerTelegramNativeCommands", () => {
);
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: { key: "plug", requireAuth: false },
args: undefined,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "plugin failed",
isError: true,
} as never);
registerTelegramNativeCommands({
...buildParams({}),
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig,
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.({
match: "",
message: {
message_id: 1,
date: Math.floor(Date.now() / 1000),
chat: { id: 123, type: "private" },
from: { id: 456, username: "alice" },
},
});
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
silent: true,
replies: [expect.objectContaining({ isError: true })],
}),
);
});
});

View File

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

View File

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

View File

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

View File

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

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 () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
@ -645,6 +669,36 @@ describe("deliverReplies", () => {
);
});
it("keeps disable_notification on voice fallback text when silent is true", async () => {
const runtime = createRuntime();
const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError());
const sendMessage = vi.fn().mockResolvedValue({
message_id: 5,
chat: { id: "123" },
});
const bot = createBot({ sendVoice, sendMessage });
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await deliverWith({
replies: [
{ mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true },
],
runtime,
bot,
silent: true,
});
expect(sendVoice).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.stringContaining("Hello there"),
expect.objectContaining({
disable_notification: true,
}),
);
});
it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
voiceError: createVoiceMessagesForbiddenError(),

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js";
let capturedCtx: unknown;
let capturedDispatchParams: unknown;

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 type { ReplyPayload } from "../../../src/auto-reply/types.js";
import {
installSendPayloadContractSuite,
primeSendMock,
} from "../../../src/test-utils/send-payload-contract.js";
import { whatsappOutbound } from "./outbound-adapter.js";
function createHarness(params: {
payload: ReplyPayload;
sendResults?: Array<{ messageId: string }>;
}) {
const sendWhatsApp = vi.fn();
primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults);
const ctx = {
cfg: {},
to: "5511999999999@c.us",
text: "",
payload: params.payload,
deps: {
sendWhatsApp,
},
};
return {
run: async () => await whatsappOutbound.sendPayload!(ctx),
sendMock: sendWhatsApp,
to: ctx.to,
};
}
describe("whatsappOutbound sendPayload", () => {
installSendPayloadContractSuite({
channel: "whatsapp",
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
createHarness,
});
it("trims leading whitespace for direct text sends", async () => {
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));

View File

@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
createPluginRuntimeStore<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 {
createPluginBackedWebSearchProvider,
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";
describe("zalo group policy access", () => {
it("defaults missing provider config to allowlist", () => {
const resolved = __testing.resolveZaloRuntimeGroupPolicy({
providerConfigPresent: false,
groupPolicy: undefined,
defaultGroupPolicy: "open",
});
expect(resolved).toEqual({
groupPolicy: "allowlist",
providerMissingFallbackApplied: true,
});
});
it("blocks all group messages when policy is disabled", () => {
const decision = __testing.evaluateZaloGroupAccess({
providerConfigPresent: true,

View File

@ -1,10 +1,7 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./accounts.test-mocks.js";
import {
installSendPayloadContractSuite,
primeSendMock,
} from "../../../src/test-utils/send-payload-contract.js";
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js";
import { zalouserPlugin } from "./channel.js";
import { setZalouserRuntime } from "./runtime.js";
@ -36,8 +33,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
} as never);
const mod = await import("./send.js");
mockedSend = vi.mocked(mod.sendMessageZalouser);
mockedSend.mockClear();
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
});
it("group target delegates with isGroup=true and stripped threadId", async () => {
@ -110,19 +106,6 @@ describe("zalouserPlugin outbound sendPayload", () => {
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
});
installSendPayloadContractSuite({
channel: "zalouser",
chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: ({ payload, sendResults }) => {
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
return {
run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)),
sendMock: mockedSend,
to: "987654321",
};
},
});
});
describe("zalouserPlugin messaging target normalization", () => {

View File

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

View File

@ -29,6 +29,8 @@
"assets/",
"dist/",
"docs/",
"!docs/.generated/**",
"!docs/.i18n/zh-CN.tm.jsonl",
"extensions/",
"skills/"
],
@ -48,6 +50,22 @@
"types": "./dist/plugin-sdk/compat.d.ts",
"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": {
"types": "./dist/plugin-sdk/routing.d.ts",
"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:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts",
"test:channels": "vitest run --config vitest.channels.config.ts",
"test:contracts": "pnpm test -- src/channels/plugins/contracts src/plugins/contracts",
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
@ -323,6 +342,7 @@
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts",
"test:extension": "node scripts/test-extension.mjs",
"test:extensions": "vitest run --config vitest.extensions.config.ts",
"test:fast": "vitest run --config vitest.unit.config.ts",
"test:force": "node --import tsx scripts/test-force.ts",

View File

@ -63,11 +63,12 @@ const cases = [
];
function parseMaxRssMb(stderr) {
const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m"));
if (!match) {
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
const lastMatch = matches.at(-1);
if (!lastMatch) {
return null;
}
return Number(match[1]) / 1024;
return Number(lastMatch[1]) / 1024;
}
function buildBenchEnv() {
@ -98,6 +99,9 @@ function buildBenchEnv() {
// one-shot compile cache overhead, which varies across runner builds.
env.NODE_DISABLE_COMPILE_CACHE = "1";
}
// Keep the benchmark on a single process so RSS reflects the actual command
// path rather than the warning-suppression respawn wrapper.
env.OPENCLAW_NO_RESPAWN = "1";
return env;
}

View File

@ -2,6 +2,10 @@
"index",
"core",
"compat",
"ollama-setup",
"provider-setup",
"sandbox",
"self-hosted-provider-setup",
"routing",
"telegram",
"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 { resolveUserPath } from "../utils.js";
export function resolveOpenClawAgentDir(): string {
const override =
process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
export function resolveOpenClawAgentDir(env: NodeJS.ProcessEnv = process.env): string {
const override = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim();
if (override) {
return resolveUserPath(override);
return resolveUserPath(override, env);
}
const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent");
return resolveUserPath(defaultAgentDir);
const defaultAgentDir = path.join(resolveStateDir(env), "agents", DEFAULT_AGENT_ID, "agent");
return resolveUserPath(defaultAgentDir, env);
}
export function ensureOpenClawAgentEnv(): string {

View File

@ -327,12 +327,16 @@ export function resolveAgentIdByWorkspacePath(
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 configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
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");
}

View File

@ -47,7 +47,10 @@ describe("auth profiles read-only external CLI sync", () => {
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({
type: "oauth",
provider: "qwen-portal",

View File

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

View File

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

View File

@ -381,7 +381,7 @@ function loadAuthProfileStoreForAgent(
if (asStore) {
// Runtime secret activation must remain read-only:
// sync external CLI credentials in-memory, but never persist while readOnly.
const synced = syncExternalCliCredentials(asStore);
const synced = syncExternalCliCredentials(asStore, { log: !readOnly });
if (synced && !readOnly) {
saveJsonFile(authPath, asStore);
}
@ -413,7 +413,7 @@ function loadAuthProfileStoreForAgent(
const mergedOAuth = mergeOAuthFileIntoStore(store);
// 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 shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
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 { withEnvAsync } from "../test-utils/env.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
import {
getApiKeyForModel,
hasAvailableAuthForProvider,
resolveApiKeyForProvider,
resolveEnvApiKey,
} from "./model-auth.js";
const envVar = (...parts: string[]) => parts.join("_");
@ -206,6 +211,40 @@ describe("getApiKeyForModel", () => {
);
});
it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => {
await withEnvAsync(
{
GEMINI_API_KEY: undefined,
GOOGLE_API_KEY: "google-test-key", // pragma: allowlist secret
},
async () => {
await expect(
hasAvailableAuthForProvider({
provider: "google",
store: { version: 1, profiles: {} },
}),
).resolves.toBe(true);
},
);
});
it("hasAvailableAuthForProvider returns false when no provider auth is available", async () => {
await withEnvAsync(
{
ZAI_API_KEY: undefined,
Z_AI_API_KEY: undefined,
},
async () => {
await expect(
hasAvailableAuthForProvider({
provider: "zai",
store: { version: 1, profiles: {} },
}),
).resolves.toBe(false);
},
);
});
it("resolves Synthetic API key from env", async () => {
await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => {
// pragma: allowlist secret

View File

@ -487,6 +487,56 @@ export function resolveModelAuthMode(
return "unknown";
}
export async function hasAvailableAuthForProvider(params: {
provider: string;
cfg?: OpenClawConfig;
preferredProfile?: string;
store?: AuthProfileStore;
agentDir?: string;
}): Promise<boolean> {
const { provider, cfg, preferredProfile } = params;
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
const authOverride = resolveProviderAuthOverride(cfg, provider);
if (authOverride === "aws-sdk") {
return true;
}
const order = resolveAuthProfileOrder({
cfg,
store,
provider,
preferredProfile,
});
for (const candidate of order) {
try {
const resolved = await resolveApiKeyForProfile({
cfg,
store,
profileId: candidate,
agentDir: params.agentDir,
});
if (resolved) {
return true;
}
} catch (err) {
log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
}
}
if (resolveEnvApiKey(provider)) {
return true;
}
if (resolveUsableCustomProviderApiKey({ cfg, provider })) {
return true;
}
if (resolveSyntheticLocalProviderAuth({ cfg, provider })) {
return true;
}
return authOverride === undefined && normalizeProviderId(provider) === "amazon-bedrock";
}
export async function getApiKeyForModel(params: {
model: Model<Api>;
cfg?: OpenClawConfig;

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