Compare commits

...

134 Commits

Author SHA1 Message Date
Peter Steinberger ccfeecb688 test: harden parallels macos dashboard smoke
Docker Release / validate_manual_backfill (push) Has been skipped
Docker Release / approve_manual_backfill (push) Has been skipped
Docker Release / build-amd64 (push) Failing after 39s
Docker Release / create-manifest (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
2026-03-23 16:13:20 -07:00
Peter Steinberger a921b5bdff test: fix update-cli default path assertion 2026-03-23 23:05:25 +00:00
Peter Steinberger 725a2cc2ca test: expand gemini live transcript stripping 2026-03-23 23:01:22 +00:00
Peter Steinberger 67dbb1ad42 test: update command coverage 2026-03-23 23:01:22 +00:00
Peter Steinberger d67efbfbd3 test: stabilize test isolation 2026-03-23 23:01:22 +00:00
Sally O'Malley ae336d1602 Doctor: prune stale plugin allowlist and entry refs (#53187)
Signed-off-by: sallyom <somalley@redhat.com>
2026-03-23 18:58:54 -04:00
Vincent Koc 03231c0633 fix(auth): prevent stale auth store reverts (#53211) 2026-03-23 15:56:46 -07:00
Peter Steinberger 47bdc36831 test: make update-cli checkout path assertion platform-safe 2026-03-23 22:54:32 +00:00
Vincent Koc 19295994f3 fix(ci): stabilize whatsapp extension checks 2026-03-23 15:50:19 -07:00
Vincent Koc 6f5df14308 test(whatsapp): preserve harness session exports 2026-03-23 15:50:19 -07:00
Vincent Koc 7b03502175 test(whatsapp): preserve media test module exports 2026-03-23 15:50:19 -07:00
Vincent Koc e87a64f8d0 test(whatsapp): preserve session exports in login coverage 2026-03-23 15:50:19 -07:00
Vincent Koc dad6018230 test(whatsapp): stabilize login coverage in shared workers 2026-03-23 15:50:19 -07:00
Peter Steinberger e808f8d6ef docs(changelog): reorder release highlights 2026-03-23 15:44:18 -07:00
Onur 6ab6e7a493 Release: document manual macOS asset upload (#53178)
* Release: document manual macOS asset upload

* Release: document macOS smoke-test mode
2026-03-23 23:19:52 +01:00
Robin Waslander fb6588cb99 fix(diagnostics): redact credentials from cache-trace diagnostic output
Refs #53103
2026-03-23 22:59:09 +01:00
Onur 6e8d5cd578 Release: privatize macOS publish flow (#53166) 2026-03-23 22:35:51 +01:00
George Zhang ade0182ae0 feat(modelstudio): add standard (pay-as-you-go) DashScope endpoints for Qwen (#43878)
Add Standard API Key auth methods for China (dashscope.aliyuncs.com)
and Global/Intl (dashscope-intl.aliyuncs.com) pay-as-you-go endpoints
alongside the existing Coding Plan (subscription) endpoints.

Also updates group label to 'Qwen (Alibaba Cloud Model Studio)' and
fixes glm-4.7 -> glm-5 in Coding Plan note messages.

Co-authored-by: wenmeng zhou <wenmengzhou@users.noreply.github.com>
2026-03-23 14:35:19 -07:00
Ayush Ojha 64f4df1886 fix: generalize api_error detection for fallback model triggering (#49611)
Co-authored-by: Ayush Ojha <7945279+ayushozha@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
2026-03-24 00:34:27 +03:00
Vincent Koc 3814f956d1 Infra: tighten shell-wrapper positional-argv allowlist matching (#53133)
* Infra: tighten shell carrier allowlist matching

* fix(security): tighten shell carrier allowlist matcher
2026-03-23 14:11:38 -07:00
theo674 dbe7da7684 fix: prevent delivery-mirror re-delivery and raise Slack chunk limit (#45489)
Merged via squash.

Prepared head SHA: c7664c7b6e026b0d197fc9b53065d13bef05176a
Co-authored-by: theo674 <261068216+theo674@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-24 00:11:19 +03:00
Altay a53715e9d0 test(auth): align device scope expectations (#53151) 2026-03-23 23:56:02 +03:00
Vincent Koc c1131ba7e0 fix(plugins): accept clawhub uninstall specs 2026-03-23 13:52:27 -07:00
betoblair 1c7e98de16 docs(feishu): replace botName with name in config examples (#52753)
Merged via squash.

Prepared head SHA: 5237726c9cc329bc3795b5caf8227420f1f09285
Co-authored-by: haroldfabla2-hue <229189334+haroldfabla2-hue@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-23 23:48:33 +03:00
Vincent Koc 34c57487b4 fix(subagents): recheck timed-out announce waits (#53127)
Recheck timed-out subagent announce waits against the latest runtime snapshot before announcing timeout, and keep that recheck best-effort so transient gateway failures do not suppress the announcement.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-03-23 15:36:49 -05:00
Onur Solmaz bc95436a43 release: upload macos preflight artifacts (#53105)
* release: upload macos preflight artifacts

* release: speed up macos preflight

* release: use xlarge macos runner

* release: skip dmg path in macos preflight
2026-03-23 21:34:51 +01:00
Peter Steinberger f698774324 build: finalize 2026.3.23 release 2026-03-23 13:08:33 -07:00
Nimrod Gutman b98f3634c4 fix(memory): bootstrap lancedb runtime on demand (#53111)
Bootstrap LanceDB into plugin runtime state on first use for packaged/global installs, keep @lancedb/lancedb plugin-local, and add regression coverage for bundled, cached, retry, and Nix fail-fast runtime paths.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-03-23 15:07:24 -05:00
Vincent Koc fd5496d1d3 docs(changelog): note skill secretref runtime fix 2026-03-23 13:05:32 -07:00
Vincent Koc 13e81870bb fix(agents): prefer runtime snapshot for skill secrets 2026-03-23 13:04:12 -07:00
Peter Steinberger 6c58277577 build: prepare 2026.3.23 2026-03-23 12:58:00 -07:00
Val Alexander 3e2b3bd2c5 Fix Control UI operator.read scope handling (#53110)
Preserve Control UI scopes through the device-auth bypass path, normalize implied operator device-auth scopes, ignore cached under-scoped operator tokens, and degrade read-backed main pages gracefully when a connection truly lacks operator.read.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
2026-03-23 14:57:21 -05:00
Tak Hoffman 99c84294f3 ci: shard bun test lane 2026-03-23 14:51:47 -05:00
Onur Solmaz 01c1fc797f release: harden preflight workflows (#53087)
* release: harden preflight-only workflows

* release: require main for publish runs

* release: select xcode for macos workflow

* release: retry flaky macos preflight steps
2026-03-23 20:30:46 +01:00
Tak Hoffman 8670f2cead fix(openai-codex): bootstrap proxy on oauth refresh (openclaw#53078)
Verified:
- pnpm install --frozen-lockfile
- pnpm exec vitest run extensions/openai/openai-codex-provider.runtime.test.ts extensions/openai/openai-provider.test.ts
2026-03-23 14:08:04 -05:00
Vincent Koc cdd797f943 docs(changelog): credit web search runtime fix 2026-03-23 12:01:17 -07:00
Peter Steinberger e599c66277 test: harden canvas host undici isolation
Docker Release / validate_manual_backfill (push) Has been skipped
Docker Release / approve_manual_backfill (push) Has been skipped
Docker Release / build-amd64 (push) Failing after 36s
Docker Release / create-manifest (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
2026-03-23 11:54:49 -07:00
Peter Steinberger 870b0d216a build: prepare 2026.3.23-beta.1 2026-03-23 11:54:49 -07:00
Vincent Koc 8fa91d283b fix(cli): preserve posix default git dir 2026-03-23 11:49:55 -07:00
Val Alexander 7a459045de fix packaged control ui asset lookup (#53081) 2026-03-23 13:45:04 -05:00
Peter Steinberger 323ae73b30 fix: bound tracked debounce key accounting 2026-03-23 11:33:06 -07:00
scoootscooob 708cccd43b docs(changelog): add missing recent fixes 2026-03-23 11:26:26 -07:00
scoootscooob cdc8bac466 fix(discord): reply on native command auth failures (#53072) 2026-03-23 11:20:58 -07:00
Peter Steinberger 1b69d9ee1a fix: preserve debounce and followup ordering (#52998) (thanks @osolmaz) 2026-03-23 11:11:06 -07:00
Vincent Koc 9b7cfd0bea fix(inbound): bound tracked debounce keys 2026-03-23 11:11:06 -07:00
Vincent Koc 02e07a157d fix(reply): clear idle followup callbacks 2026-03-23 11:11:06 -07:00
Vincent Koc 3de42e946a fix(reply): preserve no-debounce inbound concurrency 2026-03-23 11:11:06 -07:00
Vincent Koc 7bc8e67d2a Update CHANGELOG.md 2026-03-23 11:11:06 -07:00
Vincent Koc a35dcf608e fix(reply): refresh followup drain callbacks 2026-03-23 11:11:06 -07:00
Onur Solmaz bcaadc39ea Telegram: fix fire-and-forget debounce order 2026-03-23 11:11:06 -07:00
Onur Solmaz 9a34a602bd Telegram: preserve inbound debounce order 2026-03-23 11:11:06 -07:00
Vincent Koc b15462ebaf Revert "fix(plugins): enable bundled Brave web search plugin by default (#52072)"
This reverts commit 0ea3c4d5d8.
2026-03-23 11:04:02 -07:00
Peter Steinberger 80bd5ba728 fix(release): fail empty control ui tarballs 2026-03-23 11:03:14 -07:00
Quinn H. 0ea3c4d5d8 fix(plugins): enable bundled Brave web search plugin by default (#52072)
Brave is a bundled web search plugin but was missing from
BUNDLED_ENABLED_BY_DEFAULT, causing it to be filtered out during
provider resolution. This made web_search unavailable even when
plugins.entries.brave.enabled was configured.

Fixes #51937

Co-authored-by: Ubuntu <ubuntu@ip-172-26-10-234.us-west-2.compute.internal>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-23 11:01:02 -07:00
Vincent Koc dd586d59ed fix(mistral): repair max-token defaults and doctor migration (#53054)
* fix(mistral): repair max-token defaults and doctor migration

* fix(mistral): add missing small-model repair cap
2026-03-23 10:57:56 -07:00
Peter Steinberger ffb287e1de fix: harden update dev switch and refresh changelog 2026-03-23 10:56:35 -07:00
Nimrod Gutman 848414d7f2 docs(changelog): add channel catalog override note (#52988) (#53059) 2026-03-23 19:52:32 +02:00
TheRipper 36d6ba55e3 Release: fix npm release preflight under pnpm (#52985)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-23 10:51:09 -07:00
Peter Steinberger f9a7427e8e test: refresh thread-safe agent fixtures 2026-03-23 10:47:02 -07:00
Vincent Koc 2592eb0796 fix(gateway): guard openrouter auto pricing recursion (#53055) 2026-03-23 10:43:01 -07:00
Vincent Koc 3fe2f0a550 docs: fix CLI command tree, SDK import path, and tool group listing
- Remove non-existent 'secrets migrate' from CLI command tree
- Add actual secrets subcommands: audit, configure, apply
- Add missing plugin subcommands: inspect, uninstall, update, marketplace list
- Fix plugins info -> inspect (actual command name)
- Add message send and broadcast subcommands to command tree
- Remove misleading deprecated import from sdk-overview
- Add sessions_yield and subagents to group:sessions tool group docs
- Fix formatting
2026-03-23 10:40:41 -07:00
Jamil Zakirov 921a147196 Agents: fix runtime web_search provider selection (#53020)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-23 10:30:45 -07:00
scoootscooob df18063260 fix(clawhub): preserve XDG auth path on macOS 2026-03-23 10:21:28 -07:00
Vincent Koc d1c95c84b5 docs: fix nav ordering, missing pages, and stale model references
- Sort providers alphabetically in docs.json nav
- Sort channels alphabetically in docs.json nav (slack before synology-chat)
- Add install/migrating-matrix to Maintenance nav section (was orphaned)
- Remove zh-CN/plugins/architecture from nav (file does not exist)
- Add Voice Call to channels index page
- Add missing providers to providers index (DeepSeek, GitHub Copilot, OpenCode Go, Synthetic)
- Sort providers index alphabetically
- Update stale claude-3-5-sonnet model reference to claude-sonnet-4-6 in webhook docs
2026-03-23 10:20:15 -07:00
scoootscooob 5b09463bc3 fix(clawhub): honor macOS auth config path (#53034) 2026-03-23 10:14:23 -07:00
Vincent Koc b4dd600b37 fix(browser): reuse running loopback browser after probe miss 2026-03-23 10:05:26 -07:00
Vincent Koc a1df10caac fix(config): ignore stale plugin allow entries 2026-03-23 09:57:46 -07:00
Luke d98e3a1ea9 Tests: fix fresh-main regressions (#53011)
* Tests: fix fresh-main regressions

* Tests: avoid chat notice cache priming

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-23 09:54:50 -07:00
Vincent Koc c55d4f63eb fix(release): raise npm pack size budget 2026-03-23 09:54:28 -07:00
Vincent Koc 071de383ff fix(clawhub): resolve auth token for skill browsing (#53017)
* fix(clawhub): resolve auth token for skill browsing

* docs(changelog): note clawhub skill auth fix
2026-03-23 09:53:40 -07:00
Vincent Koc d5dc6b6573 fix(gateway): require auth for canvas routes 2026-03-23 09:31:40 -07:00
Vincent Koc b0ce53a79c fix(voice-call): stabilize plivo v2 replay keys 2026-03-23 09:31:40 -07:00
Vincent Koc 50f6a2f136 fix(gateway): require admin for agent session reset 2026-03-23 09:31:39 -07:00
Nimrod Gutman 041c47419f fix(channels): preserve external catalog overrides (#52988)
* fix(channels): preserve external catalog overrides

* fix(channels): clarify catalog precedence

* fix(channels): respect overridden install specs
2026-03-23 18:08:17 +02:00
Vincent Koc 29ad211e76 fix(plugins): unblock Discord/Slack message tool sends and Feishu media (#52991)
* fix(plugins): unblock Discord and Slack message tool payloads

* docs(changelog): note Discord Slack and Feishu message fixes
2026-03-23 09:04:57 -07:00
Vincent Koc 83e86482c6 docs(changelog): note release and matrix fixes 2026-03-23 08:51:46 -07:00
Vincent Koc 93df5f613e fix(gateway): avoid probe false negatives after connect 2026-03-23 08:49:31 -07:00
Vincent Koc 1e5f38a1a8 fix(matrix): avoid duplicate runtime api exports 2026-03-23 08:46:58 -07:00
Vincent Koc beadd4c553 fix(gateway): harden supervised lock and browser attach readiness 2026-03-23 08:42:00 -07:00
Nimrod Gutman b84a130788 fix(release): preserve shipped channel surfaces in npm tar (#52913)
* fix(channels): ship official channel catalog (#52838)

* fix(release): keep shipped bundles in npm tar (#52838)

* build(release): fix rebased release-check helpers (#52838)
2026-03-23 17:39:22 +02:00
Vincent Koc 7299b42e2a fix(config): keep built-in auto-enable idempotent 2026-03-23 08:34:37 -07:00
Vincent Koc 2615402a2b Update CHANGELOG.md 2026-03-23 08:29:02 -07:00
Vincent Koc e68cbea5b4 fix(config): keep built-in channels out of plugin allowlists (#52964)
* fix(config): keep built-in channels out of plugin allowlists

* docs(changelog): note doctor whatsapp allowlist fix

* docs(changelog): move doctor whatsapp fix to top
2026-03-23 08:26:51 -07:00
Vincent Koc 70b235f312 fix(release): ship bundled plugins in pack artifacts 2026-03-23 08:22:00 -07:00
Vincent Koc 31675d65d4 fix(agents): preserve anthropic thinking block order (#52961) 2026-03-23 08:18:15 -07:00
Tak Hoffman 6872e079e6 Improve PR template regression prompts 2026-03-23 10:12:59 -05:00
Vincent Koc 8a8ed34716 Update CHANGELOG.md 2026-03-23 08:10:57 -07:00
Onur Solmaz 8ed33c2aff release: automate macOS publishing (#52853)
* release: automate macOS publishing

* release: keep mac appcast in openclaw repo

* release: add preflight-only release workflow runs

* release: keep appcast updates manual

* release: generate signed appcast as workflow artifact

* release: require preflight before publish

* release: require mac app for every release

* docs: clarify every release ships mac app

* release: document Sparkle feed and SHA rules

* release: keep publish flow tag-based

* release: stabilize mac appcast flow

* release: document local mac fallback
2026-03-23 16:04:53 +01:00
Vincent Koc e9078b3ff6 fix(msteams): isolate probe test env credentials 2026-03-23 08:00:54 -07:00
Sathvik Veerapaneni d2e8ed3632 fix: keep session transcript pointers fresh after compaction (#50688)
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-03-23 22:58:07 +08:00
Robin Waslander dd132ea77b release: add changelog for control UI tarball check 2026-03-23 15:40:36 +01:00
evann c278e8d7a8 release-check: include stderr/stdout when npm pack fails 2026-03-23 15:40:35 +01:00
evann fcafd8d4de release: verify control-ui assets are included in npm tarball 2026-03-23 15:40:34 +01:00
Peter Steinberger b2bf508dc5 docs: refresh generated config baseline 2026-03-23 14:38:57 +00:00
Peter Steinberger 05e31eb9ff test: stabilize channel ci gate 2026-03-23 14:32:10 +00:00
Peter Steinberger 3725b38335 fix: restore ci gates 2026-03-23 14:32:10 +00:00
wangchunyue 4e849ac127 fix: ensure env proxy dispatcher before MiniMax and OpenAI Codex OAuth flows (openclaw#52228)
Verified:
- pnpm install --frozen-lockfile
- NPM_CONFIG_CACHE=/tmp/openclaw-npm-cache-52228 pnpm build
- pnpm check
- pnpm test:macmini (failed on inherited pre-existing plugin contract test: src/plugins/contracts/registry.contract.test.ts missing deepseek in bundled provider contract registry outside this PR surface)

Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-23 09:26:34 -05:00
Peter Steinberger 8fdb9194d2 build: sync lockfile for mattermost plugin 2026-03-23 12:59:15 +00:00
Peter Steinberger b393effba6 test: harden channel suite isolation 2026-03-23 12:57:43 +00:00
Peter Steinberger 949d6be1d1 test: fix status plugin pagination expectation 2026-03-23 05:51:19 -07:00
Peter Steinberger 383c61e305 test: stabilize gateway thread harness 2026-03-23 05:44:34 -07:00
Onur Solmaz cd7d49b48e CI: remove npm release preview workflow (#52825)
* CI: remove npm release preview workflow

* Docs: align release maintainer skill with manual publish

* Docs: expand release maintainer skill flow
2026-03-23 13:35:57 +01:00
Peter Steinberger 41850c3880 refactor: isolate attempt context engine thread helpers 2026-03-23 05:27:44 -07:00
Peter Steinberger 76dc854963 test: remove replaced spawn-workspace monolith 2026-03-23 05:21:59 -07:00
Peter Steinberger 4e661d5c4b test: split attempt spawn-workspace thread fixtures 2026-03-23 05:21:59 -07:00
Peter Steinberger 08b5ba1a12 test: reset line webhook mocks between cases 2026-03-23 12:13:11 +00:00
Ayaan Zaidi 69b9e44762 fix: declare typebox runtime dep for mattermost plugin 2026-03-23 17:39:30 +05:30
Peter Steinberger 9f2330363e fix: refactor deepseek bundled plugin (#48762) (thanks @07akioni) 2026-03-23 05:06:07 -07:00
Bakhtier Sizhaev a0cb443aa3 fix: document Telegram asDocument alias (#52461) (thanks @bakhtiersizhaev)
* feat(telegram): add asDocument param to message tool

Adds `asDocument` as a user-facing alias for the existing `forceDocument`
parameter in the message tool. When set to `true`, media files (images,
videos, GIFs) are sent via `sendDocument` instead of `sendPhoto`/
`sendVideo`/`sendAnimation`, preserving the original file quality
without Telegram compression.

This is useful when agents need to deliver high-resolution images or
uncompressed files to users via Telegram.

`asDocument` is intentionally an alias rather than a replacement — the
existing `forceDocument` continues to work unchanged.

Changes:
- src/agents/tools/message-tool.ts: add asDocument to send schema
- src/agents/tools/telegram-actions.ts: OR asDocument into forceDocument
- src/infra/outbound/message-action-runner.ts: same OR logic for outbound path
- extensions/telegram/src/channel-actions.ts: read and forward asDocument
- src/channels/plugins/actions/actions.test.ts: add test case

* fix: restore channel-actions.ts to main version (rebase conflict fix)

* fix(test): match asDocument test payload to actual params structure

* fix(telegram): preserve forceDocument alias semantics

* fix: document Telegram asDocument alias (#52461) (thanks @bakhtiersizhaev)

---------

Co-authored-by: Бахтиер Сижаев <bkh@MacBook-Air.local>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-23 17:32:46 +05:30
Peter Steinberger 47db5abece test: inject thread-safe base seams 2026-03-23 04:59:15 -07:00
Peter Steinberger 8fd2fa13c6 test: avoid repo-root perf profile artifacts 2026-03-23 11:51:26 +00:00
Peter Steinberger f98811a67c fix: harden parallels smoke agent invocation 2026-03-23 04:51:07 -07:00
Peter Steinberger b3844d920a test: decouple vitest config checks from ambient env 2026-03-23 11:48:19 +00:00
Peter Steinberger e84ca730a3 test: stabilize e2e module isolation 2026-03-23 11:46:54 +00:00
Peter Steinberger 4f92eaad37 test: inject image-tool provider deps for raw threads 2026-03-23 04:40:58 -07:00
Peter Steinberger a6c7ad84ba build: prepare 2026.3.23 2026-03-23 04:40:13 -07:00
Peter Steinberger 6a3d4f9fad test: isolate pi model and reset-model thread fixtures 2026-03-23 04:36:06 -07:00
Peter Steinberger 6bcd9a801a test: inject thread-safe gateway and ACP seams 2026-03-23 04:34:42 -07:00
Peter Steinberger d841d02439 ci: stabilize windows and bun unit lanes 2026-03-23 11:28:25 +00:00
Peter Steinberger adf63eba9f test: raise timeout for slow provider auth normalization 2026-03-23 11:19:26 +00:00
Peter Steinberger f182c3a292 test: inject thread-safe deps for agent tools 2026-03-23 04:16:53 -07:00
Peter Steinberger fc9739313c test: harden channel suite isolation 2026-03-23 11:09:12 +00:00
Peter Steinberger e7d11f6c33 build: prepare 2026.3.22
Docker Release / validate_manual_backfill (push) Has been skipped
OpenClaw NPM Release / preview_openclaw_npm (push) Failing after 39s
OpenClaw NPM Release / publish_openclaw_npm (push) Has been skipped
Docker Release / approve_manual_backfill (push) Has been skipped
Docker Release / build-amd64 (push) Failing after 43s
Docker Release / create-manifest (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
2026-03-23 04:08:15 -07:00
Peter Steinberger 6f048f59cb test: stabilize trigger handling and hook e2e tests 2026-03-23 11:06:24 +00:00
Peter Steinberger b9efba1faf test: target gemini 3.1 flash alias 2026-03-23 10:59:12 +00:00
Peter Steinberger 4dcc39c25c test: snapshot ci timeout investigation 2026-03-23 10:44:34 +00:00
Peter Steinberger 6e012d7feb test: inject model runtime hooks for thread-safe tests 2026-03-23 03:25:42 -07:00
Peter Steinberger 2df10e81c8 test: isolate server-context browser harness imports 2026-03-23 03:23:30 -07:00
Peter Steinberger 9d3d7f9e65 fix: restart windows gateway after npm update 2026-03-23 10:20:59 +00:00
Peter Steinberger a0ad47440a test: stabilize live provider docker probes 2026-03-23 10:16:06 +00:00
Peter Steinberger 3ff2f85bad fix: stop browser server tests from launching real chrome 2026-03-23 03:15:02 -07:00
Peter Steinberger 203eebec2f test: split pi embedded model thread fixtures 2026-03-23 02:54:10 -07:00
Peter Steinberger 399fae33ca test: add parallels npm update smoke 2026-03-23 09:47:35 +00:00
Peter Steinberger 8067ae50fa fix: restore provider runtime lazy boundary 2026-03-23 09:35:18 +00:00
476 changed files with 15436 additions and 5449 deletions
@@ -16,13 +16,25 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Pass `--json` for machine-readable summaries.
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
## npm install then update
- Preferred entrypoint: `pnpm test:parallels:npm-update`
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
- Keep the aggregate npm-update Linux VM name aligned with the default Linux smoke VM (`Ubuntu 24.04.3 ARM64` on Peter's host today). Do not hardcode a different Linux guest in the wrapper unless the per-OS Linux smoke default changed too.
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
## macOS flow
- Preferred entrypoint: `pnpm test:parallels:macos`
- Default to the snapshot closest to `macOS 26.3.1 latest`.
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
@@ -11,7 +11,9 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- Do not change version numbers without explicit operator approval.
- Ask permission before any npm publish or release step.
- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy.
- This skill should be sufficient to drive the normal release flow end-to-end.
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
## Keep release channel naming aligned
@@ -31,8 +33,19 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
- `docs/install/updating.md`
- Peekaboo Xcode project and plist version fields
- Before creating a release tag, make every version location above match the version encoded by that tag.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
- “Bump version everywhere” means all version locations above except `appcast.xml`.
- Release signing and notary credentials live outside the repo in the private maintainer docs.
- Every OpenClaw release ships the npm package and macOS app together.
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
- That shared production Sparkle feed is stable-only. Beta mac releases may
upload assets to the GitHub prerelease, but they must not replace the shared
`appcast.xml` unless a separate beta feed exists.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
`APP_BUILD` / Sparkle build than the original release so existing installs
see it as newer.
## Build changelog-backed release notes
@@ -62,13 +75,139 @@ For a non-root smoke path:
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
```
## Check all relevant release builds
- Always validate the OpenClaw npm release path before creating the tag.
- Default release checks:
- `pnpm check`
- `pnpm build`
- `node --import tsx scripts/release-check.ts`
- `pnpm release:check`
- `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
- Check all release-related build surfaces touched by the release, not only the npm package.
- Include mac release readiness in preflight by running the public validation
workflow in `openclaw/openclaw` and the real mac preflight in
`openclaw/releases-private` for every release.
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
- The workflows remain tag-based. The agent is responsible for making sure
preflight runs complete successfully before any publish run starts.
- Any fix after preflight means a new commit. Delete and recreate the tag and
matching GitHub release from the fixed commit, then rerun preflight from
scratch before publishing.
- For stable mac releases, generate the signed `appcast.xml` before uploading
public release assets so the updater feed cannot lag the published binaries.
- Serialize stable appcast-producing runs across tags so two releases do not
generate replacement `appcast.xml` files from the same stale seed.
- For stable releases, confirm the latest beta already passed the broader release workflows before cutting stable.
- If any required build, packaging step, or release workflow is red, do not say the release is ready.
## Use the right auth flow
- Core `openclaw` publish uses GitHub trusted publishing.
- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
- OpenClaw publish uses GitHub trusted publishing.
- The publish run must be started manually with `workflow_dispatch`.
- The npm workflow and the private mac publish workflow accept
`preflight_only=true` to run validation/build/package steps without uploading
public release assets.
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
appcast generation, and do not prove release readiness.
- `preflight_only=true` on the npm workflow is also the right way to validate an
existing tag after publish; it should keep running the build checks even when
the npm version is already published.
- Validation-only runs may be dispatched from a branch when you are testing a
workflow change before merge.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the private repo; it does not build or publish macOS artifacts.
- Real mac preflight and real mac publish both use
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
- The private mac workflow runs on GitHub's xlarge macOS runner and uses a
SwiftPM cache because the Swift build/test/package path is CPU-heavy.
- Private mac preflight uploads notarized build artifacts as workflow artifacts
instead of uploading public GitHub release assets.
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
workflow artifacts and intentionally skip stable `appcast.xml` generation.
- npm preflight, public mac validation, and private mac preflight must all pass
before any real publish run starts.
- Real publish runs must be dispatched from `main`; branch-dispatched publish
attempts should fail before the protected environment is reached.
- The release workflows stay tag-based; rely on the documented release sequence
rather than workflow-level SHA pinning.
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
- Mac publish uses
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
build, signing, notarization, packaged mac artifact generation, and
stable-feed `appcast.xml` artifact generation.
- After a successful real private mac publish, the agent must download
`macos-release-<tag>` from that run and upload the packaged mac assets to the
existing GitHub release in `openclaw/openclaw`.
- For stable releases, the agent must also download the signed
`macos-appcast-<tag>` artifact from the successful private mac workflow and
then update `appcast.xml` on `main`.
- For beta mac releases, do not update the shared production `appcast.xml`
unless a separate beta Sparkle feed exists.
- The private repo targets a dedicated `mac-release` environment. If the GitHub
plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on private repo access and
CODEOWNERS until those settings can be enabled.
- Do not use `NPM_TOKEN` or the plugin OTP flow for OpenClaw releases.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
## Fallback local mac publish
- Keep the original local macOS publish workflow available as a fallback in case
CI/CD mac publishing is unavailable or broken.
- Preserve the existing maintainer workflow Peter uses: run it on a real Mac
with local signing, notary, and Sparkle credentials already configured.
- Follow the private maintainer macOS runbook for the local steps:
`scripts/package-mac-dist.sh` to build, sign, notarize, and package the app;
manual GitHub release asset upload; then `scripts/make_appcast.sh` plus the
`appcast.xml` commit to `main`.
- For stable tags, the local fallback may update the shared production
`appcast.xml`.
- For beta tags, the local fallback still publishes the mac assets but must not
update the shared production `appcast.xml` unless a separate beta feed exists.
- Treat the local workflow as fallback only. Prefer the CI/CD publish workflow
when it is working.
## Run the release sequence
1. Confirm the operator explicitly wants to cut a release.
2. Choose the exact target version and git tag.
3. Make every repo version location match that tag before creating it.
4. Update `CHANGELOG.md` and assemble the matching GitHub release notes.
5. Run the full preflight for all relevant release builds, including mac readiness.
6. Confirm the target npm version is not already published.
7. Create and push the git tag.
8. Create or refresh the matching GitHub release.
9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true`
and wait for it to pass.
10. Start `.github/workflows/macos-release.yml` in `openclaw/openclaw` and wait
for the public validation-only run to pass.
11. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
with `preflight_only=true` and wait for it to pass.
12. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes.
13. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
the real publish.
14. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
15. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish and wait for success.
16. Download `macos-release-<tag>` from the successful private mac run and
upload the `.zip`, `.dmg`, and `.dSYM.zip` artifacts to the existing
GitHub release in `openclaw/openclaw`.
17. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, and verify the feed.
18. For beta releases, publish the mac assets but expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
19. After publish, verify npm and the attached release artifacts.
## GHSA advisory work
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.
+4
View File
@@ -241,6 +241,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/byteplus/**"
"extensions: deepseek":
- changed-files:
- any-glob-to-any-file:
- "extensions/deepseek/**"
"extensions: anthropic":
- changed-files:
- any-glob-to-any-file:
+26
View File
@@ -31,6 +31,32 @@ Describe the problem and fix in 25 bullets:
- Closes #
- Related #
- [ ] This PR fixes a bug or regression
## Root Cause / Regression History (if applicable)
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
- Root cause:
- Missing detection / guardrail:
- Prior context (`git blame`, prior PR, issue, or refactor if known):
- Why this regressed now:
- If unknown, what was ruled out:
## Regression Test Plan (if applicable)
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`.
- Coverage level that should have caught this:
- [ ] Unit test
- [ ] Seam / integration test
- [ ] End-to-end test
- [ ] Existing coverage already sufficient
- Target test or file:
- Scenario the test should lock in:
- Why this is the smallest reliable guardrail:
- Existing test that already covers this (if any):
- If no new test is added, why not:
## User-visible / Behavior Changes
+12 -1
View File
@@ -187,6 +187,9 @@ jobs:
- name: Build dist
run: pnpm build
- name: Build Control UI
run: pnpm ui:build
- name: Upload dist artifact
uses: actions/upload-artifact@v7
with:
@@ -254,13 +257,21 @@ jobs:
command: pnpm protocol:check
- runtime: bun
task: test
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 1/2
- runtime: bun
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 2/2
- runtime: node
task: compat-node22
node_version: "22.x"
cache_key_suffix: "node22"
command: |
pnpm build
pnpm ui:build
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton
+86
View File
@@ -0,0 +1,86 @@
name: macOS Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing release tag to validate for macOS release handoff (for example v2026.3.22 or v2026.3.22-beta.1)
required: true
type: string
preflight_only:
description: Retained for operator compatibility; this public workflow is validation-only
required: true
default: true
type: boolean
concurrency:
group: macos-release-${{ inputs.tag }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.23.0"
jobs:
validate_macos_release_request:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
- name: Checkout selected tag
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Ensure matching GitHub release exists
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Summarize next step
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
{
echo "## Public macOS validation only"
echo
echo "This workflow no longer builds, signs, notarizes, or uploads macOS assets."
echo
echo "Next step:"
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\`."
echo "- Use \`preflight_only=true\` there for the full private mac preflight."
echo "- For the real publish path, the private run uploads \`macos-release-${RELEASE_TAG}\` as a workflow artifact. Download it and upload the packaged assets to the existing GitHub release in \`openclaw/openclaw\`."
echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`."
} >> "$GITHUB_STEP_SUMMARY"
+44 -68
View File
@@ -1,15 +1,17 @@
name: OpenClaw NPM Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: Release tag to publish (for example v2026.3.22, v2026.3.22-beta.1, or fallback v2026.3.22-1)
required: true
type: string
preflight_only:
description: Run validation/build only and skip the gated publish job
required: true
default: false
type: boolean
concurrency:
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
@@ -21,15 +23,25 @@ env:
PNPM_VERSION: "10.23.0"
jobs:
preview_openclaw_npm:
if: github.event_name == 'push'
preflight_openclaw_npm:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Setup Node environment
@@ -40,38 +52,12 @@ jobs:
install-bun: "false"
use-sticky-disk: "false"
- name: Print release plan
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
TAG_KIND="fallback correction"
else
TAG_KIND="standard"
fi
echo "Release plan for ${RELEASE_TAG}:"
echo "Resolved release SHA: ${RELEASE_SHA}"
echo "Resolved package version: ${PACKAGE_VERSION}"
echo "Resolved tag kind: ${TAG_KIND}"
if [[ "${TAG_KIND}" == "fallback correction" ]]; then
echo "Correction tag note: npm version remains ${PACKAGE_VERSION}"
fi
echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main"
echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check"
echo "Would run: npm view openclaw@${PACKAGE_VERSION} version"
echo "Would run: pnpm check"
echo "Would run: pnpm build"
echo "Would run: pnpm release:check"
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
run: |
set -euxo pipefail
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
# Fetch the full main ref so merge-base ancestry checks keep working
@@ -81,52 +67,51 @@ jobs:
- name: Ensure version is not already published
env:
RELEASE_TAG: ${{ github.ref_name }}
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
run: |
set -euxo pipefail
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
IS_CORRECTION_TAG=0
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
IS_CORRECTION_TAG=1
fi
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error."
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
exit 0
fi
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}"
else
echo "Previewing openclaw@${PACKAGE_VERSION}"
fi
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Check
run: |
set -euxo pipefail
pnpm check
run: pnpm check
- name: Build
run: |
set -euxo pipefail
pnpm build
run: pnpm build
- name: Verify release contents
run: |
set -euxo pipefail
pnpm release:check
run: pnpm release:check
- name: Preview publish command
run: bash scripts/openclaw-npm-publish.sh --dry-run
validate_publish_dispatch_ref:
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require main workflow ref for publish
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
exit 1
fi
publish_openclaw_npm:
if: github.event_name == 'workflow_dispatch'
# npm trusted publishing + provenance requires a GitHub-hosted runner.
needs: [preflight_openclaw_npm, validate_publish_dispatch_ref]
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
@@ -182,14 +167,5 @@ jobs:
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Check
run: pnpm check
- name: Build
run: pnpm build
- name: Verify release contents
run: pnpm release:check
- name: Publish
run: bash scripts/openclaw-npm-publish.sh --publish
+60
View File
@@ -6,6 +6,56 @@ Docs: https://docs.openclaw.ai
### Breaking
### Changes
- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
### Fixes
- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
## 2026.3.23
### Breaking
### Changes
### Fixes
- Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
- Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
- ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
- ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
- ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
- Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
- Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
- Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
- Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
- Config/plugins: treat stale unknown `plugins.allow` ids as warnings instead of fatal config errors, so recovery commands like `plugins install`, `doctor --fix`, and `status` still run when a plugin is missing locally. Fixes #52992. Thanks @vincentkoc.
- Doctor/WhatsApp: stop auto-enable from appending built-in channel ids like `whatsapp` to `plugins.allow`, so `openclaw doctor --fix` no longer writes schema-invalid plugin allowlist entries when repairing built-in channels. Fixes #52931. Thanks @vincentkoc.
- Telegram/auto-reply: preserve same-chat inbound debounce order without stranding stale busy-session followups, and keep same-key overflow turns ordered when tracked debounce keys are saturated. (#52998) Thanks @osolmaz.
- Discord/commands: return an explicit unauthorized reply for privileged native slash commands instead of falling through to Discord's misleading generic completion when auth gates reject the sender. Fixes #53041. Thanks @scoootscooob.
- Channels/catalog: let external channel catalogs override shipped fallback metadata and honor overridden npm specs during channel setup, so custom channel catalogs no longer fall back to bundled packages when a channel id matches. (#52988)
- Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.
- Agents/skills: prefer the active resolved runtime snapshot for embedded skill config and env injection, so `skills.entries.<skill>.apiKey` SecretRefs resolve correctly during embedded startup instead of failing on raw source config. Fixes #53098. Thanks @vincentkoc.
- Agents/subagents: recheck timed-out worker waits against the latest runtime snapshot before sending completion events, so fast-finishing workers stop being reported as timed out when they actually succeeded. Fixes #53106. Thanks @vincentkoc.
- Agents/Anthropic: preserve latest assistant thinking and redacted-thinking block ordering during transcript image sanitization so follow-up turns do not trip Anthropic's unmodified-thinking validation. (#52961) Thanks @vincentkoc.
- Gateway/probe: stop successful gateway handshakes from timing out as unreachable while post-connect detail RPCs are still loading, so slow devices report a reachable RPC failure instead of a false negative dead gateway. Fixes #52927. Thanks @vincentkoc.
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
- Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
## 2026.3.22
### Breaking
- Plugins/install: bare `openclaw plugins install <package>` now prefers ClawHub before npm for npm-safe names, and only falls back to npm when ClawHub does not have that package or version. Docs: https://docs.openclaw.ai/tools/clawhub
- 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. Docs: https://docs.openclaw.ai/gateway/doctor and https://docs.openclaw.ai/tools/browser (#47893) Thanks @vincentkoc.
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
@@ -24,6 +74,7 @@ Docs: https://docs.openclaw.ai
- Agents/media replies: migrate the remaining browser, canvas, and nodes snapshot outputs onto `details.media` so generated media keeps attaching to assistant replies after the collect-then-attach refactor. (#51731) Thanks @christianklotz.
- Android/contacts search: escape literal `%` and `_` in contact-name queries so searches like `100%` or `_id` no longer match unrelated contacts through SQL `LIKE` wildcards. (#41891) Thanks @Kaneki-x.
- Gateway/usage: include reset and deleted archived session transcripts in usage totals, session discovery, and archived-only session detail fallback so the Usage view no longer undercounts rotated sessions. (#43215) Thanks @rcrick.
- Channels/inbound debounce: reserve same-conversation timer-backed flush order so a buffered run cannot be overtaken by a newer message for the same key, preventing Telegram reply desync after long debounced turns. Fixes #52982. Thanks @vincentkoc and @osolmaz.
### Changes
@@ -101,11 +152,17 @@ Docs: https://docs.openclaw.ai
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
- Docs/plugins: add the community wecom plugin listing to the docs catalog. (#29905) Thanks @sliverp.
- Telegram/message tool: add `asDocument` as a user-facing alias for `forceDocument` on image and GIF sends, while preserving explicit `forceDocument` precedence when both flags are present. (#52461) Thanks @bakhtiersizhaev.
### Fixes
- Security/exec approvals: keep shell-wrapper positional-argv allowlist matching on real direct carriers only by rejecting single-quoted `$0`/`$n` tokens, disallowing newline-separated `exec`, and still accepting `exec --` carrier forms. Thanks @vincentkoc.
- Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
- Plugins/DeepSeek: refactor the bundled DeepSeek provider onto the shared single-provider plugin entry, move its coverage into the extension test lane, and keep bundled auth env-var metadata on the generated manifest path. (#48762) Thanks @07akioni.
- Web tools/search provider lists: keep onboarding, configure, and docs provider lists alphabetical while preserving the separate runtime auto-detect precedence used for credential-based provider selection.
- Media/Windows security: block remote-host `file://` media URLs and UNC/network paths before local filesystem resolution in core media loading and adjacent prompt/sandbox attachment seams, so the next release no longer allows structured local-media inputs to trigger outbound SMB credential handshakes on Windows. Thanks @RacerZ-fighting for reporting.
- Release/npm packaging: keep previously released bundled plugins and Control UI assets in published `openclaw` npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
- Plugins/Matrix: avoid duplicate `resolveMatrixAccountStringValues` runtime-api exports under Jiti so bundled Matrix installs no longer crash at startup with `Cannot redefine property: resolveMatrixAccountStringValues`. Fixes #52909 and #52891. Thanks @vincentkoc.
- Gateway/discovery: fail closed on unresolved Bonjour and DNS-SD service endpoints in CLI discovery, onboarding, and `gateway status` so TXT-only hints can no longer steer routing or SSH auto-target selection. Thanks @nexrin for reporting.
- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey.
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
@@ -321,6 +378,9 @@ Docs: https://docs.openclaw.ai
- Discord/pickers: keep `/codex_resume --browse-projects` picker callbacks alive in Discord by sharing component callback state across duplicate module graphs, preserving callback fallbacks, and acknowledging matched plugin interactions before dispatch. (#51260) Thanks @huntharo.
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
- Telegram/Mattermost message tool: keep plugin button schemas optional in isolated and cron sessions so plain sends do not fail validation when no current channel is active. (#52589) Thanks @tylerliu612.
- Release/npm publish: fail the npm release check when `dist/control-ui/index.html` is missing from the packed tarball, so broken Control UI asset releases are blocked before publish. Fixes #52808. (#52852) Thanks @kevinheinrichs.
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
## 2026.3.13
+2 -2
View File
@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026032200
versionName = "2026.3.22"
versionCode = 2026032300
versionName = "2026.3.23"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
+3 -3
View File
@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.22
OPENCLAW_MARKETING_VERSION = 2026.3.22
OPENCLAW_BUILD_VERSION = 202603220
OPENCLAW_GATEWAY_VERSION = 2026.3.23
OPENCLAW_MARKETING_VERSION = 2026.3.23
OPENCLAW_BUILD_VERSION = 202603230
#include? "../build/Version.xcconfig"
+1 -1
View File
@@ -99,7 +99,7 @@ def normalize_release_version(raw_value)
version = raw_value.to_s.strip.sub(/\Av/, "")
UI.user_error!("Missing root package.json version.") unless env_present?(version)
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.22 or 2026.3.22-beta.1.")
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected YYYY.M.D or YYYY.M.D-beta.N.")
end
version
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.22</string>
<string>2026.3.23</string>
<key>CFBundleVersion</key>
<string>202603220</string>
<string>202603230</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>
+121
View File
@@ -47450,6 +47450,127 @@
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.deepseek",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/deepseek-provider",
"help": "OpenClaw DeepSeek provider plugin (plugin: deepseek)",
"hasChildren": true
},
{
"path": "plugins.entries.deepseek.config",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/deepseek-provider Config",
"help": "Plugin-defined config payload for deepseek.",
"hasChildren": false
},
{
"path": "plugins.entries.deepseek.enabled",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Enable @openclaw/deepseek-provider",
"hasChildren": false
},
{
"path": "plugins.entries.deepseek.hooks",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Hook Policy",
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
"hasChildren": true
},
{
"path": "plugins.entries.deepseek.hooks.allowPromptInjection",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Prompt Injection Hooks",
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"hasChildren": false
},
{
"path": "plugins.entries.deepseek.subagent",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Subagent Policy",
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
"hasChildren": true
},
{
"path": "plugins.entries.deepseek.subagent.allowedModels",
"kind": "plugin",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Plugin Subagent Allowed Models",
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
"hasChildren": true
},
{
"path": "plugins.entries.deepseek.subagent.allowedModels.*",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "plugins.entries.deepseek.subagent.allowModelOverride",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Plugin Subagent Model Override",
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.device-pair",
"kind": "plugin",
+10 -1
View File
@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5619}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5628}
{"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}
@@ -4188,6 +4188,15 @@
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepseek-provider","help":"OpenClaw DeepSeek provider plugin (plugin: deepseek)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepseek.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepseek-provider Config","help":"Plugin-defined config payload for deepseek.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/deepseek-provider","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepseek.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.device-pair","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing","help":"Generate setup codes and approve device pairing requests. (plugin: device-pair)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.device-pair.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing Config","help":"Plugin-defined config payload for device-pair.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.device-pair.config.publicUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway URL","help":"Public WebSocket URL used for /pair setup codes (ws/wss or http/https).","hasChildren":false}
+1 -1
View File
@@ -86,7 +86,7 @@ Payload:
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `channel` optional (string): The messaging channel for delivery. Core channels: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `irc`, `googlechat`, `line`. Extension channels (plugins): `msteams`, `mattermost`, and others. Defaults to `last`.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for Microsoft Teams). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `model` optional (string): Model override (e.g., `anthropic/claude-sonnet-4-6` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
+3 -3
View File
@@ -185,7 +185,7 @@ Edit `~/.openclaw/openclaw.json`:
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "My AI assistant",
name: "My AI assistant",
},
},
},
@@ -494,12 +494,12 @@ openclaw pairing list feishu
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "Primary bot",
name: "Primary bot",
},
backup: {
appId: "cli_yyy",
appSecret: "yyy",
botName: "Backup bot",
name: "Backup bot",
enabled: false,
},
},
+2 -1
View File
@@ -26,11 +26,12 @@ Text is supported everywhere; media and reactions vary by channel.
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
- [Voice Call](/plugins/voice-call) — Telephony via Plivo or Twilio (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
+9 -2
View File
@@ -113,7 +113,9 @@ openclaw [--dev] [--profile <name>] <command>
audit
secrets
reload
migrate
audit
configure
apply
reset
uninstall
update
@@ -132,16 +134,21 @@ openclaw [--dev] [--profile <name>] <command>
check
plugins
list
info
inspect
install
uninstall
update
enable
disable
doctor
marketplace list
memory
status
index
search
message
send
broadcast
agent
agents
list
+8 -7
View File
@@ -950,6 +950,7 @@
"pages": [
"install/updating",
"install/migrating",
"install/migrating-matrix",
"install/uninstall",
"install/development-channels"
]
@@ -979,8 +980,8 @@
"channels/nextcloud-talk",
"channels/nostr",
"channels/signal",
"channels/synology-chat",
"channels/slack",
"channels/synology-chat",
"channels/telegram",
"channels/tlon",
"channels/twitch",
@@ -1180,25 +1181,26 @@
"pages": [
"providers/anthropic",
"providers/bedrock",
"providers/cloudflare-ai-gateway",
"providers/claude-max-api-proxy",
"providers/cloudflare-ai-gateway",
"providers/deepgram",
"providers/deepseek",
"providers/github-copilot",
"providers/glm",
"providers/google",
"providers/groq",
"providers/huggingface",
"providers/kilocode",
"providers/litellm",
"providers/glm",
"providers/minimax",
"providers/mistral",
"providers/modelstudio",
"providers/moonshot",
"providers/mistral",
"providers/nvidia",
"providers/ollama",
"providers/openai",
"providers/opencode-go",
"providers/opencode",
"providers/opencode-go",
"providers/openrouter",
"providers/perplexity-provider",
"providers/qianfan",
@@ -1206,8 +1208,8 @@
"providers/sglang",
"providers/synthetic",
"providers/together",
"providers/vercel-ai-gateway",
"providers/venice",
"providers/vercel-ai-gateway",
"providers/vllm",
"providers/volcengine",
"providers/xai",
@@ -1744,7 +1746,6 @@
{
"group": "扩展",
"pages": [
"zh-CN/plugins/architecture",
"zh-CN/plugins/voice-call",
"zh-CN/plugins/zalouser",
"zh-CN/plugins/manifest",
-3
View File
@@ -27,9 +27,6 @@ Always import from a specific subpath:
```typescript
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
// Deprecated — will be removed in the next major release
import { definePluginEntry } from "openclaw/plugin-sdk";
```
Each subpath is a small, self-contained module. This keeps startup fast and
+53
View File
@@ -0,0 +1,53 @@
---
summary: "DeepSeek setup (auth + model selection)"
read_when:
- You want to use DeepSeek with OpenClaw
- You need the API key env var or CLI auth choice
---
# DeepSeek
[DeepSeek](https://www.deepseek.com) provides powerful AI models with an OpenAI-compatible API.
- Provider: `deepseek`
- Auth: `DEEPSEEK_API_KEY`
- API: OpenAI-compatible
## Quick start
Set the API key (recommended: store it for the Gateway):
```bash
openclaw onboard --auth-choice deepseek-api-key
```
This will prompt for your API key and set `deepseek/deepseek-chat` as the default model.
## Non-interactive example
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice deepseek-api-key \
--deepseek-api-key "$DEEPSEEK_API_KEY" \
--skip-health \
--accept-risk
```
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `DEEPSEEK_API_KEY`
is available to that process (for example, in `~/.openclaw/.env` or via
`env.shellEnv`).
## Available models
| Model ID | Name | Type | Context |
| ------------------- | ------------------------ | --------- | ------- |
| `deepseek-chat` | DeepSeek Chat (V3.2) | General | 128K |
| `deepseek-reasoner` | DeepSeek Reasoner (V3.2) | Reasoning | 128K |
- **deepseek-chat** corresponds to DeepSeek-V3.2 in non-thinking mode.
- **deepseek-reasoner** corresponds to DeepSeek-V3.2 in thinking mode with chain-of-thought reasoning.
Get your API key at [platform.deepseek.com](https://platform.deepseek.com/api_keys).
+6 -2
View File
@@ -29,6 +29,8 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Amazon Bedrock](/providers/bedrock)
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [DeepSeek](/providers/deepseek)
- [GitHub Copilot](/providers/github-copilot)
- [GLM models](/providers/glm)
- [Google (Gemini)](/providers/google)
- [Groq (LPU inference)](/providers/groq)
@@ -42,15 +44,17 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [NVIDIA](/providers/nvidia)
- [Ollama (cloud + local models)](/providers/ollama)
- [OpenAI (API + Codex)](/providers/openai)
- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenCode](/providers/opencode)
- [OpenCode Go](/providers/opencode-go)
- [OpenRouter](/providers/openrouter)
- [Perplexity (web search)](/providers/perplexity-provider)
- [Qianfan](/providers/qianfan)
- [Qwen (OAuth)](/providers/qwen)
- [SGLang (local models)](/providers/sglang)
- [Synthetic](/providers/synthetic)
- [Together AI](/providers/together)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [vLLM (local models)](/providers/vllm)
- [Volcengine (Doubao)](/providers/volcengine)
- [xAI](/providers/xai)
+10 -1
View File
@@ -23,7 +23,7 @@ OpenClaw has three public release lanes:
- Do not zero-pad month or day
- `latest` means the current stable npm release
- `beta` means the current prerelease npm release
- Beta releases may ship before the macOS app catches up
- Every OpenClaw release ships the npm package and macOS app together
## Release cadence
@@ -32,6 +32,15 @@ OpenClaw has three public release lanes:
- Detailed release procedure, approvals, credentials, and recovery notes are
maintainer-only
## Release preflight
- Run `pnpm release:check` before every tagged release
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
(or the matching beta/correction tag) before approval
- npm release preflight fails closed unless the tarball includes both
`dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
so we do not ship an empty browser dashboard again
## Public references
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
+12 -12
View File
@@ -109,18 +109,18 @@ Per-agent override: `agents.list[].tools.profile`.
Use `group:*` shorthands in allow/deny lists:
| Group | Tools |
| ------------------ | ------------------------------------------------------------------------------ |
| `group:runtime` | exec, bash, process |
| `group:fs` | read, write, edit, apply_patch |
| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, session_status |
| `group:memory` | memory_search, memory_get |
| `group:web` | web_search, web_fetch |
| `group:ui` | browser, canvas |
| `group:automation` | cron, gateway |
| `group:messaging` | message |
| `group:nodes` | nodes |
| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) |
| Group | Tools |
| ------------------ | --------------------------------------------------------------------------------------------------------- |
| `group:runtime` | exec, bash, process |
| `group:fs` | read, write, edit, apply_patch |
| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, sessions_yield, subagents, session_status |
| `group:memory` | memory_search, memory_get |
| `group:web` | web_search, web_fetch |
| `group:ui` | browser, canvas |
| `group:automation` | cron, gateway |
| `group:messaging` | message |
| `group:nodes` | nodes |
| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) |
### Provider-specific restrictions
+3 -3
View File
@@ -190,7 +190,7 @@ openclaw channels add
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "My AI assistant",
name: "My AI assistant",
},
},
},
@@ -499,12 +499,12 @@ openclaw pairing list feishu
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "Primary bot",
name: "Primary bot",
},
backup: {
appId: "cli_yyy",
appSecret: "yyy",
botName: "Backup bot",
name: "Backup bot",
enabled: false,
},
},
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveProviderPluginChoice } from "../../src/plugins/provider-wizard.js";
import { registerSingleProviderPlugin } from "../../test/helpers/extensions/plugin-registration.js";
import deepseekPlugin from "./index.js";
describe("deepseek provider plugin", () => {
it("registers DeepSeek with api-key auth wizard metadata", () => {
const provider = registerSingleProviderPlugin(deepseekPlugin);
const resolved = resolveProviderPluginChoice({
providers: [provider],
choice: "deepseek-api-key",
});
expect(provider.id).toBe("deepseek");
expect(provider.label).toBe("DeepSeek");
expect(provider.envVars).toEqual(["DEEPSEEK_API_KEY"]);
expect(provider.auth).toHaveLength(1);
expect(resolved).not.toBeNull();
expect(resolved?.provider.id).toBe("deepseek");
expect(resolved?.method.id).toBe("api-key");
});
it("builds the static DeepSeek model catalog", async () => {
const provider = registerSingleProviderPlugin(deepseekPlugin);
expect(provider.catalog).toBeDefined();
const catalog = await provider.catalog!.run({
config: {},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
resolveProviderAuth: () => ({
apiKey: "test-key",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.api).toBe("openai-completions");
expect(catalog.provider.baseUrl).toBe("https://api.deepseek.com");
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
"deepseek-chat",
"deepseek-reasoner",
]);
expect(
catalog.provider.models?.find((model) => model.id === "deepseek-reasoner")?.reasoning,
).toBe(true);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildDeepSeekProvider } from "./provider-catalog.js";
const PROVIDER_ID = "deepseek";
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "DeepSeek Provider",
description: "Bundled DeepSeek provider plugin",
provider: {
label: "DeepSeek",
docsPath: "/providers/deepseek",
auth: [
{
methodId: "api-key",
label: "DeepSeek API key",
hint: "API key",
optionKey: "deepseekApiKey",
flagName: "--deepseek-api-key",
envVar: "DEEPSEEK_API_KEY",
promptMessage: "Enter DeepSeek API key",
defaultModel: DEEPSEEK_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyDeepSeekConfig(cfg),
wizard: {
choiceId: "deepseek-api-key",
choiceLabel: "DeepSeek API key",
groupId: "deepseek",
groupLabel: "DeepSeek",
groupHint: "API key",
},
},
],
catalog: {
buildProvider: buildDeepSeekProvider,
},
},
});
+35
View File
@@ -0,0 +1,35 @@
import {
buildDeepSeekModelDefinition,
DEEPSEEK_BASE_URL,
DEEPSEEK_MODEL_CATALOG,
} from "openclaw/plugin-sdk/provider-models";
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithModelCatalog,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const DEEPSEEK_DEFAULT_MODEL_REF = "deepseek/deepseek-chat";
export function applyDeepSeekProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[DEEPSEEK_DEFAULT_MODEL_REF] = {
...models[DEEPSEEK_DEFAULT_MODEL_REF],
alias: models[DEEPSEEK_DEFAULT_MODEL_REF]?.alias ?? "DeepSeek",
};
return applyProviderConfigWithModelCatalog(cfg, {
agentModels: models,
providerId: "deepseek",
api: "openai-completions",
baseUrl: DEEPSEEK_BASE_URL,
catalogModels: DEEPSEEK_MODEL_CATALOG.map(buildDeepSeekModelDefinition),
});
}
export function applyDeepSeekConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyDeepSeekProviderConfig(cfg),
DEEPSEEK_DEFAULT_MODEL_REF,
);
}
+27
View File
@@ -0,0 +1,27 @@
{
"id": "deepseek",
"providers": ["deepseek"],
"providerAuthEnvVars": {
"deepseek": ["DEEPSEEK_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "deepseek",
"method": "api-key",
"choiceId": "deepseek-api-key",
"choiceLabel": "DeepSeek API key",
"groupId": "deepseek",
"groupLabel": "DeepSeek",
"groupHint": "API key",
"optionKey": "deepseekApiKey",
"cliFlag": "--deepseek-api-key",
"cliOption": "--deepseek-api-key <key>",
"cliDescription": "DeepSeek API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@openclaw/deepseek-provider",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw DeepSeek provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
+14
View File
@@ -0,0 +1,14 @@
import {
buildDeepSeekModelDefinition,
DEEPSEEK_BASE_URL,
DEEPSEEK_MODEL_CATALOG,
type ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-models";
export function buildDeepSeekProvider(): ModelProviderConfig {
return {
baseUrl: DEEPSEEK_BASE_URL,
api: "openai-completions",
models: DEEPSEEK_MODEL_CATALOG.map(buildDeepSeekModelDefinition),
};
}
+5 -1
View File
@@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js";
import { fetchDiscord } from "./api.js";
import { jsonResponse } from "./test-http-helpers.js";
describe("fetchDiscord", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("formats rate limit payloads without raw JSON", async () => {
const fetcher = withFetchPreconnect(async () =>
jsonResponse(
@@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it, vi } from "vitest";
@@ -56,6 +57,24 @@ describe("discordMessageActions", () => {
expect(discovery?.actions).not.toContain("role-add");
});
it("keeps components optional in the message tool schema", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: "Bot token-main",
},
},
} as OpenClawConfig,
});
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected discord message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
});
it("extracts send targets for message and thread reply actions", () => {
expect(
discordMessageActions.extractToolSend?.({
+2 -1
View File
@@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import {
createUnionActionGate,
listTokenSourcedAccounts,
@@ -124,7 +125,7 @@ function describeDiscordMessageTool({
capabilities: ["interactive", "components"],
schema: {
properties: {
components: createDiscordMessageToolComponentsSchema(),
components: Type.Optional(createDiscordMessageToolComponentsSchema()),
},
},
};
+10 -3
View File
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { ResolvedDiscordAccount } from "./accounts.js";
import { discordPlugin } from "./channel.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { setDiscordRuntime } from "./runtime.js";
let discordPlugin: typeof import("./channel.js").discordPlugin;
let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime;
const probeDiscordMock = vi.hoisted(() => vi.fn());
const monitorDiscordProviderMock = vi.hoisted(() => vi.fn());
@@ -75,6 +75,13 @@ afterEach(() => {
auditDiscordChannelPermissionsMock.mockReset();
});
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
({ discordPlugin } = await import("./channel.js"));
({ setDiscordRuntime } = await import("./runtime.js"));
});
describe("discordPlugin outbound", () => {
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
+21 -12
View File
@@ -1,16 +1,25 @@
import { MessageFlags } from "discord-api-types/v10";
import { beforeEach, describe, expect, it } from "vitest";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} from "./components-registry.js";
import {
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
readDiscordComponentSpec,
} from "./components.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
let resolveDiscordComponentEntry: typeof import("./components-registry.js").resolveDiscordComponentEntry;
let resolveDiscordModalEntry: typeof import("./components-registry.js").resolveDiscordModalEntry;
let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordComponentMessage;
let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags;
let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec;
beforeEach(async () => {
vi.resetModules();
({
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} = await import("./components-registry.js"));
({ buildDiscordComponentMessage, buildDiscordComponentMessageFlags, readDiscordComponentSpec } =
await import("./components.js"));
});
describe("discord components", () => {
it("builds v2 containers with modal trigger", () => {
+5 -1
View File
@@ -26,6 +26,10 @@ import {
} from "@buape/carbon";
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
// Some test-only module graphs partially mock `@buape/carbon` and can drop `Modal`.
// Keep dynamic form definitions loadable instead of crashing unrelated suites.
const ModalBase: typeof Modal = (Modal ?? class {}) as typeof Modal;
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://";
@@ -1126,7 +1130,7 @@ export function buildDiscordComponentMessageFlags(
return hasV2 ? MessageFlags.IsComponentsV2 : undefined;
}
export class DiscordFormModal extends Modal {
export class DiscordFormModal extends ModalBase {
title: string;
customId: string;
components: Array<Label | TextDisplay>;
+10 -4
View File
@@ -1,12 +1,12 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../src/globals.js", () => ({
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
logVerbose: vi.fn(),
}));
import { logVerbose } from "../../../src/globals.js";
import { attachDiscordGatewayLogging } from "./gateway-logging.js";
let logVerbose: typeof import("openclaw/plugin-sdk/runtime-env").logVerbose;
let attachDiscordGatewayLogging: typeof import("./gateway-logging.js").attachDiscordGatewayLogging;
const makeRuntime = () => ({
log: vi.fn(),
@@ -15,6 +15,12 @@ const makeRuntime = () => ({
});
describe("attachDiscordGatewayLogging", () => {
beforeEach(async () => {
vi.resetModules();
({ logVerbose } = await import("openclaw/plugin-sdk/runtime-env"));
({ attachDiscordGatewayLogging } = await import("./gateway-logging.js"));
});
afterEach(() => {
vi.clearAllMocks();
});
+21
View File
@@ -61,6 +61,11 @@ function createAutoThreadMentionContext() {
return { guildInfo, channelConfig };
}
beforeEach(() => {
vi.useRealTimers();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
});
describe("registerDiscordListener", () => {
class FakeListener {}
@@ -892,6 +897,22 @@ const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({
})),
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: enqueueSystemEventSpy,
};
});
vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/routing")>();
return {
...actual,
resolveAgentRoute: resolveAgentRouteMock,
};
});
vi.mock("../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: enqueueSystemEventSpy,
}));
@@ -1,5 +1,5 @@
import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon";
import { MessageType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
dispatchMock,
@@ -9,13 +9,19 @@ import {
updateLastRouteMock,
upsertPairingRequestMock,
} from "./monitor.tool-result.test-harness.js";
import { createDiscordMessageHandler } from "./monitor/message-handler.js";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
type Config = ReturnType<typeof import("../../../src/config/config.js").loadConfig>;
let ChannelType: typeof import("@buape/carbon").ChannelType;
let createDiscordMessageHandler: typeof import("./monitor/message-handler.js").createDiscordMessageHandler;
let __resetDiscordChannelInfoCacheForTest: typeof import("./monitor/message-utils.js").__resetDiscordChannelInfoCacheForTest;
let createNoopThreadBindingManager: typeof import("./monitor/thread-bindings.js").createNoopThreadBindingManager;
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ ChannelType } = await import("@buape/carbon"));
({ createDiscordMessageHandler } = await import("./monitor/message-handler.js"));
({ __resetDiscordChannelInfoCacheForTest } = await import("./monitor/message-utils.js"));
({ createNoopThreadBindingManager } = await import("./monitor/thread-bindings.js"));
__resetDiscordChannelInfoCacheForTest();
sendMock.mockClear().mockResolvedValue(undefined);
updateLastRouteMock.mockClear();
@@ -1,14 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildDiscordComponentCustomId, buildDiscordModalCustomId } from "../components.js";
import {
createDiscordComponentButton,
createDiscordComponentChannelSelect,
createDiscordComponentMentionableSelect,
createDiscordComponentModal,
createDiscordComponentRoleSelect,
createDiscordComponentStringSelect,
createDiscordComponentUserSelect,
} from "./agent-components.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let buildDiscordComponentCustomId: typeof import("../components.js").buildDiscordComponentCustomId;
let buildDiscordModalCustomId: typeof import("../components.js").buildDiscordModalCustomId;
let createDiscordComponentButton: typeof import("./agent-components.js").createDiscordComponentButton;
let createDiscordComponentChannelSelect: typeof import("./agent-components.js").createDiscordComponentChannelSelect;
let createDiscordComponentMentionableSelect: typeof import("./agent-components.js").createDiscordComponentMentionableSelect;
let createDiscordComponentModal: typeof import("./agent-components.js").createDiscordComponentModal;
let createDiscordComponentRoleSelect: typeof import("./agent-components.js").createDiscordComponentRoleSelect;
let createDiscordComponentStringSelect: typeof import("./agent-components.js").createDiscordComponentStringSelect;
let createDiscordComponentUserSelect: typeof import("./agent-components.js").createDiscordComponentUserSelect;
beforeEach(async () => {
vi.resetModules();
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } = await import("../components.js"));
({
createDiscordComponentButton,
createDiscordComponentChannelSelect,
createDiscordComponentMentionableSelect,
createDiscordComponentModal,
createDiscordComponentRoleSelect,
createDiscordComponentStringSelect,
createDiscordComponentUserSelect,
} = await import("./agent-components.js"));
});
type WildcardComponent = {
customId: string;
@@ -6,15 +6,6 @@ import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
import {
buildExecApprovalCustomId,
extractDiscordChannelId,
parseExecApprovalData,
type ExecApprovalRequest,
DiscordExecApprovalHandler,
ExecApprovalButton,
type ExecApprovalButtonContext,
} from "./exec-approvals.js";
const STORE_PATH = path.join(os.tmpdir(), "openclaw-exec-approvals-test.json");
@@ -59,6 +50,7 @@ const gatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true }))
const gatewayClientParams = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
vi.mock("../send.shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.shared.js")>();
@@ -75,8 +67,12 @@ vi.mock("../send.shared.js", async (importOriginal) => {
};
});
vi.mock("../../../../src/gateway/client.js", () => ({
GatewayClient: class {
vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/gateway-runtime")>();
type CreateOperatorApprovalsGatewayClientParams = Parameters<
typeof actual.createOperatorApprovalsGatewayClient
>[0];
class MockGatewayClient {
private params: Record<string, unknown>;
constructor(params: Record<string, unknown>) {
this.params = params;
@@ -92,18 +88,70 @@ vi.mock("../../../../src/gateway/client.js", () => ({
async request() {
return gatewayClientRequests();
}
},
}));
}
return {
...actual,
GatewayClient: MockGatewayClient,
createOperatorApprovalsGatewayClient: async (
params: CreateOperatorApprovalsGatewayClientParams,
) => {
mockCreateOperatorApprovalsGatewayClient(params);
const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim();
const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789";
const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined;
const auth = await mockResolveGatewayConnectionAuth({
config: params.config,
env: process.env,
...(urlOverrideSource
? {
urlOverride: gatewayUrl,
urlOverrideSource,
}
: {}),
});
return new MockGatewayClient({
url: gatewayUrl,
token: auth?.token,
password: auth?.password,
clientName: "gateway-client",
clientDisplayName: params.clientDisplayName,
mode: "backend",
scopes: ["operator.approvals"],
onEvent: params.onEvent,
onHelloOk: params.onHelloOk,
onConnectError: params.onConnectError,
onClose: params.onClose,
});
},
};
});
vi.mock("../../../../src/gateway/connection-auth.js", () => ({
resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth,
}));
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
return {
...actual,
logDebug: vi.fn(),
logError: vi.fn(),
};
});
vi.mock("../../../../src/logger.js", () => ({
logDebug: vi.fn(),
logError: vi.fn(),
}));
let buildExecApprovalCustomId: typeof import("./exec-approvals.js").buildExecApprovalCustomId;
let extractDiscordChannelId: typeof import("./exec-approvals.js").extractDiscordChannelId;
let parseExecApprovalData: typeof import("./exec-approvals.js").parseExecApprovalData;
let DiscordExecApprovalHandler: typeof import("./exec-approvals.js").DiscordExecApprovalHandler;
let ExecApprovalButton: typeof import("./exec-approvals.js").ExecApprovalButton;
type DiscordExecApprovalHandlerInstance = InstanceType<
typeof import("./exec-approvals.js").DiscordExecApprovalHandler
>;
type ExecApprovalRequest = import("./exec-approvals.js").ExecApprovalRequest;
type ExecApprovalButtonContext = import("./exec-approvals.js").ExecApprovalButtonContext;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function createHandler(config: DiscordExecApprovalConfig, accountId = "default") {
@@ -143,7 +191,7 @@ function mockSuccessfulDmDelivery(params?: {
}
async function expectGatewayAuthStart(params: {
handler: DiscordExecApprovalHandler;
handler: DiscordExecApprovalHandlerInstance;
expectedUrl: string;
expectedSource: "cli" | "env";
expectedToken?: string;
@@ -181,11 +229,13 @@ type ExecApprovalHandlerInternals = {
handleApprovalTimeout: (approvalId: string, source?: "channel" | "dm") => Promise<void>;
};
function getHandlerInternals(handler: DiscordExecApprovalHandler): ExecApprovalHandlerInternals {
function getHandlerInternals(
handler: DiscordExecApprovalHandlerInstance,
): ExecApprovalHandlerInternals {
return handler as unknown as ExecApprovalHandlerInternals;
}
function clearPendingTimeouts(handler: DiscordExecApprovalHandler) {
function clearPendingTimeouts(handler: DiscordExecApprovalHandlerInstance) {
const internals = getHandlerInternals(handler);
for (const pending of internals.pending.values()) {
clearTimeout(pending.timeoutId);
@@ -220,6 +270,18 @@ beforeEach(() => {
gatewayClientRequests.mockReset();
gatewayClientRequests.mockResolvedValue({ ok: true });
gatewayClientParams.length = 0;
mockCreateOperatorApprovalsGatewayClient.mockReset();
});
beforeEach(async () => {
vi.resetModules();
({
buildExecApprovalCustomId,
extractDiscordChannelId,
parseExecApprovalData,
DiscordExecApprovalHandler,
ExecApprovalButton,
} = await import("./exec-approvals.js"));
});
// ─── buildExecApprovalCustomId ────────────────────────────────────────────────
@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { DiscordMessageListener } from "./listeners.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let DiscordMessageListener: typeof import("./listeners.js").DiscordMessageListener;
beforeEach(async () => {
vi.resetModules();
({ DiscordMessageListener } = await import("./listeners.js"));
});
function createLogger() {
return {
@@ -10,11 +10,6 @@ import {
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "../../../../src/infra/outbound/session-binding-service.js";
import {
preflightDiscordMessage,
resolvePreflightMentionRequirement,
shouldIgnoreBoundThreadWebhookMessage,
} from "./message-handler.preflight.js";
import {
createDiscordMessage,
createDiscordPreflightArgs,
@@ -25,10 +20,22 @@ import {
type DiscordConfig,
type DiscordMessageEvent,
} from "./message-handler.preflight.test-helpers.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
} from "./thread-bindings.js";
let preflightDiscordMessage: typeof import("./message-handler.preflight.js").preflightDiscordMessage;
let resolvePreflightMentionRequirement: typeof import("./message-handler.preflight.js").resolvePreflightMentionRequirement;
let shouldIgnoreBoundThreadWebhookMessage: typeof import("./message-handler.preflight.js").shouldIgnoreBoundThreadWebhookMessage;
let threadBindingTesting: typeof import("./thread-bindings.js").__testing;
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;
beforeEach(async () => {
vi.resetModules();
({
preflightDiscordMessage,
resolvePreflightMentionRequirement,
shouldIgnoreBoundThreadWebhookMessage,
} = await import("./message-handler.preflight.js"));
({ __testing: threadBindingTesting, createThreadBindingManager } =
await import("./thread-bindings.js"));
});
function createThreadBinding(
overrides?: Partial<
@@ -1,13 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_EMOJIS } from "../../../../src/channels/status-reactions.js";
import {
createBaseDiscordMessageContext,
createDiscordDirectMessageContextOverrides,
} from "./message-handler.test-harness.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
} from "./thread-bindings.js";
const sendMocks = vi.hoisted(() => ({
reactMessageDiscord: vi.fn(async () => {}),
@@ -66,6 +58,11 @@ const configSessionsMocks = vi.hoisted(() => ({
}));
const readSessionUpdatedAt = configSessionsMocks.readSessionUpdatedAt;
const resolveStorePath = configSessionsMocks.resolveStorePath;
let createBaseDiscordMessageContext: typeof import("./message-handler.test-harness.js").createBaseDiscordMessageContext;
let createDiscordDirectMessageContextOverrides: typeof import("./message-handler.test-harness.js").createDiscordDirectMessageContextOverrides;
let threadBindingTesting: typeof import("./thread-bindings.js").__testing;
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;
let processDiscordMessage: typeof import("./message-handler.process.js").processDiscordMessage;
vi.mock("../send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.js")>();
@@ -89,46 +86,52 @@ vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: deliveryMocks.deliverDiscordReply,
}));
vi.mock("../../../../src/auto-reply/dispatch.js", () => ({
dispatchInboundMessage,
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
dispatchInboundMessage,
createReplyDispatcherWithTyping: vi.fn(
(opts: { deliver: (payload: unknown, info: { kind: string }) => Promise<void> | void }) => ({
dispatcher: {
sendToolResult: vi.fn(() => true),
sendBlockReply: vi.fn((payload: unknown) => {
void opts.deliver(payload as never, { kind: "block" });
return true;
}),
sendFinalReply: vi.fn((payload: unknown) => {
void opts.deliver(payload as never, { kind: "final" });
return true;
}),
waitForIdle: vi.fn(async () => {}),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
}),
),
};
});
vi.mock("../../../../src/auto-reply/reply/reply-dispatcher.js", () => ({
createReplyDispatcherWithTyping: vi.fn(
(opts: { deliver: (payload: unknown, info: { kind: string }) => Promise<void> | void }) => ({
dispatcher: {
sendToolResult: vi.fn(() => true),
sendBlockReply: vi.fn((payload: unknown) => {
void opts.deliver(payload as never, { kind: "block" });
return true;
}),
sendFinalReply: vi.fn((payload: unknown) => {
void opts.deliver(payload as never, { kind: "final" });
return true;
}),
waitForIdle: vi.fn(async () => {}),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
}),
),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
recordInboundSession,
};
});
vi.mock("../../../../src/channels/session.js", () => ({
recordInboundSession,
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt,
resolveStorePath: configSessionsMocks.resolveStorePath,
};
});
vi.mock("../../../../src/config/sessions.js", () => ({
readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt,
resolveStorePath: configSessionsMocks.resolveStorePath,
}));
const { processDiscordMessage } = await import("./message-handler.process.js");
const createBaseContext = createBaseDiscordMessageContext;
const BASE_CHANNEL_ROUTE = {
agentId: "main",
channel: "discord",
@@ -137,6 +140,18 @@ const BASE_CHANNEL_ROUTE = {
mainSessionKey: "agent:main:main",
} as const;
async function createBaseContext(
...args: Parameters<typeof createBaseDiscordMessageContext>
): Promise<Awaited<ReturnType<typeof createBaseDiscordMessageContext>>> {
return await createBaseDiscordMessageContext(...args);
}
function createDirectMessageContextOverrides(
...args: Parameters<typeof createDiscordDirectMessageContextOverrides>
): ReturnType<typeof createDiscordDirectMessageContextOverrides> {
return createDiscordDirectMessageContextOverrides(...args);
}
function mockDispatchSingleBlockReply(payload: { text: string; isReasoning?: boolean }) {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendBlockReply(payload);
@@ -154,8 +169,14 @@ async function processStreamOffDiscordMessage() {
await processDiscordMessage(ctx as any);
}
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.useRealTimers();
({ createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } =
await import("./message-handler.test-harness.js"));
({ __testing: threadBindingTesting, createThreadBindingManager } =
await import("./thread-bindings.js"));
({ processDiscordMessage } = await import("./message-handler.process.js"));
sendMocks.reactMessageDiscord.mockClear();
sendMocks.removeReactionDiscord.mockClear();
editMessageDiscord.mockClear();
@@ -418,7 +439,7 @@ describe("processDiscordMessage ack reactions", () => {
describe("processDiscordMessage session routing", () => {
it("stores DM lastRoute with user target for direct-session continuity", async () => {
const ctx = await createBaseContext({
...createDiscordDirectMessageContextOverrides(),
...createDirectMessageContextOverrides(),
message: {
id: "m1",
channelId: "dm1",
@@ -1,13 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import {
createDiscordMessageHandler,
preflightDiscordMessageMock,
processDiscordMessageMock,
} from "./message-handler.module-test-helpers.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createDiscordHandlerParams,
createDiscordPreflightContext,
} from "./message-handler.test-helpers.js";
let createDiscordMessageHandler: typeof import("./message-handler.module-test-helpers.js").createDiscordMessageHandler;
let preflightDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").preflightDiscordMessageMock;
let processDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").processDiscordMessageMock;
const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn());
type SetStatusFn = (patch: Record<string, unknown>) => void;
@@ -86,6 +84,12 @@ async function createLifecycleStopScenario(params: {
}
describe("createDiscordMessageHandler queue behavior", () => {
beforeEach(async () => {
vi.resetModules();
({ createDiscordMessageHandler, preflightDiscordMessageMock, processDiscordMessageMock } =
await import("./message-handler.module-test-helpers.js"));
});
it("resets busy counters when the handler is created", () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
@@ -21,14 +21,24 @@ vi.mock("../../../../src/globals.js", () => ({
logVerbose: () => {},
}));
const {
__resetDiscordChannelInfoCacheForTest,
resolveDiscordChannelInfo,
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
resolveForwardedMediaList,
resolveMediaList,
} = await import("./message-utils.js");
let __resetDiscordChannelInfoCacheForTest: typeof import("./message-utils.js").__resetDiscordChannelInfoCacheForTest;
let resolveDiscordChannelInfo: typeof import("./message-utils.js").resolveDiscordChannelInfo;
let resolveDiscordMessageChannelId: typeof import("./message-utils.js").resolveDiscordMessageChannelId;
let resolveDiscordMessageText: typeof import("./message-utils.js").resolveDiscordMessageText;
let resolveForwardedMediaList: typeof import("./message-utils.js").resolveForwardedMediaList;
let resolveMediaList: typeof import("./message-utils.js").resolveMediaList;
beforeEach(async () => {
vi.resetModules();
({
__resetDiscordChannelInfoCacheForTest,
resolveDiscordChannelInfo,
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
resolveForwardedMediaList,
resolveMediaList,
} = await import("./message-utils.js"));
});
function asMessage(payload: Record<string, unknown>): Message {
return payload as unknown as Message;
@@ -1,10 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { listNativeCommandSpecs } from "../../../../src/auto-reply/commands-registry.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
import { createDiscordNativeCommand } from "./native-command.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
let listNativeCommandSpecs: typeof import("../../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs;
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
let createNoopThreadBindingManager: typeof import("./thread-bindings.js").createNoopThreadBindingManager;
function createNativeCommand(name: string): ReturnType<typeof createDiscordNativeCommand> {
function createNativeCommand(
name: string,
): ReturnType<typeof import("./native-command.js").createDiscordNativeCommand> {
const command = listNativeCommandSpecs({ provider: "discord" }).find(
(entry) => entry.name === name,
);
@@ -24,17 +26,19 @@ function createNativeCommand(name: string): ReturnType<typeof createDiscordNativ
});
}
type CommandOption = NonNullable<ReturnType<typeof createDiscordNativeCommand>["options"]>[number];
type CommandOption = NonNullable<
ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>["options"]
>[number];
function findOption(
command: ReturnType<typeof createDiscordNativeCommand>,
command: ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>,
name: string,
): CommandOption | undefined {
return command.options?.find((entry) => entry.name === name);
}
function requireOption(
command: ReturnType<typeof createDiscordNativeCommand>,
command: ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>,
name: string,
): CommandOption {
const option = findOption(command, name);
@@ -60,6 +64,13 @@ function readChoices(option: CommandOption | undefined): unknown[] | undefined {
}
describe("createDiscordNativeCommand option wiring", () => {
beforeEach(async () => {
vi.resetModules();
({ listNativeCommandSpecs } = await import("../../../../src/auto-reply/commands-registry.js"));
({ createDiscordNativeCommand } = await import("./native-command.js"));
({ createNoopThreadBindingManager } = await import("./thread-bindings.js"));
});
it("uses autocomplete for /acp action so inline action values are accepted", async () => {
const command = createNativeCommand("acp");
const action = requireOption(command, "action");
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./provider.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let __testing: typeof import("./provider.js").__testing;
describe("resolveThreadBindingsEnabled", () => {
beforeEach(async () => {
vi.resetModules();
({ __testing } = await import("./provider.js"));
});
it("defaults to enabled when unset", () => {
expect(
__testing.resolveThreadBindingsEnabled({
@@ -34,11 +34,14 @@ vi.mock("../send.js", async (importOriginal) => {
};
});
const { maybeSendBindingMessage, resolveChannelIdForBinding } =
await import("./thread-bindings.discord-api.js");
let maybeSendBindingMessage: typeof import("./thread-bindings.discord-api.js").maybeSendBindingMessage;
let resolveChannelIdForBinding: typeof import("./thread-bindings.discord-api.js").resolveChannelIdForBinding;
describe("resolveChannelIdForBinding", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ maybeSendBindingMessage, resolveChannelIdForBinding } =
await import("./thread-bindings.discord-api.js"));
hoisted.restGet.mockClear();
hoisted.createDiscordRestClient.mockClear();
hoisted.sendMessageDiscord.mockClear().mockResolvedValue({});
@@ -124,7 +127,10 @@ describe("resolveChannelIdForBinding", () => {
});
describe("maybeSendBindingMessage", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ maybeSendBindingMessage, resolveChannelIdForBinding } =
await import("./thread-bindings.discord-api.js"));
hoisted.sendMessageDiscord.mockClear().mockResolvedValue({});
hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({});
});
@@ -15,7 +15,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
};
});
const { closeDiscordThreadSessions } = await import("./thread-session-close.js");
let closeDiscordThreadSessions: typeof import("./thread-session-close.js").closeDiscordThreadSessions;
function setupStore(store: Record<string, { updatedAt: number }>) {
hoisted.updateSessionStore.mockImplementation(
@@ -30,7 +30,9 @@ const MATCHED_KEY = `agent:main:discord:channel:${THREAD_ID}`;
const UNMATCHED_KEY = `agent:main:discord:channel:${OTHER_ID}`;
describe("closeDiscordThreadSessions", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ closeDiscordThreadSessions } = await import("./thread-session-close.js"));
hoisted.updateSessionStore.mockClear();
hoisted.resolveStorePath.mockClear();
hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json");
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
const hoisted = vi.hoisted(() => {
const sendMessageDiscordMock = vi.fn();
@@ -37,7 +36,14 @@ vi.mock("./monitor/thread-bindings.js", async (importOriginal) => {
};
});
const { discordOutbound } = await import("./outbound-adapter.js");
let normalizeDiscordOutboundTarget: typeof import("./normalize.js").normalizeDiscordOutboundTarget;
let discordOutbound: typeof import("./outbound-adapter.js").discordOutbound;
beforeEach(async () => {
vi.resetModules();
({ normalizeDiscordOutboundTarget } = await import("./normalize.js"));
({ discordOutbound } = await import("./outbound-adapter.js"));
});
const DEFAULT_DISCORD_SEND_RESULT = {
channel: "discord",
+16 -9
View File
@@ -1,11 +1,5 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerDiscordComponentEntries } from "./components-registry.js";
import {
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} from "./send.components.js";
import { makeDiscordRest } from "./send.test-harness.js";
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } })));
@@ -24,10 +18,23 @@ vi.mock("./components-registry.js", () => ({
registerDiscordComponentEntries: vi.fn(),
}));
describe("sendDiscordComponentMessage", () => {
const registerMock = vi.mocked(registerDiscordComponentEntries);
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
let editDiscordComponentMessage: typeof import("./send.components.js").editDiscordComponentMessage;
let registerBuiltDiscordComponentMessage: typeof import("./send.components.js").registerBuiltDiscordComponentMessage;
let sendDiscordComponentMessage: typeof import("./send.components.js").sendDiscordComponentMessage;
beforeEach(() => {
describe("sendDiscordComponentMessage", () => {
let registerMock: ReturnType<typeof vi.mocked<typeof registerDiscordComponentEntries>>;
beforeEach(async () => {
vi.resetModules();
({ registerDiscordComponentEntries } = await import("./components-registry.js"));
({
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} = await import("./send.components.js"));
registerMock = vi.mocked(registerDiscordComponentEntries);
vi.clearAllMocks();
});
@@ -2,21 +2,6 @@ import { RateLimitError } from "@buape/carbon";
import { ChannelType, Routes } from "discord-api-types/v10";
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addRoleDiscord,
banMemberDiscord,
createThreadDiscord,
listGuildEmojisDiscord,
listThreadsDiscord,
reactMessageDiscord,
removeRoleDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
timeoutMemberDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "./send.js";
import { makeDiscordRest } from "./send.test-harness.js";
vi.mock("openclaw/plugin-sdk/web-media", async () => {
@@ -24,11 +9,41 @@ vi.mock("openclaw/plugin-sdk/web-media", async () => {
return discordWebMediaMockFactory();
});
describe("sendMessageDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
let addRoleDiscord: typeof import("./send.js").addRoleDiscord;
let banMemberDiscord: typeof import("./send.js").banMemberDiscord;
let createThreadDiscord: typeof import("./send.js").createThreadDiscord;
let listGuildEmojisDiscord: typeof import("./send.js").listGuildEmojisDiscord;
let listThreadsDiscord: typeof import("./send.js").listThreadsDiscord;
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
let removeRoleDiscord: typeof import("./send.js").removeRoleDiscord;
let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord;
let sendPollDiscord: typeof import("./send.js").sendPollDiscord;
let sendStickerDiscord: typeof import("./send.js").sendStickerDiscord;
let timeoutMemberDiscord: typeof import("./send.js").timeoutMemberDiscord;
let uploadEmojiDiscord: typeof import("./send.js").uploadEmojiDiscord;
let uploadStickerDiscord: typeof import("./send.js").uploadStickerDiscord;
beforeEach(async () => {
vi.resetModules();
({
addRoleDiscord,
banMemberDiscord,
createThreadDiscord,
listGuildEmojisDiscord,
listThreadsDiscord,
reactMessageDiscord,
removeRoleDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
timeoutMemberDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} = await import("./send.js"));
vi.clearAllMocks();
});
describe("sendMessageDiscord", () => {
it("creates a thread", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "t1" });
@@ -1,11 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import {
fetchMemberGuildPermissionsDiscord,
hasAllGuildPermissionsDiscord,
hasAnyGuildPermissionDiscord,
} from "./send.permissions.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockRest = vi.hoisted(() => ({
get: vi.fn(),
@@ -15,6 +10,10 @@ vi.mock("./client.js", () => ({
resolveDiscordRest: () => mockRest as unknown as RequestClient,
}));
let fetchMemberGuildPermissionsDiscord: typeof import("./send.permissions.js").fetchMemberGuildPermissionsDiscord;
let hasAllGuildPermissionsDiscord: typeof import("./send.permissions.js").hasAllGuildPermissionsDiscord;
let hasAnyGuildPermissionDiscord: typeof import("./send.permissions.js").hasAnyGuildPermissionDiscord;
type RouteMockParams = {
guildId?: string;
userId?: string;
@@ -44,6 +43,16 @@ function mockGuildMemberRoutes(params: RouteMockParams): void {
}
describe("discord guild permission authorization", () => {
beforeEach(async () => {
vi.resetModules();
({
fetchMemberGuildPermissionsDiscord,
hasAllGuildPermissionsDiscord,
hasAnyGuildPermissionDiscord,
} = await import("./send.permissions.js"));
mockRest.get.mockReset();
});
describe("fetchMemberGuildPermissionsDiscord", () => {
it("returns null when user is not a guild member", async () => {
mockRest.get.mockRejectedValueOnce(new Error("404 Member not found"));
@@ -1,24 +1,5 @@
import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetDiscordDirectoryCacheForTest,
rememberDiscordDirectoryUser,
} from "./directory-cache.js";
import {
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
searchMessagesDiscord,
sendMessageDiscord,
unpinMessageDiscord,
} from "./send.js";
import { makeDiscordRest } from "./send.test-harness.js";
vi.mock("openclaw/plugin-sdk/web-media", async () => {
@@ -26,6 +7,45 @@ vi.mock("openclaw/plugin-sdk/web-media", async () => {
return discordWebMediaMockFactory();
});
let deleteMessageDiscord: typeof import("./send.js").deleteMessageDiscord;
let editMessageDiscord: typeof import("./send.js").editMessageDiscord;
let fetchChannelPermissionsDiscord: typeof import("./send.js").fetchChannelPermissionsDiscord;
let fetchReactionsDiscord: typeof import("./send.js").fetchReactionsDiscord;
let pinMessageDiscord: typeof import("./send.js").pinMessageDiscord;
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
let readMessagesDiscord: typeof import("./send.js").readMessagesDiscord;
let removeOwnReactionsDiscord: typeof import("./send.js").removeOwnReactionsDiscord;
let removeReactionDiscord: typeof import("./send.js").removeReactionDiscord;
let searchMessagesDiscord: typeof import("./send.js").searchMessagesDiscord;
let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord;
let unpinMessageDiscord: typeof import("./send.js").unpinMessageDiscord;
let loadWebMedia: typeof import("openclaw/plugin-sdk/web-media").loadWebMedia;
let __resetDiscordDirectoryCacheForTest: typeof import("./directory-cache.js").__resetDiscordDirectoryCacheForTest;
let rememberDiscordDirectoryUser: typeof import("./directory-cache.js").rememberDiscordDirectoryUser;
beforeEach(async () => {
vi.resetModules();
({
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
searchMessagesDiscord,
sendMessageDiscord,
unpinMessageDiscord,
} = await import("./send.js"));
({ loadWebMedia } = await import("openclaw/plugin-sdk/web-media"));
({ __resetDiscordDirectoryCacheForTest, rememberDiscordDirectoryUser } =
await import("./directory-cache.js"));
vi.clearAllMocks();
__resetDiscordDirectoryCacheForTest();
});
describe("sendMessageDiscord", () => {
function expectReplyReference(
body: { message_reference?: unknown } | undefined,
@@ -65,11 +85,6 @@ describe("sendMessageDiscord", () => {
return { rest, postMock };
}
beforeEach(() => {
vi.clearAllMocks();
__resetDiscordDirectoryCacheForTest();
});
it("sends basic channel messages", async () => {
const { rest, postMock, getMock } = makeDiscordRest();
// Channel type lookup returns a normal text channel (not a forum).
+7 -2
View File
@@ -1,6 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveDiscordRestMock = vi.hoisted(() => vi.fn());
@@ -8,7 +8,12 @@ vi.mock("./client.js", () => ({
resolveDiscordRest: resolveDiscordRestMock,
}));
import { sendTypingDiscord } from "./send.typing.js";
let sendTypingDiscord: typeof import("./send.typing.js").sendTypingDiscord;
beforeEach(async () => {
vi.resetModules();
({ sendTypingDiscord } = await import("./send.typing.js"));
});
describe("sendTypingDiscord", () => {
it("sends a typing event to the resolved Discord channel route", async () => {
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sendWebhookMessageDiscord } from "./send.js";
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } })));
@@ -20,8 +19,12 @@ vi.mock("../../../src/infra/channel-activity.js", async (importOriginal) => {
};
});
let sendWebhookMessageDiscord: typeof import("./send.js").sendWebhookMessageDiscord;
describe("sendWebhookMessageDiscord activity", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ sendWebhookMessageDiscord } = await import("./send.js"));
recordChannelActivityMock.mockClear();
loadConfigMock.mockClear();
vi.stubGlobal(
+47 -102
View File
@@ -1,146 +1,91 @@
import type { ChildProcess, ExecFileOptions } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type ExecCallback = (
error: NodeJS.ErrnoException | null,
stdout: string | Buffer,
stderr: string | Buffer,
) => void;
type ExecCall = {
command: string;
args: string[];
options?: ExecFileOptions;
};
type MockExecResult = {
stdout?: string;
stderr?: string;
error?: NodeJS.ErrnoException;
};
const execCalls: ExecCall[] = [];
const mockExecResults: MockExecResult[] = [];
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
const execFileImpl = (
file: string,
args?: readonly string[] | null,
optionsOrCallback?: ExecFileOptions | ExecCallback | null,
callbackMaybe?: ExecCallback,
) => {
const normalizedArgs = Array.isArray(args) ? [...args] : [];
const callback =
typeof optionsOrCallback === "function" ? optionsOrCallback : (callbackMaybe ?? undefined);
const options =
typeof optionsOrCallback === "function" ? undefined : (optionsOrCallback ?? undefined);
execCalls.push({
command: file,
args: normalizedArgs,
options,
});
const next = mockExecResults.shift() ?? { stdout: "", stderr: "" };
queueMicrotask(() => {
callback?.(next.error ?? null, next.stdout ?? "", next.stderr ?? "");
});
return {} as ChildProcess;
};
const execFileWithCustomPromisify = execFileImpl as unknown as typeof actual.execFile & {
[promisify.custom]?: (
file: string,
args?: readonly string[] | null,
options?: ExecFileOptions | null,
) => Promise<{ stdout: string | Buffer; stderr: string | Buffer }>;
};
execFileWithCustomPromisify[promisify.custom] = (
file: string,
args?: readonly string[] | null,
options?: ExecFileOptions | null,
) =>
new Promise<{ stdout: string | Buffer; stderr: string | Buffer }>((resolve, reject) => {
execFileImpl(file, args, options, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
const runFfprobeMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<string>>());
const runFfmpegMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<void>>());
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
execFile: execFileWithCustomPromisify,
resolvePreferredOpenClawTmpDir: () => "/tmp",
};
});
vi.mock("../../../src/infra/tmp-openclaw-dir.js", () => ({
resolvePreferredOpenClawTmpDir: () => "/tmp",
}));
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
return {
...actual,
runFfprobe: runFfprobeMock,
runFfmpeg: runFfmpegMock,
parseFfprobeCodecAndSampleRate: (stdout: string) => {
const [codec, sampleRate] = stdout.trim().split(",");
return {
codec,
sampleRateHz: Number(sampleRate),
};
},
MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS: 1200,
unlinkIfExists: vi.fn(async () => {}),
};
});
const { ensureOggOpus } = await import("./voice-message.js");
let ensureOggOpus: typeof import("./voice-message.js").ensureOggOpus;
describe("ensureOggOpus", () => {
beforeEach(() => {
execCalls.length = 0;
mockExecResults.length = 0;
beforeEach(async () => {
vi.resetModules();
runFfprobeMock.mockReset();
runFfmpegMock.mockReset();
({ ensureOggOpus } = await import("./voice-message.js"));
});
afterEach(() => {
execCalls.length = 0;
mockExecResults.length = 0;
runFfprobeMock.mockReset();
runFfmpegMock.mockReset();
});
it("rejects URL/protocol input paths", async () => {
await expect(ensureOggOpus("https://example.com/audio.ogg")).rejects.toThrow(
/local file path/i,
);
expect(execCalls).toHaveLength(0);
expect(runFfprobeMock).not.toHaveBeenCalled();
expect(runFfmpegMock).not.toHaveBeenCalled();
});
it("keeps .ogg only when codec is opus and sample rate is 48kHz", async () => {
mockExecResults.push({ stdout: "opus,48000\n" });
runFfprobeMock.mockResolvedValueOnce("opus,48000\n");
const result = await ensureOggOpus("/tmp/input.ogg");
expect(result).toEqual({ path: "/tmp/input.ogg", cleanup: false });
expect(execCalls).toHaveLength(1);
expect(execCalls[0].command).toBe("ffprobe");
expect(execCalls[0].args).toContain("stream=codec_name,sample_rate");
expect(execCalls[0].options?.timeout).toBe(10_000);
expect(runFfprobeMock).toHaveBeenCalledWith(
expect.arrayContaining(["-show_entries", "stream=codec_name,sample_rate", "/tmp/input.ogg"]),
);
expect(runFfmpegMock).not.toHaveBeenCalled();
});
it("re-encodes .ogg opus when sample rate is not 48kHz", async () => {
mockExecResults.push({ stdout: "opus,24000\n" });
mockExecResults.push({ stdout: "" });
runFfprobeMock.mockResolvedValueOnce("opus,24000\n");
runFfmpegMock.mockResolvedValueOnce();
const result = await ensureOggOpus("/tmp/input.ogg");
const ffmpegCall = execCalls.find((call) => call.command === "ffmpeg");
expect(result.cleanup).toBe(true);
expect(result.path).toMatch(/^\/tmp\/voice-.*\.ogg$/);
expect(ffmpegCall).toBeDefined();
expect(ffmpegCall?.args).toContain("-t");
expect(ffmpegCall?.args).toContain("1200");
expect(ffmpegCall?.args).toContain("-ar");
expect(ffmpegCall?.args).toContain("48000");
expect(ffmpegCall?.options?.timeout).toBe(45_000);
expect(runFfmpegMock).toHaveBeenCalledWith(
expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg", result.path]),
);
});
it("re-encodes non-ogg input with bounded ffmpeg execution", async () => {
mockExecResults.push({ stdout: "" });
runFfmpegMock.mockResolvedValueOnce();
const result = await ensureOggOpus("/tmp/input.mp3");
const ffprobeCalls = execCalls.filter((call) => call.command === "ffprobe");
const ffmpegCalls = execCalls.filter((call) => call.command === "ffmpeg");
expect(result.cleanup).toBe(true);
expect(ffprobeCalls).toHaveLength(0);
expect(ffmpegCalls).toHaveLength(1);
expect(ffmpegCalls[0].options?.timeout).toBe(45_000);
expect(ffmpegCalls[0].args).toEqual(expect.arrayContaining(["-vn", "-sn", "-dn"]));
expect(runFfprobeMock).not.toHaveBeenCalled();
expect(runFfmpegMock).toHaveBeenCalledWith(
expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3", result.path]),
);
});
});
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runDuckDuckGoSearch = vi.fn(async (params: Record<string, unknown>) => params);
const { runDuckDuckGoSearch } = vi.hoisted(() => ({
runDuckDuckGoSearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./ddg-client.js", () => ({
runDuckDuckGoSearch,
}));
describe("duckduckgo web search provider", () => {
beforeEach(() => {
vi.resetModules();
runDuckDuckGoSearch.mockReset();
runDuckDuckGoSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes keyless metadata and enables the plugin in config", async () => {
const { createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js");
+34 -1
View File
@@ -18,6 +18,7 @@ const getChatMembersMock = vi.hoisted(() => vi.fn());
const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn());
const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn());
const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn());
const feishuOutboundSendMediaMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
@@ -47,7 +48,7 @@ vi.mock("./channel.runtime.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
feishuOutbound: {
sendText: vi.fn(),
sendMedia: vi.fn(),
sendMedia: feishuOutboundSendMediaMock,
},
},
}));
@@ -204,6 +205,38 @@ describe("feishuPlugin actions", () => {
expect(result?.details).toMatchObject({ ok: true, messageId: "om_card", chatId: "oc_group_1" });
});
it("sends media through the outbound adapter", async () => {
feishuOutboundSendMediaMock.mockResolvedValueOnce({
channel: "feishu",
messageId: "om_media",
details: { messageId: "om_media", chatId: "oc_group_1" },
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: "test",
media: "/tmp/image.png",
},
cfg,
accountId: undefined,
toolContext: {},
mediaLocalRoots: ["/tmp"],
} as never);
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
text: "test",
mediaUrl: "/tmp/image.png",
accountId: undefined,
mediaLocalRoots: ["/tmp"],
replyToId: undefined,
});
expect(result?.details).toMatchObject({ messageId: "om_media" });
});
it("reads messages", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_1",
+49 -19
View File
@@ -51,6 +51,14 @@ import { feishuSetupWizard } from "./setup-surface.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { FeishuConfig, FeishuProbeResult, ResolvedFeishuAccount } from "./types.js";
function readFeishuMediaParam(params: Record<string, unknown>): string | undefined {
const media = params.media;
if (typeof media !== "string") {
return undefined;
}
return media.trim() ? media : undefined;
}
const meta: ChannelMeta = {
id: "feishu",
label: "Feishu",
@@ -502,27 +510,49 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
? (ctx.params.card as Record<string, unknown>)
: undefined;
const text = readFirstString(ctx.params, ["text", "message"]);
if (!card && !text) {
throw new Error(`Feishu ${ctx.action} requires text/message or card.`);
const mediaUrl = readFeishuMediaParam(ctx.params);
if (card && mediaUrl) {
throw new Error(`Feishu ${ctx.action} does not support card with media.`);
}
if (!card && !text && !mediaUrl) {
throw new Error(`Feishu ${ctx.action} requires text/message, media, or card.`);
}
const runtime = await loadFeishuChannelRuntime();
const result = card
? await runtime.sendCardFeishu({
cfg: ctx.cfg,
to,
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
})
: await runtime.sendMessageFeishu({
cfg: ctx.cfg,
to,
text: text!,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
});
const maybeSendMedia = runtime.feishuOutbound.sendMedia;
if (mediaUrl && !maybeSendMedia) {
throw new Error("Feishu media sending is not available.");
}
const sendMedia = maybeSendMedia;
let result;
if (card) {
result = await runtime.sendCardFeishu({
cfg: ctx.cfg,
to,
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
});
} else if (mediaUrl) {
result = await sendMedia!({
cfg: ctx.cfg,
to,
text: text ?? "",
mediaUrl,
accountId: ctx.accountId ?? undefined,
mediaLocalRoots: ctx.mediaLocalRoots,
replyToId: replyToMessageId,
});
} else {
result = await runtime.sendMessageFeishu({
cfg: ctx.cfg,
to,
text: text!,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
});
}
return jsonActionResult({
ok: true,
channel: "feishu",
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlScrape = vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
const { runFirecrawlScrape } = vi.hoisted(() => ({
runFirecrawlScrape: vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
})),
}));
vi.mock("./firecrawl-client.js", () => ({
@@ -10,6 +12,15 @@ vi.mock("./firecrawl-client.js", () => ({
}));
describe("firecrawl scrape tool", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlScrape.mockReset();
runFirecrawlScrape.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
});
it("maps scrape params and defaults extract mode to markdown", async () => {
const { createFirecrawlScrapeTool } = await import("./firecrawl-scrape-tool.js");
const tool = createFirecrawlScrapeTool({
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlSearch = vi.fn(async (params: Record<string, unknown>) => params);
const { runFirecrawlSearch } = vi.hoisted(() => ({
runFirecrawlSearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./firecrawl-client.js", () => ({
runFirecrawlSearch,
}));
describe("firecrawl web search provider", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlSearch.mockReset();
runFirecrawlSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes selection metadata and enables the plugin in config", async () => {
const { createFirecrawlWebSearchProvider } = await import("./firecrawl-search-provider.js");
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlSearch = vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
const { runFirecrawlSearch } = vi.hoisted(() => ({
runFirecrawlSearch: vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
})),
}));
vi.mock("./firecrawl-client.js", () => ({
@@ -10,6 +12,15 @@ vi.mock("./firecrawl-client.js", () => ({
}));
describe("firecrawl search tool", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlSearch.mockReset();
runFirecrawlSearch.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
});
it("normalizes optional search parameters before invoking Firecrawl", async () => {
const { createFirecrawlSearchTool } = await import("./firecrawl-search-tool.js");
const tool = createFirecrawlSearchTool({
+27 -16
View File
@@ -14,32 +14,43 @@ vi.mock("../send.js", () => ({
sendMessageIMessageMock(to, message, opts),
}));
vi.mock("../../../../src/auto-reply/chunk.js", () => ({
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
resolveChunkMode: () => resolveChunkModeMock(),
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => ({}),
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
};
});
vi.mock("../../../../src/config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
resolveChunkMode: () => resolveChunkModeMock(),
};
});
vi.mock("../../../../src/config/markdown-tables.js", () => ({
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
}));
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
return {
...actual,
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
};
});
vi.mock("../../../../src/markdown/tables.js", () => ({
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
}));
import { deliverReplies } from "./deliver.js";
let deliverReplies: typeof import("./deliver.js").deliverReplies;
describe("deliverReplies", () => {
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
chunkTextWithModeMock.mockImplementation((text: string) => [text]);
({ deliverReplies } = await import("./deliver.js"));
});
it("propagates payload replyToId through all text chunks", async () => {
+30 -12
View File
@@ -1,6 +1,6 @@
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LineAccountConfig } from "./types.js";
// Avoid pulling in globals/pairing/media dependencies; this suite only asserts
@@ -74,6 +74,16 @@ type LineWebhookContext = Parameters<typeof import("./bot-handlers.js").handleLi
const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() });
function buildDefaultLineMessageContext() {
return {
ctxPayload: { From: "line:group:group-1" },
replyToken: "reply-token",
route: { agentId: "default" },
isGroup: true,
accountId: "default",
};
}
function createReplayMessageEvent(params: {
messageId: string;
groupId: string;
@@ -199,17 +209,24 @@ async function startInflightReplayDuplicate(params: {
}
describe("handleLineWebhookEvents", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
buildLineMessageContextMock.mockReset();
buildLineMessageContextMock.mockImplementation(async () => ({
ctxPayload: { From: "line:group:group-1" },
replyToken: "reply-token",
route: { agentId: "default" },
isGroup: true,
accountId: "default",
}));
buildLinePostbackContextMock.mockReset();
buildLinePostbackContextMock.mockImplementation(async () => null as unknown);
readAllowFromStoreMock.mockReset();
readAllowFromStoreMock.mockImplementation(async () => [] as string[]);
upsertPairingRequestMock.mockReset();
upsertPairingRequestMock.mockImplementation(async () => ({ code: "CODE", created: true }));
({ handleLineWebhookEvents, createLineWebhookReplayCache } = await import("./bot-handlers.js"));
});
beforeEach(() => {
buildLineMessageContextMock.mockClear();
buildLinePostbackContextMock.mockClear();
readAllowFromStoreMock.mockClear();
upsertPairingRequestMock.mockClear();
});
it("blocks group messages when groupPolicy is disabled", async () => {
const processMessage = vi.fn();
const event = {
@@ -573,10 +590,11 @@ describe("handleLineWebhookEvents", () => {
isRedelivery: true,
});
const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
const firstFailure = expect(firstRun).rejects.toThrow("transient inflight failure");
const secondFailure = expect(secondRun).rejects.toThrow("transient inflight failure");
rejectFirst?.(new Error("transient inflight failure"));
await expect(firstRun).rejects.toThrow("transient inflight failure");
await expect(secondRun).rejects.toThrow("transient inflight failure");
await Promise.all([firstFailure, secondFailure]);
expect(processMessage).toHaveBeenCalledTimes(1);
});
+6 -3
View File
@@ -29,7 +29,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
logVerbose: () => {},
}));
import { downloadLineMedia } from "./download.js";
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
for (const part of parts) {
@@ -38,8 +38,11 @@ async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
}
describe("downloadLineMedia", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(async () => {
vi.restoreAllMocks();
vi.resetModules();
getMessageContentMock.mockReset();
({ downloadLineMedia } = await import("./download.js"));
});
it("does not derive temp file path from external messageId", async () => {
+12 -8
View File
@@ -11,6 +11,8 @@ const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = v
unregisterHttpMock: vi.fn(),
}));
let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
vi.mock("./bot.js", () => ({
createLineBot: createLineBotMock,
}));
@@ -74,14 +76,19 @@ vi.mock("./template-messages.js", () => ({
}));
describe("monitorLineProvider lifecycle", () => {
beforeEach(() => {
createLineBotMock.mockClear();
unregisterHttpMock.mockClear();
registerPluginHttpRouteMock.mockClear().mockReturnValue(unregisterHttpMock);
beforeEach(async () => {
vi.resetModules();
createLineBotMock.mockReset();
createLineBotMock.mockReturnValue({
account: { accountId: "default" },
handleWebhook: vi.fn(),
});
unregisterHttpMock.mockReset();
registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
({ monitorLineProvider } = await import("./monitor.js"));
});
it("waits for abort before resolving", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const abort = new AbortController();
let resolved = false;
@@ -108,7 +115,6 @@ describe("monitorLineProvider lifecycle", () => {
});
it("stops immediately when signal is already aborted", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const abort = new AbortController();
abort.abort();
@@ -124,8 +130,6 @@ describe("monitorLineProvider lifecycle", () => {
});
it("returns immediately without abort signal and stop is idempotent", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
+8 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
const getBotInfoMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
@@ -19,7 +19,13 @@ afterEach(() => {
});
describe("probeLineBot", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
getBotInfoMock.mockReset();
MessagingApiClientMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return { getBotInfo: getBotInfoMock };
});
({ probeLineBot } = await import("./probe.js"));
});
+13 -7
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
pushMessageMock,
@@ -74,28 +74,34 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
let sendModule: typeof import("./send.js");
describe("LINE send helpers", () => {
beforeAll(async () => {
sendModule = await import("./send.js");
});
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
pushMessageMock.mockReset();
replyMessageMock.mockReset();
showLoadingAnimationMock.mockReset();
getProfileMock.mockReset();
MessagingApiClientMock.mockClear();
MessagingApiClientMock.mockReset();
loadConfigMock.mockReset();
resolveLineAccountMock.mockReset();
resolveLineChannelAccessTokenMock.mockReset();
recordChannelActivityMock.mockReset();
logVerboseMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
loadConfigMock.mockReturnValue({});
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
pushMessageMock.mockResolvedValue({});
replyMessageMock.mockResolvedValue({});
showLoadingAnimationMock.mockResolvedValue({});
sendModule = await import("./send.js");
});
afterEach(() => {
+20
View File
@@ -35,6 +35,26 @@ describe("matrix plugin registration", () => {
});
}, 240_000);
it("loads the matrix src runtime api through Jiti without duplicate export errors", () => {
const runtimeApiPath = path.join(
process.cwd(),
"extensions",
"matrix",
"src",
"runtime-api.ts",
);
const jiti = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(
resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }),
),
tryNative: false,
});
expect(jiti(runtimeApiPath)).toMatchObject({
resolveMatrixAccountStringValues: expect.any(Function),
});
}, 240_000);
it("registers the channel without bootstrapping crypto runtime", () => {
const runtime = {} as never;
matrixPlugin.register({
+2 -3
View File
@@ -14,6 +14,5 @@ export {
maybeCreateMatrixMigrationSnapshot,
resolveConfiguredAcpBindingRecord,
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
// Keep auth-precedence available internally without re-exporting helper-api
// twice through both plugin-sdk/matrix and ../runtime-api.js.
export * from "./auth-precedence.js";
// resolveMatrixAccountStringValues already comes from plugin-sdk/matrix.
// Re-exporting auth-precedence here makes Jiti try to define the same export twice.
+1
View File
@@ -4,6 +4,7 @@
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"dependencies": {
"@sinclair/typebox": "0.34.48",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
+13 -9
View File
@@ -148,14 +148,7 @@ describe("memory plugin e2e", () => {
const toArray = vi.fn(async () => []);
const limit = vi.fn(() => ({ toArray }));
const vectorSearch = vi.fn(() => ({ limit }));
vi.resetModules();
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: embeddingsCreate };
},
}));
vi.doMock("@lancedb/lancedb", () => ({
const loadLanceDbModule = vi.fn(async () => ({
connect: vi.fn(async () => ({
tableNames: vi.fn(async () => ["memories"]),
openTable: vi.fn(async () => ({
@@ -167,6 +160,16 @@ describe("memory plugin e2e", () => {
})),
}));
vi.resetModules();
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: embeddingsCreate };
},
}));
vi.doMock("./lancedb-runtime.js", () => ({
loadLanceDbModule,
}));
try {
const { default: memoryPlugin } = await import("./index.js");
// oxlint-disable-next-line typescript/no-explicit-any
@@ -214,6 +217,7 @@ describe("memory plugin e2e", () => {
}
await recallTool.execute("test-call-dims", { query: "hello dimensions" });
expect(loadLanceDbModule).toHaveBeenCalledTimes(1);
expect(embeddingsCreate).toHaveBeenCalledWith({
model: "text-embedding-3-small",
input: "hello dimensions",
@@ -221,7 +225,7 @@ describe("memory plugin e2e", () => {
});
} finally {
vi.doUnmock("openai");
vi.doUnmock("@lancedb/lancedb");
vi.doUnmock("./lancedb-runtime.js");
vi.resetModules();
}
});
+2 -14
View File
@@ -18,24 +18,12 @@ import {
memoryConfigSchema,
vectorDimsForModel,
} from "./config.js";
import { loadLanceDbModule } from "./lancedb-runtime.js";
// ============================================================================
// Types
// ============================================================================
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
if (!lancedbImportPromise) {
lancedbImportPromise = import("@lancedb/lancedb");
}
try {
return await lancedbImportPromise;
} catch (err) {
// Common on macOS today: upstream package may not ship darwin native bindings.
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`, { cause: err });
}
};
type MemoryEntry = {
id: string;
text: string;
@@ -79,7 +67,7 @@ class MemoryDB {
}
private async doInitialize(): Promise<void> {
const lancedb = await loadLanceDB();
const lancedb = await loadLanceDbModule();
this.db = await lancedb.connect(this.dbPath);
const tables = await this.db.tableNames();
@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from "vitest";
import { createLanceDbRuntimeLoader, type LanceDbRuntimeLogger } from "./lancedb-runtime.js";
const TEST_RUNTIME_MANIFEST = {
name: "openclaw-memory-lancedb-runtime",
private: true as const,
type: "module" as const,
dependencies: {
"@lancedb/lancedb": "^0.27.1",
},
};
type LanceDbModule = typeof import("@lancedb/lancedb");
type RuntimeManifest = {
name: string;
private: true;
type: "module";
dependencies: Record<string, string>;
};
function createMockModule(): LanceDbModule {
return {
connect: vi.fn(),
} as unknown as LanceDbModule;
}
function createLoader(
overrides: {
env?: NodeJS.ProcessEnv;
importBundled?: () => Promise<LanceDbModule>;
importResolved?: (resolvedPath: string) => Promise<LanceDbModule>;
resolveRuntimeEntry?: (params: {
runtimeDir: string;
manifest: RuntimeManifest;
}) => string | null;
installRuntime?: (params: {
runtimeDir: string;
manifest: RuntimeManifest;
env: NodeJS.ProcessEnv;
logger?: LanceDbRuntimeLogger;
}) => Promise<string>;
} = {},
) {
return createLanceDbRuntimeLoader({
env: overrides.env ?? ({} as NodeJS.ProcessEnv),
resolveStateDir: () => "/tmp/openclaw-state",
runtimeManifest: TEST_RUNTIME_MANIFEST,
importBundled:
overrides.importBundled ??
(async () => {
throw new Error("Cannot find package '@lancedb/lancedb'");
}),
importResolved: overrides.importResolved ?? (async () => createMockModule()),
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? (() => null),
installRuntime:
overrides.installRuntime ??
(async ({ runtimeDir }: { runtimeDir: string }) =>
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`),
});
}
describe("lancedb runtime loader", () => {
it("uses the bundled module when it is already available", async () => {
const bundledModule = createMockModule();
const importBundled = vi.fn(async () => bundledModule);
const importResolved = vi.fn(async () => createMockModule());
const resolveRuntimeEntry = vi.fn(() => null);
const installRuntime = vi.fn(async () => "/tmp/openclaw-state/plugin-runtimes/lancedb.js");
const loader = createLoader({
importBundled,
importResolved,
resolveRuntimeEntry,
installRuntime,
});
await expect(loader.load()).resolves.toBe(bundledModule);
expect(resolveRuntimeEntry).not.toHaveBeenCalled();
expect(installRuntime).not.toHaveBeenCalled();
expect(importResolved).not.toHaveBeenCalled();
});
it("reuses an existing user runtime install before attempting a reinstall", async () => {
const runtimeModule = createMockModule();
const importResolved = vi.fn(async () => runtimeModule);
const resolveRuntimeEntry = vi.fn(
() => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
);
const installRuntime = vi.fn(
async () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
);
const loader = createLoader({
importResolved,
resolveRuntimeEntry,
installRuntime,
});
await expect(loader.load()).resolves.toBe(runtimeModule);
expect(resolveRuntimeEntry).toHaveBeenCalledWith(
expect.objectContaining({
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
}),
);
expect(installRuntime).not.toHaveBeenCalled();
});
it("installs LanceDB into user state when the bundled runtime is unavailable", async () => {
const runtimeModule = createMockModule();
const logger: LanceDbRuntimeLogger = {
warn: vi.fn(),
info: vi.fn(),
};
const importResolved = vi.fn(async () => runtimeModule);
const resolveRuntimeEntry = vi.fn(() => null);
const installRuntime = vi.fn(
async ({ runtimeDir }: { runtimeDir: string }) =>
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
);
const loader = createLoader({
importResolved,
resolveRuntimeEntry,
installRuntime,
});
await expect(loader.load(logger)).resolves.toBe(runtimeModule);
expect(installRuntime).toHaveBeenCalledWith(
expect.objectContaining({
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
manifest: TEST_RUNTIME_MANIFEST,
}),
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
"installing runtime deps under /tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
),
);
});
it("fails fast in nix mode instead of attempting auto-install", async () => {
const installRuntime = vi.fn(
async ({ runtimeDir }: { runtimeDir: string }) =>
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
);
const loader = createLoader({
env: { OPENCLAW_NIX_MODE: "1" } as NodeJS.ProcessEnv,
installRuntime,
});
await expect(loader.load()).rejects.toThrow(
"memory-lancedb: failed to load LanceDB and Nix mode disables auto-install.",
);
expect(installRuntime).not.toHaveBeenCalled();
});
it("clears the cached failure so later calls can retry the install", async () => {
const runtimeModule = createMockModule();
const installRuntime = vi
.fn()
.mockRejectedValueOnce(new Error("network down"))
.mockResolvedValueOnce(
"/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb/node_modules/@lancedb/lancedb/index.js",
);
const importResolved = vi.fn(async () => runtimeModule);
const loader = createLoader({
installRuntime,
importResolved,
});
await expect(loader.load()).rejects.toThrow("network down");
await expect(loader.load()).resolves.toBe(runtimeModule);
expect(installRuntime).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,266 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { resolveStateDir } from "./api.js";
type LanceDbModule = typeof import("@lancedb/lancedb");
export type LanceDbRuntimeLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
type RuntimeManifest = {
name: string;
private: true;
type: "module";
dependencies: Record<string, string>;
};
type LanceDbRuntimeLoaderDeps = {
env: NodeJS.ProcessEnv;
resolveStateDir: (env?: NodeJS.ProcessEnv, homedir?: () => string) => string;
runtimeManifest: RuntimeManifest;
importBundled: () => Promise<LanceDbModule>;
importResolved: (resolvedPath: string) => Promise<LanceDbModule>;
resolveRuntimeEntry: (params: { runtimeDir: string; manifest: RuntimeManifest }) => string | null;
installRuntime: (params: {
runtimeDir: string;
manifest: RuntimeManifest;
env: NodeJS.ProcessEnv;
logger?: LanceDbRuntimeLogger;
}) => Promise<string>;
};
const MEMORY_LANCEDB_RUNTIME_MANIFEST: RuntimeManifest = (() => {
const packageJson = JSON.parse(
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"),
) as {
dependencies?: Record<string, string>;
};
const lanceDbSpec = packageJson.dependencies?.["@lancedb/lancedb"];
if (!lanceDbSpec) {
throw new Error('memory-lancedb package.json is missing "@lancedb/lancedb"');
}
return {
name: "openclaw-memory-lancedb-runtime",
private: true,
type: "module",
dependencies: {
"@lancedb/lancedb": lanceDbSpec,
},
};
})();
function resolveRuntimeDir(stateDir: string): string {
return path.join(stateDir, "plugin-runtimes", "memory-lancedb", "lancedb");
}
function readRuntimeManifest(filePath: string): RuntimeManifest | null {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as RuntimeManifest;
} catch {
return null;
}
}
function manifestsMatch(actual: RuntimeManifest | null, expected: RuntimeManifest): boolean {
if (!actual) {
return false;
}
return JSON.stringify(actual) === JSON.stringify(expected);
}
function defaultResolveRuntimeEntry(params: {
runtimeDir: string;
manifest: RuntimeManifest;
}): string | null {
const runtimePackagePath = path.join(params.runtimeDir, "package.json");
if (!manifestsMatch(readRuntimeManifest(runtimePackagePath), params.manifest)) {
return null;
}
try {
const runtimeRequire = createRequire(runtimePackagePath);
return runtimeRequire.resolve("@lancedb/lancedb");
} catch {
return null;
}
}
function collectSpawnOutput(params: {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
}): Promise<{ code: number | null; stdout: string; stderr: string; error?: Error }> {
return new Promise((resolve) => {
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
resolve({ code: null, stdout, stderr, error });
});
child.on("close", (code) => {
resolve({ code, stdout, stderr });
});
});
}
async function defaultInstallRuntime(params: {
runtimeDir: string;
manifest: RuntimeManifest;
env: NodeJS.ProcessEnv;
logger?: LanceDbRuntimeLogger;
}): Promise<string> {
const runtimePackagePath = path.join(params.runtimeDir, "package.json");
const currentManifest = readRuntimeManifest(runtimePackagePath);
if (!manifestsMatch(currentManifest, params.manifest)) {
await fs.promises.rm(path.join(params.runtimeDir, "node_modules"), {
recursive: true,
force: true,
});
await fs.promises.rm(path.join(params.runtimeDir, "package-lock.json"), { force: true });
}
await fs.promises.mkdir(params.runtimeDir, { recursive: true });
await fs.promises.writeFile(
runtimePackagePath,
`${JSON.stringify(params.manifest, null, 2)}\n`,
"utf8",
);
const install = await collectSpawnOutput({
command: "npm",
args: ["install", "--omit=dev", "--silent", "--ignore-scripts", "--package-lock=false"],
cwd: params.runtimeDir,
env: params.env,
});
if (install.error) {
const spawnError = install.error as NodeJS.ErrnoException;
throw new Error(
spawnError.code === "ENOENT"
? "npm is required to install the LanceDB runtime but was not found on PATH"
: install.error.message,
);
}
if ((install.code ?? 0) !== 0) {
const detail = install.stderr.trim() || install.stdout.trim();
throw new Error(detail || `npm exited with code ${install.code ?? "unknown"}`);
}
const resolved = defaultResolveRuntimeEntry({
runtimeDir: params.runtimeDir,
manifest: params.manifest,
});
if (!resolved) {
throw new Error("installed LanceDB runtime is missing the @lancedb/lancedb entry");
}
params.logger?.info?.(`memory-lancedb: installed LanceDB runtime under ${params.runtimeDir}`);
return resolved;
}
function defaultImportResolved(resolvedPath: string): Promise<LanceDbModule> {
return import(pathToFileURL(resolvedPath).href);
}
function buildLoadFailureMessage(prefix: string, error: unknown): string {
return `memory-lancedb: ${prefix}. ${String(error)}`;
}
export function createLanceDbRuntimeLoader(overrides: Partial<LanceDbRuntimeLoaderDeps> = {}): {
load: (logger?: LanceDbRuntimeLogger) => Promise<LanceDbModule>;
} {
const deps: LanceDbRuntimeLoaderDeps = {
env: overrides.env ?? process.env,
resolveStateDir: overrides.resolveStateDir ?? resolveStateDir,
runtimeManifest: overrides.runtimeManifest ?? MEMORY_LANCEDB_RUNTIME_MANIFEST,
importBundled: overrides.importBundled ?? (() => import("@lancedb/lancedb")),
importResolved: overrides.importResolved ?? defaultImportResolved,
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? defaultResolveRuntimeEntry,
installRuntime: overrides.installRuntime ?? defaultInstallRuntime,
};
let loadPromise: Promise<LanceDbModule> | null = null;
return {
async load(logger?: LanceDbRuntimeLogger): Promise<LanceDbModule> {
if (!loadPromise) {
loadPromise = (async () => {
try {
return await deps.importBundled();
} catch (bundledError) {
const runtimeDir = resolveRuntimeDir(
deps.resolveStateDir(deps.env, () =>
deps.env.HOME?.trim() ? deps.env.HOME : os.homedir(),
),
);
const existingRuntime = deps.resolveRuntimeEntry({
runtimeDir,
manifest: deps.runtimeManifest,
});
if (existingRuntime) {
try {
return await deps.importResolved(existingRuntime);
} catch {
// Reinstall below when the cached runtime is incomplete or stale.
}
}
if (deps.env.OPENCLAW_NIX_MODE === "1") {
throw new Error(
buildLoadFailureMessage(
"failed to load LanceDB and Nix mode disables auto-install",
bundledError,
),
{ cause: bundledError },
);
}
logger?.warn?.(
`memory-lancedb: bundled LanceDB runtime unavailable (${String(bundledError)}); installing runtime deps under ${runtimeDir}`,
);
const installedEntry = await deps.installRuntime({
runtimeDir,
manifest: deps.runtimeManifest,
env: deps.env,
logger,
});
try {
return await deps.importResolved(installedEntry);
} catch (runtimeError) {
throw new Error(
buildLoadFailureMessage(
"failed to load LanceDB after installing runtime deps",
runtimeError,
),
{ cause: runtimeError },
);
}
}
})().catch((error) => {
loadPromise = null;
throw error;
});
}
return await loadPromise;
},
};
}
const defaultLoader = createLanceDbRuntimeLoader();
export async function loadLanceDbModule(logger?: LanceDbRuntimeLogger): Promise<LanceDbModule> {
return await defaultLoader.load(logger);
}
+4
View File
@@ -1,4 +1,5 @@
import { randomBytes, randomUUID } from "node:crypto";
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/infra-runtime";
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
export type MiniMaxRegion = "cn" | "global";
@@ -184,6 +185,9 @@ export async function loginMiniMaxPortalOAuth(params: {
progress: { update: (message: string) => void; stop: (message?: string) => void };
region?: MiniMaxRegion;
}): Promise<MiniMaxOAuthToken> {
// Ensure env-based proxy dispatcher is active before any outbound fetch calls.
// Without this, HTTP_PROXY/HTTPS_PROXY env vars are silently ignored (#51619).
ensureGlobalUndiciEnvProxyDispatcher();
const region = params.region ?? "global";
const { verifier, challenge, state } = generatePkce();
const oauth = await requestOAuthCode({ challenge, state, region });
+2 -2
View File
@@ -39,13 +39,13 @@ describe("mistral model definitions", () => {
reasoning: true,
input: ["text"],
contextWindow: 128000,
maxTokens: 128000,
maxTokens: 40000,
}),
expect.objectContaining({
id: "pixtral-large-latest",
input: ["text", "image"],
contextWindow: 128000,
maxTokens: 128000,
maxTokens: 32768,
}),
]),
);
+5 -5
View File
@@ -4,7 +4,7 @@ export const MISTRAL_BASE_URL = "https://api.mistral.ai/v1";
export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest";
export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`;
export const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144;
export const MISTRAL_DEFAULT_MAX_TOKENS = 262144;
export const MISTRAL_DEFAULT_MAX_TOKENS = 16384;
export const MISTRAL_DEFAULT_COST = {
input: 0.5,
output: 1.5,
@@ -29,7 +29,7 @@ const MISTRAL_MODEL_CATALOG = [
input: ["text"],
cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
maxTokens: 32768,
},
{
id: "magistral-small",
@@ -38,7 +38,7 @@ const MISTRAL_MODEL_CATALOG = [
input: ["text"],
cost: { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 128000,
maxTokens: 40000,
},
{
id: "mistral-large-latest",
@@ -56,7 +56,7 @@ const MISTRAL_MODEL_CATALOG = [
input: ["text", "image"],
cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
maxTokens: 8192,
},
{
id: "mistral-small-latest",
@@ -74,7 +74,7 @@ const MISTRAL_MODEL_CATALOG = [
input: ["text", "image"],
cost: { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 128000,
maxTokens: 32768,
},
] as const satisfies readonly ModelDefinitionConfig[];
+52 -6
View File
@@ -2,6 +2,8 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-en
import {
applyModelStudioConfig,
applyModelStudioConfigCn,
applyModelStudioStandardConfig,
applyModelStudioStandardConfigCn,
MODELSTUDIO_DEFAULT_MODEL_REF,
} from "./onboard.js";
import { buildModelStudioProvider } from "./provider-catalog.js";
@@ -16,6 +18,50 @@ export default defineSingleProviderPluginEntry({
label: "Model Studio",
docsPath: "/providers/models",
auth: [
{
methodId: "standard-api-key-cn",
label: "Standard API Key for China (pay-as-you-go)",
hint: "Endpoint: dashscope.aliyuncs.com",
optionKey: "modelstudioStandardApiKeyCn",
flagName: "--modelstudio-standard-api-key-cn",
envVar: "MODELSTUDIO_API_KEY",
promptMessage: "Enter Alibaba Cloud Model Studio API key (China)",
defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyModelStudioStandardConfigCn(cfg),
noteMessage: [
"Get your API key at: https://bailian.console.aliyun.com/",
"Endpoint: dashscope.aliyuncs.com/compatible-mode/v1",
"Models: qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, etc.",
].join("\n"),
noteTitle: "Alibaba Cloud Model Studio Standard (China)",
wizard: {
choiceHint: "Endpoint: dashscope.aliyuncs.com",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
},
},
{
methodId: "standard-api-key",
label: "Standard API Key for Global/Intl (pay-as-you-go)",
hint: "Endpoint: dashscope-intl.aliyuncs.com",
optionKey: "modelstudioStandardApiKey",
flagName: "--modelstudio-standard-api-key",
envVar: "MODELSTUDIO_API_KEY",
promptMessage: "Enter Alibaba Cloud Model Studio API key (Global/Intl)",
defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyModelStudioStandardConfig(cfg),
noteMessage: [
"Get your API key at: https://modelstudio.console.alibabacloud.com/",
"Endpoint: dashscope-intl.aliyuncs.com/compatible-mode/v1",
"Models: qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, etc.",
].join("\n"),
noteTitle: "Alibaba Cloud Model Studio Standard (Global/Intl)",
wizard: {
choiceHint: "Endpoint: dashscope-intl.aliyuncs.com",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
},
},
{
methodId: "api-key-cn",
label: "Coding Plan API Key for China (subscription)",
@@ -29,13 +75,13 @@ export default defineSingleProviderPluginEntry({
noteMessage: [
"Get your API key at: https://bailian.console.aliyun.com/",
"Endpoint: coding.dashscope.aliyuncs.com",
"Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.",
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
].join("\n"),
noteTitle: "Alibaba Cloud Model Studio Coding Plan (China)",
wizard: {
choiceHint: "Endpoint: coding.dashscope.aliyuncs.com",
groupLabel: "Alibaba Cloud Model Studio",
groupHint: "Coding Plan API key (CN / Global)",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
},
},
{
@@ -51,13 +97,13 @@ export default defineSingleProviderPluginEntry({
noteMessage: [
"Get your API key at: https://bailian.console.aliyun.com/",
"Endpoint: coding-intl.dashscope.aliyuncs.com",
"Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.",
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
].join("\n"),
noteTitle: "Alibaba Cloud Model Studio Coding Plan (Global/Intl)",
wizard: {
choiceHint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
groupLabel: "Alibaba Cloud Model Studio",
groupHint: "Coding Plan API key (CN / Global)",
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
groupHint: "Standard / Coding Plan (CN / Global)",
},
},
],
@@ -2,6 +2,9 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"
export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1";
export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
export const MODELSTUDIO_STANDARD_CN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
export const MODELSTUDIO_STANDARD_GLOBAL_BASE_URL =
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus";
export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`;
export const MODELSTUDIO_DEFAULT_COST = {
+25 -1
View File
@@ -6,10 +6,18 @@ import {
MODELSTUDIO_CN_BASE_URL,
MODELSTUDIO_DEFAULT_MODEL_REF,
MODELSTUDIO_GLOBAL_BASE_URL,
MODELSTUDIO_STANDARD_CN_BASE_URL,
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
} from "./model-definitions.js";
import { buildModelStudioProvider } from "./provider-catalog.js";
export { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL };
export {
MODELSTUDIO_CN_BASE_URL,
MODELSTUDIO_DEFAULT_MODEL_REF,
MODELSTUDIO_GLOBAL_BASE_URL,
MODELSTUDIO_STANDARD_CN_BASE_URL,
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
};
const modelStudioPresetAppliers = createModelCatalogPresetAppliers<[string]>({
primaryModelRef: MODELSTUDIO_DEFAULT_MODEL_REF,
@@ -43,3 +51,19 @@ export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig {
export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_CN_BASE_URL);
}
export function applyModelStudioStandardProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyProviderConfig(cfg, MODELSTUDIO_STANDARD_GLOBAL_BASE_URL);
}
export function applyModelStudioStandardProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyProviderConfig(cfg, MODELSTUDIO_STANDARD_CN_BASE_URL);
}
export function applyModelStudioStandardConfig(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_STANDARD_GLOBAL_BASE_URL);
}
export function applyModelStudioStandardConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_STANDARD_CN_BASE_URL);
}
+32 -4
View File
@@ -5,6 +5,34 @@
"modelstudio": ["MODELSTUDIO_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "modelstudio",
"method": "standard-api-key-cn",
"choiceId": "modelstudio-standard-api-key-cn",
"choiceLabel": "Standard API Key for China (pay-as-you-go)",
"choiceHint": "Endpoint: dashscope.aliyuncs.com",
"groupId": "modelstudio",
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
"groupHint": "Standard / Coding Plan (CN / Global)",
"optionKey": "modelstudioStandardApiKeyCn",
"cliFlag": "--modelstudio-standard-api-key-cn",
"cliOption": "--modelstudio-standard-api-key-cn <key>",
"cliDescription": "Alibaba Cloud Model Studio Standard API key (China)"
},
{
"provider": "modelstudio",
"method": "standard-api-key",
"choiceId": "modelstudio-standard-api-key",
"choiceLabel": "Standard API Key for Global/Intl (pay-as-you-go)",
"choiceHint": "Endpoint: dashscope-intl.aliyuncs.com",
"groupId": "modelstudio",
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
"groupHint": "Standard / Coding Plan (CN / Global)",
"optionKey": "modelstudioStandardApiKey",
"cliFlag": "--modelstudio-standard-api-key",
"cliOption": "--modelstudio-standard-api-key <key>",
"cliDescription": "Alibaba Cloud Model Studio Standard API key (Global/Intl)"
},
{
"provider": "modelstudio",
"method": "api-key-cn",
@@ -12,8 +40,8 @@
"choiceLabel": "Coding Plan API Key for China (subscription)",
"choiceHint": "Endpoint: coding.dashscope.aliyuncs.com",
"groupId": "modelstudio",
"groupLabel": "Alibaba Cloud Model Studio",
"groupHint": "Coding Plan API key (CN / Global)",
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
"groupHint": "Standard / Coding Plan (CN / Global)",
"optionKey": "modelstudioApiKeyCn",
"cliFlag": "--modelstudio-api-key-cn",
"cliOption": "--modelstudio-api-key-cn <key>",
@@ -26,8 +54,8 @@
"choiceLabel": "Coding Plan API Key for Global/Intl (subscription)",
"choiceHint": "Endpoint: coding-intl.dashscope.aliyuncs.com",
"groupId": "modelstudio",
"groupLabel": "Alibaba Cloud Model Studio",
"groupHint": "Coding Plan API key (CN / Global)",
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
"groupHint": "Standard / Coding Plan (CN / Global)",
"optionKey": "modelstudioApiKey",
"cliFlag": "--modelstudio-api-key",
"cliOption": "--modelstudio-api-key <key>",
+12 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MSTeamsConfig } from "../runtime-api.js";
const hostMockState = vi.hoisted(() => ({
@@ -20,6 +20,17 @@ vi.mock("@microsoft/agents-hosting", () => ({
import { probeMSTeams } from "./probe.js";
describe("msteams probe", () => {
beforeEach(() => {
hostMockState.tokenError = null;
vi.stubEnv("MSTEAMS_APP_ID", "");
vi.stubEnv("MSTEAMS_APP_PASSWORD", "");
vi.stubEnv("MSTEAMS_TENANT_ID", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("returns an error when credentials are missing", async () => {
const cfg = { enabled: true } as unknown as MSTeamsConfig;
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
@@ -28,7 +39,6 @@ describe("msteams probe", () => {
});
it("validates credentials by acquiring a token", async () => {
hostMockState.tokenError = null;
const cfg = {
enabled: true,
appId: "app",
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureGlobalUndiciEnvProxyDispatcher: vi.fn(),
getOAuthApiKey: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
ensureGlobalUndiciEnvProxyDispatcher: mocks.ensureGlobalUndiciEnvProxyDispatcher,
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: mocks.getOAuthApiKey,
}));
import { getOAuthApiKey } from "./openai-codex-provider.runtime.js";
describe("openai-codex-provider.runtime", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("bootstraps the env proxy dispatcher before refreshing oauth credentials", async () => {
const refreshed = {
newCredentials: {
access: "next-access",
refresh: "next-refresh",
expires: Date.now() + 60_000,
},
};
mocks.getOAuthApiKey.mockResolvedValue(refreshed);
await expect(
getOAuthApiKey("openai-codex", {
"openai-codex": {
provider: "openai-codex",
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: Date.now(),
},
}),
).resolves.toBe(refreshed);
expect(mocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce();
expect(mocks.getOAuthApiKey).toHaveBeenCalledOnce();
expect(mocks.ensureGlobalUndiciEnvProxyDispatcher.mock.invocationCallOrder[0]).toBeLessThan(
mocks.getOAuthApiKey.mock.invocationCallOrder[0],
);
});
});
@@ -1 +1,9 @@
export { getOAuthApiKey } from "@mariozechner/pi-ai/oauth";
import { getOAuthApiKey as getOAuthApiKeyFromPi } from "@mariozechner/pi-ai/oauth";
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/infra-runtime";
export async function getOAuthApiKey(
...args: Parameters<typeof getOAuthApiKeyFromPi>
): Promise<Awaited<ReturnType<typeof getOAuthApiKeyFromPi>>> {
ensureGlobalUndiciEnvProxyDispatcher();
return await getOAuthApiKeyFromPi(...args);
}
+6 -7
View File
@@ -3,19 +3,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchWithTimeoutMock = vi.fn();
const resolveFetchMock = vi.fn();
vi.mock("../../../src/infra/fetch.js", () => ({
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
resolveFetch: (...args: unknown[]) => resolveFetchMock(...args),
}));
vi.mock("../../../src/infra/secure-random.js", () => ({
generateSecureUuid: () => "test-id",
}));
vi.mock("../../../src/utils/fetch-timeout.js", () => ({
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args),
}));
import { signalRpcRequest } from "./client.js";
let signalRpcRequest: typeof import("./client.js").signalRpcRequest;
function rpcResponse(body: unknown, status = 200): Response {
if (typeof body === "string") {
@@ -25,9 +22,11 @@ function rpcResponse(body: unknown, status = 200): Response {
}
describe("signalRpcRequest", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
resolveFetchMock.mockReturnValue(vi.fn());
({ signalRpcRequest } = await import("./client.js"));
});
it("returns parsed RPC result", async () => {
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
config,
flush,
@@ -8,9 +8,7 @@ import {
} from "./monitor.tool-result.test-harness.js";
installSignalToolResultTestHooks();
// Import after the harness registers `vi.mock(...)` for Signal internals.
const { monitorSignalProvider } = await import("./monitor.js");
let monitorSignalProvider: typeof import("./monitor.js").monitorSignalProvider;
const { replyMock, sendMock, streamMock, upsertPairingRequestMock } =
getSignalToolResultTestMocks();
@@ -21,6 +19,11 @@ async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
return monitorSignalProvider(opts);
}
describe("monitorSignalProvider tool results", () => {
beforeEach(async () => {
vi.resetModules();
({ monitorSignalProvider } = await import("./monitor.js"));
});
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
const baseChannels = (config.channels ?? {}) as Record<string, unknown>;
const baseSignal = (baseChannels.signal ?? {}) as Record<string, unknown>;
@@ -1,11 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
import { createSignalEventHandler } from "./event-handler.js";
import {
createBaseSignalEventHandlerDeps,
createSignalReceiveEvent,
} from "./event-handler.test-harness.js";
let expectInboundContextContract: typeof import("../../../../src/channels/plugins/contracts/suites.js").expectChannelInboundContextContract;
let createBaseSignalEventHandlerDeps: typeof import("./event-handler.test-harness.js").createBaseSignalEventHandlerDeps;
let createSignalReceiveEvent: typeof import("./event-handler.test-harness.js").createSignalReceiveEvent;
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
() => {
@@ -49,8 +46,17 @@ vi.mock("../../../../src/pairing/pairing-store.js", () => ({
upsertChannelPairingRequest: vi.fn(),
}));
let createSignalEventHandler: typeof import("./event-handler.js").createSignalEventHandler;
describe("signal createSignalEventHandler inbound context", () => {
beforeEach(() => {
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
({ expectChannelInboundContextContract: expectInboundContextContract } =
await import("../../../../src/channels/plugins/contracts/suites.js"));
({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
await import("./event-handler.test-harness.js"));
({ createSignalEventHandler } = await import("./event-handler.js"));
capture.ctx = undefined;
sendTypingMock.mockReset().mockResolvedValue(true);
sendReadReceiptMock.mockReset().mockResolvedValue(true);
@@ -1,11 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
import { buildDispatchInboundCaptureMock } from "../../../../src/channels/plugins/contracts/inbound-testkit.js";
import type { OpenClawConfig } from "../../../../src/config/types.js";
import {
createBaseSignalEventHandlerDeps,
createSignalReceiveEvent,
} from "./event-handler.test-harness.js";
type SignalMsgContext = Pick<MsgContext, "Body" | "WasMentioned"> & {
Body?: string;
@@ -18,15 +14,17 @@ function getCapturedCtx() {
return capturedCtx as SignalMsgContext;
}
vi.mock("../../../../src/auto-reply/dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/auto-reply/dispatch.js")>();
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return buildDispatchInboundCaptureMock(actual, (ctx) => {
capturedCtx = ctx as SignalMsgContext;
});
});
import { createSignalEventHandler } from "./event-handler.js";
import { renderSignalMentions } from "./mentions.js";
let createBaseSignalEventHandlerDeps: typeof import("./event-handler.test-harness.js").createBaseSignalEventHandlerDeps;
let createSignalReceiveEvent: typeof import("./event-handler.test-harness.js").createSignalReceiveEvent;
let createSignalEventHandler: typeof import("./event-handler.js").createSignalEventHandler;
let renderSignalMentions: typeof import("./mentions.js").renderSignalMentions;
type GroupEventOpts = {
message?: string;
@@ -102,8 +100,16 @@ async function expectSkippedGroupHistory(opts: GroupEventOpts, expectedBody: str
}
describe("signal mention gating", () => {
it("drops group messages without mention when requireMention is configured", async () => {
beforeEach(async () => {
vi.resetModules();
capturedCtx = undefined;
({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
await import("./event-handler.test-harness.js"));
({ createSignalEventHandler } = await import("./event-handler.js"));
({ renderSignalMentions } = await import("./mentions.js"));
});
it("drops group messages without mention when requireMention is configured", async () => {
const handler = createMentionHandler({ requireMention: true });
await handler(makeGroupEvent({ message: "hello everyone" }));
@@ -111,7 +117,6 @@ describe("signal mention gating", () => {
});
it("allows group messages with mention when requireMention is configured", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({ requireMention: true });
await handler(makeGroupEvent({ message: "hey @bot what's up" }));
@@ -120,7 +125,6 @@ describe("signal mention gating", () => {
});
it("sets WasMentioned=false for group messages without mention when requireMention is off", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({ requireMention: false });
await handler(makeGroupEvent({ message: "hello everyone" }));
@@ -129,7 +133,6 @@ describe("signal mention gating", () => {
});
it("records pending history for skipped group messages", async () => {
capturedCtx = undefined;
const { handler, groupHistories } = createMentionGatedHistoryHandler();
await handler(makeGroupEvent({ message: "hello from alice" }));
expect(capturedCtx).toBeUndefined();
@@ -147,7 +150,6 @@ describe("signal mention gating", () => {
});
it("normalizes mixed-case parameterized attachment MIME in skipped pending history", async () => {
capturedCtx = undefined;
const groupHistories = new Map();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
@@ -172,7 +174,6 @@ describe("signal mention gating", () => {
});
it("summarizes multiple skipped attachments with stable file count wording", async () => {
capturedCtx = undefined;
const groupHistories = new Map();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
@@ -204,7 +205,6 @@ describe("signal mention gating", () => {
});
it("bypasses mention gating for authorized control commands", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({ requireMention: true });
await handler(makeGroupEvent({ message: "/help" }));
@@ -212,7 +212,6 @@ describe("signal mention gating", () => {
});
it("hydrates mention placeholders before trimming so offsets stay aligned", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({ requireMention: false });
const placeholder = "\uFFFC";
@@ -237,7 +236,6 @@ describe("signal mention gating", () => {
});
it("counts mention metadata replacements toward requireMention gating", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({
requireMention: true,
mentionPattern: "@123e4567",
@@ -7,7 +7,7 @@ vi.mock("./monitor/media.js", () => ({
resolveSlackMedia: (...args: Parameters<typeof resolveSlackMedia>) => resolveSlackMedia(...args),
}));
const { downloadSlackFile } = await import("./actions.js");
let downloadSlackFile: typeof import("./actions.js").downloadSlackFile;
function createClient() {
return {
@@ -68,8 +68,10 @@ function mockSuccessfulMediaDownload(client: ReturnType<typeof createClient>) {
}
describe("downloadSlackFile", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
resolveSlackMedia.mockReset();
({ downloadSlackFile } = await import("./actions.js"));
});
it("returns null when files.info has no private download URL", async () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import {
type ChannelMessageActionAdapter,
type ChannelMessageToolDiscovery,
@@ -40,7 +41,7 @@ export function createSlackActions(
schema: actions.includes("send")
? {
properties: {
blocks: createSlackMessageToolBlocksSchema(),
blocks: Type.Optional(createSlackMessageToolBlocksSchema()),
},
}
: null,
+25
View File
@@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import { slackOutbound } from "./outbound-adapter.js";
@@ -96,6 +97,25 @@ describe("slackPlugin actions", () => {
});
});
it("keeps blocks optional in the message tool schema", () => {
const discovery = slackPlugin.actions?.describeMessageTool({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
});
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected slack message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
});
it("forwards read threadId to Slack action handler", async () => {
handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false });
const handleAction = requireSlackHandleAction();
@@ -172,6 +192,11 @@ describe("slackPlugin outbound", () => {
},
};
it("advertises the 8000-character Slack default chunk limit", () => {
expect(slackOutbound.textChunkLimit).toBe(8000);
expect(slackPlugin.outbound?.textChunkLimit).toBe(8000);
});
it("uses threadId as threadTs fallback for sendText", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" });
const sendText = requireSlackSendText();
+2 -1
View File
@@ -46,6 +46,7 @@ import {
} from "./directory-config.js";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
import type { SlackProbe } from "./probe.js";
import { resolveSlackUserAllowlist } from "./resolve-users.js";
@@ -602,7 +603,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
base: {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
textChunkLimit: SLACK_TEXT_LIMIT,
},
attachedResults: {
channel: "slack",
+12 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@slack/web-api", () => {
const WebClient = vi.fn(function WebClientMock(
@@ -12,11 +12,18 @@ vi.mock("@slack/web-api", () => {
return { WebClient };
});
const slackWebApi = await import("@slack/web-api");
const { createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } =
await import("./client.js");
let createSlackWebClient: typeof import("./client.js").createSlackWebClient;
let resolveSlackWebClientOptions: typeof import("./client.js").resolveSlackWebClientOptions;
let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETRY_OPTIONS;
let WebClient: ReturnType<typeof vi.fn>;
const WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
const slackWebApi = await import("@slack/web-api");
({ createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } =
await import("./client.js"));
WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
});
describe("slack web client config", () => {
it("applies the default retry config when none is provided", () => {
+18
View File
@@ -98,6 +98,24 @@ describe("createSlackDraftStream", () => {
expect(warn).toHaveBeenCalledTimes(1);
});
it("allows a 4205-character preview with the default max chars", async () => {
const { stream, send, warn } = createDraftStreamHarness();
const text = "a".repeat(4205);
stream.update(text);
await stream.flush();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
"channel:C123",
text,
expect.objectContaining({
token: "xoxb-test",
}),
);
expect(warn).not.toHaveBeenCalled();
});
it("clear removes preview message when one exists", async () => {
const { stream, remove } = createDraftStreamHarness();
+2 -2
View File
@@ -1,8 +1,8 @@
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { sendMessageSlack } from "./send.js";
const SLACK_STREAM_MAX_CHARS = 4000;
const DEFAULT_THROTTLE_MS = 1000;
export type SlackDraftStream = {
@@ -29,7 +29,7 @@ export function createSlackDraftStream(params: {
edit?: typeof editSlackMessage;
remove?: typeof deleteSlackMessage;
}): SlackDraftStream {
const maxChars = Math.min(params.maxChars ?? SLACK_STREAM_MAX_CHARS, SLACK_STREAM_MAX_CHARS);
const maxChars = Math.min(params.maxChars ?? SLACK_TEXT_LIMIT, SLACK_TEXT_LIMIT);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const send = params.send ?? sendMessageSlack;
const edit = params.edit ?? editSlackMessage;
+1
View File
@@ -0,0 +1 @@
export const SLACK_TEXT_LIMIT = 8000;

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