Merge branch 'main' into fix/sandbox-fakeowner-write-data-loss
This commit is contained in:
commit
031cd958af
67
.github/workflows/ci.yml
vendored
67
.github/workflows/ci.yml
vendored
@ -78,6 +78,50 @@ jobs:
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
changed-extensions:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }}
|
||||
changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure changed-extensions base commit
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
install-deps: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Detect changed extensions
|
||||
id: changed
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { listChangedExtensionIds } from "./scripts/test-extension.mjs";
|
||||
|
||||
const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" });
|
||||
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
|
||||
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
|
||||
EOF
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope]
|
||||
@ -205,6 +249,29 @@ jobs:
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
extension-fast:
|
||||
name: "extension-fast (${{ matrix.extension }})"
|
||||
needs: [docs-scope, changed-scope, changed-extensions]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_extensions_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run changed extension tests
|
||||
run: pnpm test:extension ${{ matrix.extension }}
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
|
||||
@ -1 +1,2 @@
|
||||
**/node_modules/
|
||||
docs/.generated/
|
||||
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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(""))
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -284,7 +284,8 @@ Manage extensions and their config:
|
||||
|
||||
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
|
||||
- `openclaw plugins info <id>` — show details for a plugin.
|
||||
- `openclaw plugins install <path|.tgz|npm-spec>` — install a plugin (or add a plugin path to `plugins.load.paths`).
|
||||
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
|
||||
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
|
||||
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
|
||||
- `openclaw plugins doctor` — report plugin load errors.
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
|
||||
summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, doctor)"
|
||||
read_when:
|
||||
- You want to install or manage Gateway plugins or compatible bundles
|
||||
- You want to debug plugin load failures
|
||||
@ -28,6 +28,7 @@ openclaw plugins uninstall <id>
|
||||
openclaw plugins doctor
|
||||
openclaw plugins update <id>
|
||||
openclaw plugins update --all
|
||||
openclaw plugins marketplace list <marketplace>
|
||||
```
|
||||
|
||||
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
|
||||
@ -46,6 +47,8 @@ capabilities.
|
||||
```bash
|
||||
openclaw plugins install <path-or-spec>
|
||||
openclaw plugins install <npm-spec> --pin
|
||||
openclaw plugins install <plugin>@<marketplace>
|
||||
openclaw plugins install <plugin> --marketplace <marketplace>
|
||||
```
|
||||
|
||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||
@ -65,6 +68,31 @@ name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
Claude marketplace installs are also supported.
|
||||
|
||||
Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's
|
||||
local registry cache at `~/.claude/plugins/known_marketplaces.json`:
|
||||
|
||||
```bash
|
||||
openclaw plugins marketplace list <marketplace-name>
|
||||
openclaw plugins install <plugin-name>@<marketplace-name>
|
||||
```
|
||||
|
||||
Use `--marketplace` when you want to pass the marketplace source explicitly:
|
||||
|
||||
```bash
|
||||
openclaw plugins install <plugin-name> --marketplace <marketplace-name>
|
||||
openclaw plugins install <plugin-name> --marketplace <owner/repo>
|
||||
openclaw plugins install <plugin-name> --marketplace ./my-marketplace
|
||||
```
|
||||
|
||||
Marketplace sources can be:
|
||||
|
||||
- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json`
|
||||
- a local marketplace root or `marketplace.json` path
|
||||
- a GitHub repo shorthand such as `owner/repo`
|
||||
- a git URL
|
||||
|
||||
For local paths and archives, OpenClaw auto-detects:
|
||||
|
||||
- native OpenClaw plugins (`openclaw.plugin.json`)
|
||||
@ -114,7 +142,8 @@ openclaw plugins update --all
|
||||
openclaw plugins update <id> --dry-run
|
||||
```
|
||||
|
||||
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
|
||||
Updates apply to tracked installs in `plugins.installs`, currently npm and
|
||||
marketplace installs.
|
||||
|
||||
When a stored integrity hash exists and the fetched artifact hash changes,
|
||||
OpenClaw prints a warning and asks for confirmation before proceeding. Use
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.*`.
|
||||
|
||||
13
extensions/bluebubbles/src/actions.runtime.ts
Normal file
13
extensions/bluebubbles/src/actions.runtime.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
export {
|
||||
addBlueBubblesParticipant,
|
||||
editBlueBubblesMessage,
|
||||
leaveBlueBubblesChat,
|
||||
removeBlueBubblesParticipant,
|
||||
renameBlueBubblesChat,
|
||||
setGroupIconBlueBubbles,
|
||||
unsendBlueBubblesMessage,
|
||||
} from "./chat.js";
|
||||
export { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
export { sendBlueBubblesReaction } from "./reactions.js";
|
||||
export { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
@ -12,24 +12,18 @@ import {
|
||||
type ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import {
|
||||
editBlueBubblesMessage,
|
||||
unsendBlueBubblesMessage,
|
||||
renameBlueBubblesChat,
|
||||
setGroupIconBlueBubbles,
|
||||
addBlueBubblesParticipant,
|
||||
removeBlueBubblesParticipant,
|
||||
leaveBlueBubblesChat,
|
||||
} from "./chat.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
|
||||
let actionsRuntimePromise: Promise<typeof import("./actions.runtime.js")> | null = null;
|
||||
|
||||
function loadBlueBubblesActionsRuntime() {
|
||||
actionsRuntimePromise ??= import("./actions.runtime.js");
|
||||
return actionsRuntimePromise;
|
||||
}
|
||||
|
||||
const providerId = "bluebubbles";
|
||||
|
||||
function mapTarget(raw: string): BlueBubblesSendTarget {
|
||||
@ -99,6 +93,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
const runtime = await loadBlueBubblesActionsRuntime();
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
@ -147,7 +142,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
|
||||
}
|
||||
|
||||
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
|
||||
const resolved = await runtime.resolveChatGuidForTarget({ baseUrl, password, target });
|
||||
if (!resolved) {
|
||||
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
|
||||
}
|
||||
@ -173,11 +168,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||
requireKnownShortId: true,
|
||||
});
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
await runtime.sendBlueBubblesReaction({
|
||||
chatGuid: resolvedChatGuid,
|
||||
messageGuid: messageId,
|
||||
emoji,
|
||||
@ -218,11 +215,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||
requireKnownShortId: true,
|
||||
});
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||
|
||||
await editBlueBubblesMessage(messageId, newText, {
|
||||
await runtime.editBlueBubblesMessage(messageId, newText, {
|
||||
...opts,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||
@ -242,10 +241,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||
requireKnownShortId: true,
|
||||
});
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
await unsendBlueBubblesMessage(messageId, {
|
||||
await runtime.unsendBlueBubblesMessage(messageId, {
|
||||
...opts,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
});
|
||||
@ -276,10 +277,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||
requireKnownShortId: true,
|
||||
});
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||
...opts,
|
||||
replyToMessageGuid: messageId,
|
||||
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
@ -313,7 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||
...opts,
|
||||
effectId,
|
||||
});
|
||||
@ -330,7 +333,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
|
||||
}
|
||||
|
||||
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
|
||||
await runtime.renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
|
||||
|
||||
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
||||
}
|
||||
@ -355,7 +358,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
// Decode base64 to buffer
|
||||
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
||||
|
||||
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
||||
await runtime.setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
||||
...opts,
|
||||
contentType: contentType ?? undefined,
|
||||
});
|
||||
@ -372,7 +375,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
|
||||
}
|
||||
|
||||
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
await runtime.addBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
|
||||
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
|
||||
}
|
||||
@ -386,7 +389,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
|
||||
}
|
||||
|
||||
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
await runtime.removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||
|
||||
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
|
||||
}
|
||||
@ -396,7 +399,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
assertPrivateApiEnabled();
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
await leaveBlueBubblesChat(resolvedChatGuid, opts);
|
||||
await runtime.leaveBlueBubblesChat(resolvedChatGuid, opts);
|
||||
|
||||
return jsonResult({ ok: true, left: resolvedChatGuid });
|
||||
}
|
||||
@ -427,7 +430,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
|
||||
}
|
||||
|
||||
const result = await sendBlueBubblesAttachment({
|
||||
const result = await runtime.sendBlueBubblesAttachment({
|
||||
to,
|
||||
buffer,
|
||||
filename,
|
||||
|
||||
6
extensions/bluebubbles/src/channel.runtime.ts
Normal file
6
extensions/bluebubbles/src/channel.runtime.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { sendBlueBubblesMedia } from "./media-send.js";
|
||||
export { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
export { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||
export { type BlueBubblesProbe, probeBlueBubbles } from "./probe.js";
|
||||
export { sendMessageBlueBubbles } from "./send.js";
|
||||
export { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||
@ -25,12 +25,8 @@ import {
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import type { BlueBubblesProbe } from "./channel.runtime.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
import { blueBubblesSetupAdapter } from "./setup-core.js";
|
||||
import { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
@ -41,6 +37,13 @@ import {
|
||||
parseBlueBubblesTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
let blueBubblesChannelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
|
||||
|
||||
function loadBlueBubblesChannelRuntime() {
|
||||
blueBubblesChannelRuntimePromise ??= import("./channel.runtime.js");
|
||||
return blueBubblesChannelRuntimePromise;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
@ -221,7 +224,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
idLabel: "bluebubblesSenderId",
|
||||
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
||||
await (
|
||||
await loadBlueBubblesChannelRuntime()
|
||||
).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
||||
cfg: cfg,
|
||||
});
|
||||
},
|
||||
@ -240,12 +245,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
||||
// Resolve short ID (e.g., "5") to full UUID
|
||||
const replyToMessageGuid = rawReplyToId
|
||||
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||
: "";
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||
@ -253,6 +259,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
return { channel: "bluebubbles", ...result };
|
||||
},
|
||||
sendMedia: async (ctx) => {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
|
||||
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
|
||||
mediaPath?: string;
|
||||
@ -262,7 +269,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
caption?: string;
|
||||
};
|
||||
const resolvedCaption = caption ?? text;
|
||||
const result = await sendBlueBubblesMedia({
|
||||
const result = await runtime.sendBlueBubblesMedia({
|
||||
cfg: cfg,
|
||||
to,
|
||||
mediaUrl,
|
||||
@ -290,7 +297,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
probeBlueBubbles({
|
||||
(await loadBlueBubblesChannelRuntime()).probeBlueBubbles({
|
||||
baseUrl: account.baseUrl,
|
||||
password: account.config.password ?? null,
|
||||
timeoutMs,
|
||||
@ -315,8 +322,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
const account = ctx.account;
|
||||
const webhookPath = resolveWebhookPathFromConfig(account.config);
|
||||
const webhookPath = runtime.resolveWebhookPathFromConfig(account.config);
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
@ -325,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
baseUrl: account.baseUrl,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
||||
return monitorBlueBubblesProvider({
|
||||
return runtime.monitorBlueBubblesProvider({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./provider.js";
|
||||
|
||||
describe("resolveDiscordRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: __testing.resolveDiscordRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open default when channels.discord is configured",
|
||||
defaultGroupPolicyUnderTest: "open",
|
||||
missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
||||
});
|
||||
|
||||
it("respects explicit provider policy", () => {
|
||||
const resolved = __testing.resolveDiscordRuntimeGroupPolicy({
|
||||
providerConfigPresent: false,
|
||||
groupPolicy: "disabled",
|
||||
});
|
||||
expect(resolved.groupPolicy).toBe("disabled");
|
||||
expect(resolved.providerMissingFallbackApplied).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,37 +0,0 @@
|
||||
import { describe, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { discordOutbound } from "./outbound-adapter.js";
|
||||
|
||||
function createHarness(params: {
|
||||
payload: ReplyPayload;
|
||||
sendResults?: Array<{ messageId: string }>;
|
||||
}) {
|
||||
const sendDiscord = vi.fn();
|
||||
primeSendMock(sendDiscord, { messageId: "dc-1", channelId: "123456" }, params.sendResults);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
deps: {
|
||||
sendDiscord,
|
||||
},
|
||||
};
|
||||
return {
|
||||
run: async () => await discordOutbound.sendPayload!(ctx),
|
||||
sendMock: sendDiscord,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
describe("discordOutbound sendPayload", () => {
|
||||
installSendPayloadContractSuite({
|
||||
channel: "discord",
|
||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
||||
createHarness,
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import 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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./monitor-provider.js";
|
||||
|
||||
describe("resolveIMessageRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: __testing.resolveIMessageRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open fallback when channels.imessage is configured",
|
||||
defaultGroupPolicyUnderTest: "disabled",
|
||||
missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import 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");
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/core";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
createOpenShellSandboxBackendFactory,
|
||||
createOpenShellSandboxBackendManager,
|
||||
|
||||
@ -11,13 +11,13 @@ import type {
|
||||
SandboxBackendHandle,
|
||||
SandboxBackendManager,
|
||||
SshSandboxSession,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
createRemoteShellSandboxFsBridge,
|
||||
disposeSshSandboxSession,
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
runSshSandboxCommand,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
buildExecRemoteCommand,
|
||||
buildRemoteCommand,
|
||||
|
||||
@ -4,10 +4,10 @@ import {
|
||||
runPluginCommandWithTimeout,
|
||||
shellEscape,
|
||||
type SshSandboxSession,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import type { ResolvedOpenShellPluginConfig } from "./config.js";
|
||||
|
||||
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core";
|
||||
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
export type OpenShellExecContext = {
|
||||
config: ResolvedOpenShellPluginConfig;
|
||||
|
||||
@ -5,7 +5,7 @@ import type {
|
||||
SandboxFsBridge,
|
||||
SandboxFsStat,
|
||||
SandboxResolvedPath,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import type { OpenShellSandboxBackend } from "./backend.js";
|
||||
import { movePathWithCopyFallback } from "./mirror.js";
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
type RemoteShellSandboxHandle,
|
||||
type SandboxContext,
|
||||
type SandboxFsBridge,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
export function createOpenShellRemoteFsBridge(params: {
|
||||
sandbox: SandboxContext;
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
import {
|
||||
buildSglangProvider,
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
||||
discoverOpenAICompatibleSelfHostedProvider,
|
||||
emptyPluginConfigSchema,
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
SGLANG_DEFAULT_API_KEY_ENV_VAR,
|
||||
SGLANG_DEFAULT_BASE_URL,
|
||||
SGLANG_MODEL_PLACEHOLDER,
|
||||
SGLANG_PROVIDER_LABEL,
|
||||
} from "../../src/agents/sglang-defaults.js";
|
||||
|
||||
const PROVIDER_ID = "sglang";
|
||||
const DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1";
|
||||
|
||||
async function loadProviderSetup() {
|
||||
return await import("openclaw/plugin-sdk/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: {
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
||||
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
|
||||
import { createSignalEventHandler } from "./event-handler.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import 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");
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -3,10 +3,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { App } from "@slack/bolt";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js";
|
||||
import type { OpenClawConfig } from "../../../../../src/config/config.js";
|
||||
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js";
|
||||
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./provider.js";
|
||||
|
||||
describe("resolveSlackRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: __testing.resolveSlackRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open default when channels.slack is configured",
|
||||
defaultGroupPolicyUnderTest: "open",
|
||||
missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
||||
});
|
||||
});
|
||||
94
extensions/slack/src/monitor/provider.interop.test.ts
Normal file
94
extensions/slack/src/monitor/provider.interop.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -298,6 +298,66 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends error replies silently when silentErrorReplies is enabled", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
telegramCfg: { silentErrorReplies: true },
|
||||
});
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps error replies notifying by default", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({ context: createContext() });
|
||||
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: false,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps fallback replies silent after an error reply is skipped", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
dispatcherOptions.onSkip?.(
|
||||
{ text: "oops", isError: true },
|
||||
{ kind: "final", reason: "empty" },
|
||||
);
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
telegramCfg: { silentErrorReplies: true },
|
||||
});
|
||||
|
||||
expect(deliverReplies).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ text: expect.any(String) })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps block streaming enabled when session reasoning level is on", async () => {
|
||||
loadSessionStore.mockReturnValue({
|
||||
s1: { reasoningLevel: "on" },
|
||||
|
||||
@ -465,6 +465,7 @@ export const dispatchTelegramMessage = async ({
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
replyQuoteText,
|
||||
};
|
||||
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||
const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
|
||||
if (payload.text === text) {
|
||||
return payload;
|
||||
@ -476,6 +477,7 @@ export const dispatchTelegramMessage = async ({
|
||||
...deliveryBaseOptions,
|
||||
replies: [payload],
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
silent: silentErrorReplies && payload.isError === true,
|
||||
});
|
||||
if (result.delivered) {
|
||||
deliveryState.markDelivered();
|
||||
@ -513,6 +515,7 @@ export const dispatchTelegramMessage = async ({
|
||||
});
|
||||
|
||||
let queuedFinal = false;
|
||||
let hadErrorReplyFailureOrSkip = false;
|
||||
|
||||
if (statusReactionController) {
|
||||
void statusReactionController.setThinking();
|
||||
@ -539,6 +542,9 @@ export const dispatchTelegramMessage = async ({
|
||||
...prefixOptions,
|
||||
typingCallbacks,
|
||||
deliver: async (payload, info) => {
|
||||
if (payload.isError === true) {
|
||||
hadErrorReplyFailureOrSkip = true;
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
// Assistant callbacks are fire-and-forget; ensure queued boundary
|
||||
// rotations/partials are applied before final delivery mapping.
|
||||
@ -652,7 +658,10 @@ export const dispatchTelegramMessage = async ({
|
||||
await flushBufferedFinalAnswer();
|
||||
}
|
||||
},
|
||||
onSkip: (_payload, info) => {
|
||||
onSkip: (payload, info) => {
|
||||
if (payload.isError === true) {
|
||||
hadErrorReplyFailureOrSkip = true;
|
||||
}
|
||||
if (info.reason !== "silent") {
|
||||
deliveryState.markNonSilentSkip();
|
||||
}
|
||||
@ -809,6 +818,7 @@ export const dispatchTelegramMessage = async ({
|
||||
const result = await deliverReplies({
|
||||
replies: [{ text: fallbackText }],
|
||||
...deliveryBaseOptions,
|
||||
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
|
||||
});
|
||||
sentFallback = result.delivered;
|
||||
}
|
||||
|
||||
@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: {
|
||||
cfg: OpenClawConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params;
|
||||
const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params;
|
||||
return registerAndResolveCommandHandlerBase({
|
||||
commandName: "status",
|
||||
cfg,
|
||||
allowFrom: allowFrom ?? ["*"],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
useAccessGroups: true,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
});
|
||||
}
|
||||
@ -209,6 +211,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
useAccessGroups: boolean;
|
||||
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
@ -220,6 +223,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
} = params;
|
||||
const commandHandlers = new Map<string, TelegramCommandHandler>();
|
||||
@ -239,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
}),
|
||||
});
|
||||
@ -254,6 +259,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||
}): {
|
||||
handler: TelegramCommandHandler;
|
||||
@ -265,6 +271,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
useAccessGroups,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
} = params;
|
||||
return registerAndResolveCommandHandlerBase({
|
||||
@ -273,6 +280,7 @@ function registerAndResolveCommandHandler(params: {
|
||||
allowFrom: allowFrom ?? [],
|
||||
groupAllowFrom: groupAllowFrom ?? [],
|
||||
useAccessGroups: useAccessGroups ?? true,
|
||||
telegramCfg,
|
||||
resolveTelegramGroupConfig,
|
||||
});
|
||||
}
|
||||
@ -443,6 +451,31 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends native command error replies silently when silentErrorReplies is enabled", async () => {
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
|
||||
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
|
||||
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||
return dispatchReplyResult;
|
||||
},
|
||||
);
|
||||
|
||||
const { handler } = registerAndResolveStatusHandler({
|
||||
cfg: {},
|
||||
telegramCfg: { silentErrorReplies: true },
|
||||
});
|
||||
await handler(buildStatusCommandContext());
|
||||
|
||||
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
|
||||
| DeliverRepliesParams
|
||||
| undefined;
|
||||
expect(deliveredCall).toEqual(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||
|
||||
@ -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 () => {}),
|
||||
}));
|
||||
|
||||
@ -290,4 +290,56 @@ describe("registerTelegramNativeCommands", () => {
|
||||
);
|
||||
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
|
||||
});
|
||||
|
||||
it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => {
|
||||
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
|
||||
|
||||
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
|
||||
{
|
||||
name: "plug",
|
||||
description: "Plugin command",
|
||||
},
|
||||
] as never);
|
||||
pluginCommandMocks.matchPluginCommand.mockReturnValue({
|
||||
command: { key: "plug", requireAuth: false },
|
||||
args: undefined,
|
||||
} as never);
|
||||
pluginCommandMocks.executePluginCommand.mockResolvedValue({
|
||||
text: "plugin failed",
|
||||
isError: true,
|
||||
} as never);
|
||||
|
||||
registerTelegramNativeCommands({
|
||||
...buildParams({}),
|
||||
bot: {
|
||||
api: {
|
||||
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
|
||||
commandHandlers.set(name, cb);
|
||||
}),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||
telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig,
|
||||
});
|
||||
|
||||
const handler = commandHandlers.get("plug");
|
||||
expect(handler).toBeTruthy();
|
||||
await handler?.({
|
||||
match: "",
|
||||
message: {
|
||||
message_id: 1,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 456, username: "alice" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
silent: true,
|
||||
replies: [expect.objectContaining({ isError: true })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -363,6 +363,7 @@ export const registerTelegramNativeCommands = ({
|
||||
shouldSkipUpdate,
|
||||
opts,
|
||||
}: RegisterTelegramNativeCommandsParams) => {
|
||||
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||
const boundRoute =
|
||||
nativeEnabled && nativeSkillsEnabled
|
||||
? resolveAgentRoute({ cfg, channel: "telegram", accountId })
|
||||
@ -734,7 +735,6 @@ export const registerTelegramNativeCommands = ({
|
||||
typeof telegramCfg.blockStreaming === "boolean"
|
||||
? !telegramCfg.blockStreaming
|
||||
: undefined;
|
||||
|
||||
const deliveryState = {
|
||||
delivered: false,
|
||||
skippedNonSilent: 0,
|
||||
@ -766,6 +766,7 @@ export const registerTelegramNativeCommands = ({
|
||||
const result = await deliverReplies({
|
||||
replies: [payload],
|
||||
...deliveryBaseOptions,
|
||||
silent: silentErrorReplies && payload.isError === true,
|
||||
});
|
||||
if (result.delivered) {
|
||||
deliveryState.delivered = true;
|
||||
@ -885,6 +886,7 @@ export const registerTelegramNativeCommands = ({
|
||||
await deliverReplies({
|
||||
replies: [result],
|
||||
...deliveryBaseOptions,
|
||||
silent: silentErrorReplies && result.isError === true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../src/channels/plugins/contracts/suites.js";
|
||||
import {
|
||||
clearPluginInteractiveHandlers,
|
||||
registerPluginInteractiveHandler,
|
||||
} from "../../../src/plugins/interactive.js";
|
||||
import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
|
||||
import {
|
||||
answerCallbackQuerySpy,
|
||||
commandSpy,
|
||||
|
||||
@ -103,6 +103,7 @@ async function deliverTextReply(params: {
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
@ -129,6 +130,7 @@ async function deliverTextReply(params: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
},
|
||||
);
|
||||
@ -149,6 +151,7 @@ async function sendPendingFollowUpText(params: {
|
||||
text: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
@ -167,6 +170,7 @@ async function sendPendingFollowUpText(params: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
@ -196,6 +200,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
replyToId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
}): Promise<number | undefined> {
|
||||
@ -213,6 +218,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
silent: opts.silent,
|
||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (firstDeliveredMessageId == null) {
|
||||
@ -237,6 +243,7 @@ async function deliverMediaReply(params: {
|
||||
chunkText: ChunkTextFn;
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyQuoteText?: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyToId?: number;
|
||||
@ -282,6 +289,7 @@ async function deliverMediaReply(params: {
|
||||
...buildTelegramSendParams({
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
silent: params.silent,
|
||||
}),
|
||||
};
|
||||
if (isGif) {
|
||||
@ -375,6 +383,7 @@ async function deliverMediaReply(params: {
|
||||
replyToId: voiceFallbackReplyTo,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
});
|
||||
@ -404,6 +413,7 @@ async function deliverMediaReply(params: {
|
||||
replyToId: undefined,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyMarkup: params.replyMarkup,
|
||||
});
|
||||
}
|
||||
@ -451,6 +461,7 @@ async function deliverMediaReply(params: {
|
||||
text: pendingFollowUpText,
|
||||
replyMarkup: params.replyMarkup,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
@ -557,6 +568,8 @@ export async function deliverReplies(params: {
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
linkPreview?: boolean;
|
||||
/** When true, messages are sent with disable_notification. */
|
||||
silent?: boolean;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
@ -637,6 +650,7 @@ export async function deliverReplies(params: {
|
||||
replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
@ -654,6 +668,7 @@ export async function deliverReplies(params: {
|
||||
chunkText,
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyMarkup,
|
||||
replyToId,
|
||||
|
||||
@ -76,6 +76,7 @@ export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
export function buildTelegramSendParams(opts?: {
|
||||
replyToMessageId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
silent?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||
const params: Record<string, unknown> = {};
|
||||
@ -85,6 +86,9 @@ export function buildTelegramSendParams(opts?: {
|
||||
if (threadParams) {
|
||||
params.message_thread_id = threadParams.message_thread_id;
|
||||
}
|
||||
if (opts?.silent === true) {
|
||||
params.disable_notification = true;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@ -100,12 +104,14 @@ export async function sendTelegramText(
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
},
|
||||
): Promise<number> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
thread: opts?.thread,
|
||||
silent: opts?.silent,
|
||||
});
|
||||
// Add link_preview_options when link preview is disabled.
|
||||
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
||||
|
||||
@ -211,6 +211,30 @@ describe("deliverReplies", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets disable_notification when silent is true", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 5,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: "hello" }],
|
||||
runtime,
|
||||
bot,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
disable_notification: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits internal message:sent when session hook context is available", async () => {
|
||||
const runtime = createRuntime(false);
|
||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
|
||||
@ -645,6 +669,36 @@ describe("deliverReplies", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps disable_notification on voice fallback text when silent is true", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError());
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 5,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendVoice, sendMessage });
|
||||
|
||||
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||
|
||||
await deliverWith({
|
||||
replies: [
|
||||
{ mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true },
|
||||
],
|
||||
runtime,
|
||||
bot,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.stringContaining("Hello there"),
|
||||
expect.objectContaining({
|
||||
disable_notification: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
|
||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||
voiceError: createVoiceMessagesForbiddenError(),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js";
|
||||
|
||||
describe("resolveTelegramRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: resolveTelegramRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open fallback when channels.telegram is configured",
|
||||
defaultGroupPolicyUnderTest: "disabled",
|
||||
missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit defaults when provider config is missing",
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import 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");
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js";
|
||||
|
||||
let capturedCtx: unknown;
|
||||
let capturedDispatchParams: unknown;
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { describe } from "vitest";
|
||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
||||
import { __testing } from "./access-control.js";
|
||||
|
||||
describe("resolveWhatsAppRuntimeGroupPolicy", () => {
|
||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
||||
resolve: __testing.resolveWhatsAppRuntimeGroupPolicy,
|
||||
configuredLabel: "keeps open fallback when channels.whatsapp is configured",
|
||||
defaultGroupPolicyUnderTest: "disabled",
|
||||
missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set",
|
||||
missingDefaultLabel: "ignores explicit default policy when provider config is missing",
|
||||
});
|
||||
});
|
||||
@ -1,40 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { whatsappOutbound } from "./outbound-adapter.js";
|
||||
|
||||
function createHarness(params: {
|
||||
payload: ReplyPayload;
|
||||
sendResults?: Array<{ messageId: string }>;
|
||||
}) {
|
||||
const sendWhatsApp = vi.fn();
|
||||
primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "5511999999999@c.us",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
},
|
||||
};
|
||||
return {
|
||||
run: async () => await whatsappOutbound.sendPayload!(ctx),
|
||||
sendMock: sendWhatsApp,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
describe("whatsappOutbound sendPayload", () => {
|
||||
installSendPayloadContractSuite({
|
||||
channel: "whatsapp",
|
||||
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
|
||||
createHarness,
|
||||
});
|
||||
|
||||
it("trims leading whitespace for direct text sends", async () => {
|
||||
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
|
||||
|
||||
|
||||
@ -1,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");
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { normalizeProviderId } from "../../src/agents/model-selection.js";
|
||||
import { normalizeProviderId } from "../../src/agents/provider-id.js";
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }),
|
||||
}));
|
||||
|
||||
function baseCtx(payload: ReplyPayload) {
|
||||
return {
|
||||
cfg: {},
|
||||
to: "123456789",
|
||||
text: "",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zaloPlugin outbound sendPayload", () => {
|
||||
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalo"]>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("./send.js");
|
||||
mockedSend = vi.mocked(mod.sendMessageZalo);
|
||||
mockedSend.mockClear();
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
|
||||
});
|
||||
|
||||
installSendPayloadContractSuite({
|
||||
channel: "zalo",
|
||||
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
||||
createHarness: ({ payload, sendResults }) => {
|
||||
primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults);
|
||||
return {
|
||||
run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
||||
sendMock: mockedSend,
|
||||
to: "123456789",
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./monitor.js";
|
||||
|
||||
describe("zalo group policy access", () => {
|
||||
it("defaults missing provider config to allowlist", () => {
|
||||
const resolved = __testing.resolveZaloRuntimeGroupPolicy({
|
||||
providerConfigPresent: false,
|
||||
groupPolicy: undefined,
|
||||
defaultGroupPolicy: "open",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks all group messages when policy is disabled", () => {
|
||||
const decision = __testing.evaluateZaloGroupAccess({
|
||||
providerConfigPresent: true,
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./accounts.test-mocks.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js";
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
|
||||
@ -36,8 +33,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
||||
} as never);
|
||||
const mod = await import("./send.js");
|
||||
mockedSend = vi.mocked(mod.sendMessageZalouser);
|
||||
mockedSend.mockClear();
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
|
||||
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
|
||||
});
|
||||
|
||||
it("group target delegates with isGroup=true and stripped threadId", async () => {
|
||||
@ -110,19 +106,6 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
|
||||
});
|
||||
|
||||
installSendPayloadContractSuite({
|
||||
channel: "zalouser",
|
||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
||||
createHarness: ({ payload, sendResults }) => {
|
||||
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
|
||||
return {
|
||||
run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
||||
sendMock: mockedSend,
|
||||
to: "987654321",
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouserPlugin messaging target normalization", () => {
|
||||
|
||||
21
openclaw.mjs
21
openclaw.mjs
@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import module from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MIN_NODE_MAJOR = 22;
|
||||
const MIN_NODE_MINOR = 12;
|
||||
@ -47,6 +48,20 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
|
||||
const isModuleNotFoundError = (err) =>
|
||||
err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND";
|
||||
|
||||
const isDirectModuleNotFoundError = (err, specifier) => {
|
||||
if (!isModuleNotFoundError(err)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedUrl = new URL(specifier, import.meta.url);
|
||||
if ("url" in err && err.url === expectedUrl.href) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = "message" in err && typeof err.message === "string" ? err.message : "";
|
||||
return message.includes(fileURLToPath(expectedUrl));
|
||||
};
|
||||
|
||||
const installProcessWarningFilter = async () => {
|
||||
// Keep bootstrap warnings consistent with the TypeScript runtime.
|
||||
for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) {
|
||||
@ -57,7 +72,7 @@ const installProcessWarningFilter = async () => {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isModuleNotFoundError(err)) {
|
||||
if (isDirectModuleNotFoundError(err, specifier)) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
@ -72,8 +87,8 @@ const tryImport = async (specifier) => {
|
||||
await import(specifier);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Only swallow missing-module errors; rethrow real runtime errors.
|
||||
if (isModuleNotFoundError(err)) {
|
||||
// Only swallow direct entry misses; rethrow transitive resolution failures.
|
||||
if (isDirectModuleNotFoundError(err, specifier)) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
|
||||
20
package.json
20
package.json
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
283
scripts/test-extension.mjs
Normal file
@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { channelTestRoots } from "../vitest.channel-paths.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const pnpm = "pnpm";
|
||||
|
||||
function normalizeRelative(inputPath) {
|
||||
return inputPath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isTestFile(filePath) {
|
||||
return filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx");
|
||||
}
|
||||
|
||||
function collectTestFiles(rootPath) {
|
||||
const results = [];
|
||||
const stack = [rootPath];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current || !fs.existsSync(current)) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") {
|
||||
continue;
|
||||
}
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && isTestFile(fullPath)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function listChangedPaths(base, head = "HEAD") {
|
||||
if (!base) {
|
||||
throw new Error("A git base revision is required to list changed extensions.");
|
||||
}
|
||||
|
||||
return execFileSync("git", ["diff", "--name-only", base, head], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function hasExtensionPackage(extensionId) {
|
||||
return fs.existsSync(path.join(repoRoot, "extensions", extensionId, "package.json"));
|
||||
}
|
||||
|
||||
export function detectChangedExtensionIds(changedPaths) {
|
||||
const extensionIds = new Set();
|
||||
|
||||
for (const rawPath of changedPaths) {
|
||||
const relativePath = normalizeRelative(String(rawPath).trim());
|
||||
if (!relativePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionMatch = relativePath.match(/^extensions\/([^/]+)(?:\/|$)/);
|
||||
if (extensionMatch) {
|
||||
extensionIds.add(extensionMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairedCoreMatch = relativePath.match(/^src\/([^/]+)(?:\/|$)/);
|
||||
if (pairedCoreMatch && hasExtensionPackage(pairedCoreMatch[1])) {
|
||||
extensionIds.add(pairedCoreMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...extensionIds].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function listChangedExtensionIds(params = {}) {
|
||||
const base = params.base;
|
||||
const head = params.head ?? "HEAD";
|
||||
return detectChangedExtensionIds(listChangedPaths(base, head));
|
||||
}
|
||||
|
||||
function resolveExtensionDirectory(targetArg, cwd = process.cwd()) {
|
||||
if (targetArg) {
|
||||
const asGiven = path.resolve(cwd, targetArg);
|
||||
if (fs.existsSync(path.join(asGiven, "package.json"))) {
|
||||
return asGiven;
|
||||
}
|
||||
|
||||
const byName = path.join(repoRoot, "extensions", targetArg);
|
||||
if (fs.existsSync(path.join(byName, "package.json"))) {
|
||||
return byName;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown extension target "${targetArg}". Use an extension name like "slack" or a path under extensions/.`,
|
||||
);
|
||||
}
|
||||
|
||||
let current = cwd;
|
||||
while (true) {
|
||||
if (
|
||||
normalizeRelative(path.relative(repoRoot, current)).startsWith("extensions/") &&
|
||||
fs.existsSync(path.join(current, "package.json"))
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"No extension target provided, and current working directory is not inside extensions/.",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExtensionTestPlan(params = {}) {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const targetArg = params.targetArg;
|
||||
const extensionDir = resolveExtensionDirectory(targetArg, cwd);
|
||||
const extensionId = path.basename(extensionDir);
|
||||
const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir));
|
||||
|
||||
const roots = [relativeExtensionDir];
|
||||
const pairedCoreRoot = path.join(repoRoot, "src", extensionId);
|
||||
if (fs.existsSync(pairedCoreRoot)) {
|
||||
const pairedRelativeRoot = normalizeRelative(path.relative(repoRoot, pairedCoreRoot));
|
||||
if (collectTestFiles(pairedCoreRoot).length > 0) {
|
||||
roots.push(pairedRelativeRoot);
|
||||
}
|
||||
}
|
||||
|
||||
const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root));
|
||||
const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts";
|
||||
const testFiles = roots.flatMap((root) => collectTestFiles(path.join(repoRoot, root)));
|
||||
|
||||
return {
|
||||
config,
|
||||
extensionDir: relativeExtensionDir,
|
||||
extensionId,
|
||||
roots,
|
||||
testFiles: testFiles.map((filePath) => normalizeRelative(path.relative(repoRoot, filePath))),
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.error("Usage: pnpm test:extension <extension-name|path> [vitest args...]");
|
||||
console.error(" node scripts/test-extension.mjs [extension-name|path] [vitest args...]");
|
||||
console.error(
|
||||
" node scripts/test-extension.mjs --list-changed --base <git-ref> [--head <git-ref>]",
|
||||
);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const dryRun = rawArgs.includes("--dry-run");
|
||||
const json = rawArgs.includes("--json");
|
||||
const listChanged = rawArgs.includes("--list-changed");
|
||||
const args = rawArgs.filter(
|
||||
(arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json" && arg !== "--list-changed",
|
||||
);
|
||||
|
||||
let base = "";
|
||||
let head = "HEAD";
|
||||
const passthroughArgs = [];
|
||||
|
||||
if (listChanged) {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--base") {
|
||||
base = args[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--head") {
|
||||
head = args[index + 1] ?? "HEAD";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
passthroughArgs.push(arg);
|
||||
}
|
||||
} else {
|
||||
passthroughArgs.push(...args);
|
||||
}
|
||||
|
||||
if (listChanged) {
|
||||
let extensionIds;
|
||||
try {
|
||||
extensionIds = listChangedExtensionIds({ base, head });
|
||||
} catch (error) {
|
||||
printUsage();
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify({ base, head, extensionIds }, null, 2)}\n`);
|
||||
} else {
|
||||
for (const extensionId of extensionIds) {
|
||||
console.log(extensionId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let targetArg;
|
||||
if (passthroughArgs[0] && !passthroughArgs[0].startsWith("-")) {
|
||||
targetArg = passthroughArgs.shift();
|
||||
}
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = resolveExtensionTestPlan({ cwd: process.cwd(), targetArg });
|
||||
} catch (error) {
|
||||
printUsage();
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (plan.testFiles.length === 0) {
|
||||
console.error(`No tests found for ${plan.extensionDir}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
||||
} else {
|
||||
console.log(`[test-extension] ${plan.extensionId}`);
|
||||
console.log(`config: ${plan.config}`);
|
||||
console.log(`roots: ${plan.roots.join(", ")}`);
|
||||
console.log(`tests: ${plan.testFiles.length}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[test-extension] Running ${plan.testFiles.length} test files for ${plan.extensionId} with ${plan.config}`,
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
pnpm,
|
||||
["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...passthroughArgs],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
}
|
||||
|
||||
const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
|
||||
|
||||
if (import.meta.url === entryHref) {
|
||||
await run();
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
56
src/agents/auth-profiles/upsert-with-lock.ts
Normal file
56
src/agents/auth-profiles/upsert-with-lock.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { withFileLock } from "../../infra/file-lock.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION } from "./constants.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
|
||||
function coerceAuthProfileStore(raw: unknown): AuthProfileStore {
|
||||
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
||||
const profiles =
|
||||
record.profiles && typeof record.profiles === "object" && !Array.isArray(record.profiles)
|
||||
? { ...(record.profiles as Record<string, AuthProfileCredential>) }
|
||||
: {};
|
||||
const order =
|
||||
record.order && typeof record.order === "object" && !Array.isArray(record.order)
|
||||
? (record.order as Record<string, string[]>)
|
||||
: undefined;
|
||||
const lastGood =
|
||||
record.lastGood && typeof record.lastGood === "object" && !Array.isArray(record.lastGood)
|
||||
? (record.lastGood as Record<string, string>)
|
||||
: undefined;
|
||||
const usageStats =
|
||||
record.usageStats && typeof record.usageStats === "object" && !Array.isArray(record.usageStats)
|
||||
? (record.usageStats as Record<string, ProfileUsageStats>)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
version:
|
||||
typeof record.version === "number" && Number.isFinite(record.version)
|
||||
? record.version
|
||||
: AUTH_STORE_VERSION,
|
||||
profiles,
|
||||
...(order ? { order } : {}),
|
||||
...(lastGood ? { lastGood } : {}),
|
||||
...(usageStats ? { usageStats } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertAuthProfileWithLock(params: {
|
||||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
agentDir?: string;
|
||||
}): Promise<AuthProfileStore | null> {
|
||||
const authPath = resolveAuthStorePath(params.agentDir);
|
||||
ensureAuthStoreFile(authPath);
|
||||
|
||||
try {
|
||||
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
|
||||
const store = coerceAuthProfileStore(loadJsonFile(authPath));
|
||||
store.profiles[params.profileId] = params.credential;
|
||||
saveJsonFile(authPath, store);
|
||||
return store;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,12 @@ import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
|
||||
import {
|
||||
getApiKeyForModel,
|
||||
hasAvailableAuthForProvider,
|
||||
resolveApiKeyForProvider,
|
||||
resolveEnvApiKey,
|
||||
} from "./model-auth.js";
|
||||
|
||||
const envVar = (...parts: string[]) => parts.join("_");
|
||||
|
||||
@ -206,6 +211,40 @@ describe("getApiKeyForModel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
GEMINI_API_KEY: undefined,
|
||||
GOOGLE_API_KEY: "google-test-key", // pragma: allowlist secret
|
||||
},
|
||||
async () => {
|
||||
await expect(
|
||||
hasAvailableAuthForProvider({
|
||||
provider: "google",
|
||||
store: { version: 1, profiles: {} },
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("hasAvailableAuthForProvider returns false when no provider auth is available", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
ZAI_API_KEY: undefined,
|
||||
Z_AI_API_KEY: undefined,
|
||||
},
|
||||
async () => {
|
||||
await expect(
|
||||
hasAvailableAuthForProvider({
|
||||
provider: "zai",
|
||||
store: { version: 1, profiles: {} },
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Synthetic API key from env", async () => {
|
||||
await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => {
|
||||
// pragma: allowlist secret
|
||||
|
||||
@ -487,6 +487,56 @@ export function resolveModelAuthMode(
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function hasAvailableAuthForProvider(params: {
|
||||
provider: string;
|
||||
cfg?: OpenClawConfig;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
}): Promise<boolean> {
|
||||
const { provider, cfg, preferredProfile } = params;
|
||||
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
|
||||
|
||||
const authOverride = resolveProviderAuthOverride(cfg, provider);
|
||||
if (authOverride === "aws-sdk") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider,
|
||||
preferredProfile,
|
||||
});
|
||||
for (const candidate of order) {
|
||||
try {
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg,
|
||||
store,
|
||||
profileId: candidate,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (resolved) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolveEnvApiKey(provider)) {
|
||||
return true;
|
||||
}
|
||||
if (resolveUsableCustomProviderApiKey({ cfg, provider })) {
|
||||
return true;
|
||||
}
|
||||
if (resolveSyntheticLocalProviderAuth({ cfg, provider })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return authOverride === undefined && normalizeProviderId(provider) === "amazon-bedrock";
|
||||
}
|
||||
|
||||
export async function getApiKeyForModel(params: {
|
||||
model: Model<Api>;
|
||||
cfg?: OpenClawConfig;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user