Compare commits

..

131 Commits

Author SHA1 Message Date
Val Alexander
e9b7ccbd6e format: fix formatting after rebase 2026-03-17 23:58:55 -05:00
Peter Steinberger
f7483b35c3 fix(slack): repair gateway watch runtime export 2026-03-17 23:57:35 -05:00
Peter Steinberger
026be9dfae refactor: deduplicate channel config adapters 2026-03-17 23:57:35 -05:00
Tak Hoffman
18a8477f03 chore finalize web search provider boundaries 2026-03-17 23:57:35 -05:00
Peter Steinberger
b41aa861d5 test: harden prompt composition coverage 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
1dfeb21cb5 test(telegram): pass explicit deps in command tests 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
3bf540170a test(telegram): inject bot deps in harnesses 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
e5fd061470 refactor(telegram): inject shared bot deps 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
3a4ed87599 test(telegram): align media harness with runtime seam 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
97ae0158e8 test(telegram): rewire bot harnesses to runtime seams 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
1662f3f5da test(telegram): add dispatch and handler seams 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
b00f3f9b64 test(telegram): add bot runtime seam 2026-03-17 23:57:35 -05:00
Tak Hoffman
5484225b2d test add extension plugin sdk boundary guards 2026-03-17 23:57:35 -05:00
Tak Hoffman
a03f43d5bd refactor web search config ownership into extensions 2026-03-17 23:57:35 -05:00
Peter Steinberger
84cf8c32aa fix: repair plugin runtime api imports 2026-03-17 23:57:35 -05:00
Gustavo Madeira Santana
65dff0c2a3 Docs: clarify plugin target resolution and directories 2026-03-17 23:57:35 -05:00
Vincent Koc
5d439a43f4 Plugins: internalize slack SDK imports 2026-03-17 23:57:34 -05:00
Vincent Koc
10c16d0de8 Plugins: internalize imessage SDK imports 2026-03-17 23:57:34 -05:00
Vincent Koc
d3ca5fb8a1 Image generation: add fal provider (#49454) 2026-03-17 23:57:34 -05:00
Vincent Koc
cd60db8f54 Tests: clean up trusted proxy pairing seed 2026-03-17 23:57:34 -05:00
joshavant
9b4468fb82 Changelog: update secrets exec refs attribution 2026-03-17 23:57:34 -05:00
Peter Steinberger
49c5f4eb1e feat: finish xai provider integration 2026-03-17 23:57:34 -05:00
Gustavo Madeira Santana
3186cbbbe2 Plugins: move config-backed directories behind channel plugins 2026-03-17 23:56:58 -05:00
Peter Steinberger
348016d8a8 refactor: split remaining monitor runtime helpers 2026-03-17 23:56:58 -05:00
Gustavo Madeira Santana
0d438921ea Outbound: move target resolution heuristics behind plugins 2026-03-17 23:56:58 -05:00
Josh Avant
50a2be72fe Secrets: gate exec dry-run and preflight resolution behind --allow-exec (#49417)
* Secrets: gate exec dry-run resolution behind --allow-exec

* Secrets: fix dry-run completeness and skipped exec audit semantics

* Secrets: require --allow-exec for exec-containing apply writes

* Docs: align secrets exec consent behavior

* Changelog: note secrets exec consent gating
2026-03-17 23:56:58 -05:00
Vincent Koc
371732f399 docs(plugins): dedup in-process trust refs and add manifest cross-references
- Replace redundant in-process trust statements with cross-references
  to the Execution model section (lines 573, 2436)
- Add CLI reference link from plugin.md CLI section
- Add configuration reference link from manifest.md validation section
- Add provider runtime hooks link from manifest.md providerAuthChoices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 23:56:57 -05:00
Val Alexander
473ba3149b changelog: add entries for UI expand/navigate and plugin TDZ/import fixes 2026-03-17 23:53:03 -05:00
Val Alexander
5116dbeb60 Plugins: use var for reserved commands to avoid bundler TDZ; scope expand button to assistant; wire cron run navigate 2026-03-17 23:53:03 -05:00
Val Alexander
0bd4c7cd43 Update ui/src/ui/chat/grouped-render.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-17 23:49:00 -05:00
Val Alexander
feddfb4859 Refactor: lazy initialize reserved commands to avoid TDZ errors 2026-03-17 23:43:54 -05:00
Val Alexander
95e20e8a35 UI: add expand-to-canvas button and session navigation from sessions/cron views 2026-03-17 23:42:22 -05:00
Val Alexander
52662d39d6 format: sort imports and fix parameter formatting 2026-03-17 23:42:13 -05:00
Val Alexander
3d0b3526e0 Plugins: fix googlechat runtime-api and signal SDK import paths 2026-03-17 23:42:03 -05:00
Val Alexander
0a30d5a4e1 UI: mute colored focus ring on agent chat textarea 2026-03-17 23:25:18 -05:00
Gustavo Madeira Santana
f842de046c doctor: clarify orphan transcript archive prompt 2026-03-18 04:15:17 +00:00
Val Alexander
e5eda19db2 UI: fix redundant applyBorderRadius call and restore session-scope cap test (#49443) 2026-03-17 23:14:43 -05:00
Val Alexander
1e1bc24f80 Enhance settings persistence: add error handling for storage operations to ensure in-memory updates are applied even when storage quota is exceeded or restricted. 2026-03-17 23:14:22 -05:00
Vincent Koc
2d87bc703f Plugins: align googlechat runtime imports 2026-03-17 21:11:18 -07:00
Vincent Koc
2b67a3f76e Plugins: internalize googlechat SDK imports 2026-03-17 21:11:17 -07:00
Vincent Koc
4285eb3539 Plugins: internalize signal SDK imports 2026-03-17 21:11:15 -07:00
Vincent Koc
0636c6eafa Plugins: internalize irc SDK imports 2026-03-17 21:11:14 -07:00
Gustavo Madeira Santana
2a02337be2 Feishu: move outbound session routing behind plugin boundary 2026-03-18 04:09:49 +00:00
Gustavo Madeira Santana
c03b0877d0 Tlon: move outbound session routing behind plugin boundary 2026-03-18 04:09:49 +00:00
Gustavo Madeira Santana
de0285d8ea Nostr: move outbound session routing behind plugin boundary 2026-03-18 04:09:49 +00:00
Gustavo Madeira Santana
b8dd6548aa Zalo User: move outbound session routing behind plugin boundary 2026-03-18 04:09:49 +00:00
Gustavo Madeira Santana
33bcf11c3f Zalo: move outbound session routing behind plugin boundary 2026-03-18 04:09:48 +00:00
Gustavo Madeira Santana
6816c76738 Nextcloud Talk: move outbound session routing behind plugin boundary 2026-03-18 04:09:48 +00:00
Gustavo Madeira Santana
0f7cd59824 BlueBubbles: move outbound session routing behind plugin boundary 2026-03-18 04:09:48 +00:00
Gustavo Madeira Santana
d6c13d9dc0 Mattermost: move outbound session routing behind plugin boundary 2026-03-18 04:09:48 +00:00
Gustavo Madeira Santana
028f3c4d15 MSTeams: move outbound session routing behind plugin boundary 2026-03-18 04:09:48 +00:00
Gustavo Madeira Santana
d1d36da700 Matrix: move outbound session routing behind plugin boundary 2026-03-18 04:09:48 +00:00
Gustavo Madeira Santana
fa896704d2 WhatsApp: move outbound session routing behind plugin boundary 2026-03-18 04:09:47 +00:00
Gustavo Madeira Santana
6ba15aadcc Discord: export runtime config helpers 2026-03-18 04:09:47 +00:00
Gustavo Madeira Santana
4079de21ce Outbound: route sessions through channel plugins 2026-03-18 04:09:47 +00:00
Gustavo Madeira Santana
826c592deb Plugin SDK: add outbound session route helpers 2026-03-18 04:09:47 +00:00
Tak Hoffman
92a40d324a test refresh boundary inventories for web search migration 2026-03-17 23:07:19 -05:00
Tak Hoffman
3de973ffff refactor web search provider execution out of core 2026-03-17 23:07:19 -05:00
Val Alexander
df72ca1ece UI: add corner radius slider and appearance polish (#49436)
* Refactor CSS styles: replace hardcoded colors with CSS variables for accent colors and optimize spacing rules in layout files.

* Update CSS styles: streamline selectors, enhance hover effects, and adjust focus states for chat components and layout elements.

* Enhance focus styles for chat components: update border colors and box-shadow effects for improved accessibility and visual consistency.

* Implement theme management in UI: add dynamic theme switching based on user settings, update CSS variables for new themes, and enhance security by preventing prototype pollution in form utilities.

* Implement border radius customization in UI: add settings for corner roundness, update CSS styles for sliders, and integrate border radius adjustments across components.

* Remove border radius property from UI settings and related functions to simplify configuration and enhance consistency across components.

* Enhance responsive design in UI: add media queries for mobile layouts, adjust padding and grid structures, and implement bottom navigation for improved usability on smaller screens.

* UI: add corner radius slider to Appearance settings
2026-03-17 23:06:01 -05:00
Peter Steinberger
1a9114a169 refactor: deduplicate setup wizard helpers 2026-03-18 03:58:22 +00:00
Vincent Koc
1c81b82f48 Config: warn on plugin compatibility debt 2026-03-17 20:56:16 -07:00
Tak Hoffman
24dc91c6ef ci add time-gated boundary inventory jobs 2026-03-17 22:53:12 -05:00
Tak Hoffman
e691345774 fix preserve plugin-sdk web search compatibility 2026-03-17 22:53:12 -05:00
Peter Steinberger
326c660775 fix: restore discord runtime api exports after rebase 2026-03-17 20:52:42 -07:00
Peter Steinberger
a2518a16ac refactor: split monitor runtime helpers 2026-03-17 20:52:42 -07:00
Peter Steinberger
fb5ab95e03 build: update deps except carbon 2026-03-17 20:51:54 -07:00
Ayaan Zaidi
a89cb3e10e refactor(telegram): unify action normalization 2026-03-18 09:15:41 +05:30
Vincent Koc
4c9028439c Tests: make seam guardrails path-safe 2026-03-17 20:44:37 -07:00
Vincent Koc
2c35faf437 docs: fix "a OpenClaw" → "an OpenClaw" grammar across docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:43:18 -07:00
Vincent Koc
d2ef865073 docs(plugins): deduplicate and cross-reference plugin capability docs
- Merge hook order + which-hook-to-use into single reference table
- Deduplicate npm spec restrictions (link to CLI reference)
- Deduplicate plugin shapes in cli/plugins.md (link to main definition)
- Add capability-cookbook to docs.json navigation
- Add cross-references: Architecture→Load pipeline, Config→configuration
  reference, Plugin slots→manifest kind, Adding capability→cookbook
- Add missing cursor bundle subtype in 3 locations
- Fix verbose/info→verbose/inspect references
- Remove duplicate "info is alias for inspect" note
- Add missing install command to CLI command summary
- Replace premature "shape" jargon with "pattern" before definition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:43:18 -07:00
Gustavo Madeira Santana
1aae93b1fa LINE: remove shared group mentions helper 2026-03-18 03:43:07 +00:00
Vincent Koc
f253f14b0b Plugins: internalize discord SDK imports 2026-03-17 20:42:28 -07:00
Gustavo Madeira Santana
a8f433d611 BlueBubbles: move group policy behind plugin boundary 2026-03-18 03:40:42 +00:00
Gustavo Madeira Santana
bf8702973f Google Chat: move group policy behind plugin boundary 2026-03-18 03:39:25 +00:00
Gustavo Madeira Santana
4e706da898 iMessage: fix group policy config import 2026-03-18 03:39:21 +00:00
Gustavo Madeira Santana
1f5f3fc2ef iMessage: move group policy behind plugin boundary 2026-03-18 03:38:01 +00:00
Gustavo Madeira Santana
c29458d407 WhatsApp: move group policy behind plugin boundary 2026-03-18 03:38:01 +00:00
Gustavo Madeira Santana
a4b98f95c2 Changelog: attribute message discovery break 2026-03-18 03:38:01 +00:00
Peter Steinberger
9c12b41c52 fix: restore plugin sdk exports after rebase 2026-03-17 20:36:03 -07:00
Peter Steinberger
005b25e9d4 refactor: split remaining monitor runtime helpers 2026-03-17 20:36:03 -07:00
Vincent Koc
6556a40330 Tests: drop unstable plugins cli coverage 2026-03-17 20:34:51 -07:00
Vincent Koc
5c4903d3fd Plugins: centralize compatibility formatting 2026-03-17 20:33:12 -07:00
Gustavo Madeira Santana
7ba8dd112f Telegram: move group policy behind plugin boundary 2026-03-18 03:32:51 +00:00
Vincent Koc
a34944c918 Tests: pin Telegram fallback host (#49364)
* Tests: pin Telegram fallback host

* Changelog: note Telegram fallback guardrail
2026-03-17 20:32:38 -07:00
Vincent Koc
f8f9e06b58 Guardrails: pin runtime-api export seams (#49371)
* Guardrails: pin runtime-api export seams

* Guardrails: tighten runtime-api keyed lookup

* Changelog: note runtime-api guardrails

* Tests: harden runtime-api guardrail parsing

* Tests: align runtime-api guardrails with current seams
2026-03-17 20:30:14 -07:00
Gustavo Madeira Santana
0bfaa36126 Discord: move group policy behind plugin boundary 2026-03-18 03:30:02 +00:00
Peter Steinberger
9350cb19dd refactor: deduplicate plugin setup and channel config helpers 2026-03-18 03:28:05 +00:00
Gustavo Madeira Santana
9e556f75f5 Slack: move group policy behind plugin boundary 2026-03-18 03:26:21 +00:00
Gustavo Madeira Santana
889011c08c Build: remove legacy WhatsApp login shim 2026-03-18 03:23:27 +00:00
Gustavo Madeira Santana
abaa9107c5 Build: remove legacy channel action shim entries 2026-03-18 03:22:04 +00:00
Vincent Koc
2f21eeb3cb Plugins: internalize bluebubbles SDK imports 2026-03-17 20:21:00 -07:00
Gustavo Madeira Santana
1777b99ccc Signal: move message actions behind plugin boundary 2026-03-18 03:19:35 +00:00
Val Alexander
56066dccb0 docs(ui): harden legacy query token guidance (#49053) 2026-03-17 22:18:42 -05:00
Vincent Koc
0a90b07f8d Agents: honor workspace Anthropic provider capabilities 2026-03-17 20:17:39 -07:00
Gustavo Madeira Santana
28b888cbcd Slack: move message actions behind plugin boundary 2026-03-18 03:14:32 +00:00
Peter Steinberger
cd5c2f4cb2 refactor: dedupe channel plugin shared assembly 2026-03-17 20:13:52 -07:00
Vincent Koc
3cc83cb81e Plugins: internalize msteams SDK imports 2026-03-17 20:11:24 -07:00
Vincent Koc
a41840f717 Matrix: split internal runtime barrel from public SDK 2026-03-17 20:11:22 -07:00
Val Alexander
53dcafbec3 Config UI: click-to-reveal redacted env vars and use lightweight re-render (#49399)
* Refactor CSS styles: replace hardcoded colors with CSS variables for accent colors and optimize spacing rules in layout files.

* Update CSS styles: streamline selectors, enhance hover effects, and adjust focus states for chat components and layout elements.

* Enhance focus styles for chat components: update border colors and box-shadow effects for improved accessibility and visual consistency.

* Config UI: click-to-reveal redacted env vars and use lightweight re-render
2026-03-17 22:10:31 -05:00
Gustavo Madeira Santana
206d1be082 Changelog: note plugin message discovery break 2026-03-18 03:05:23 +00:00
Vincent Koc
27d4fdf3bb Plugins: surface compatibility notices 2026-03-17 20:03:40 -07:00
Gustavo Madeira Santana
6b9b32a160 Docs: require unified message discovery 2026-03-18 03:02:17 +00:00
Gustavo Madeira Santana
682f4d1ca3 Plugin SDK: require unified message discovery 2026-03-18 03:02:16 +00:00
Vincent Koc
870f260772 Gateway: cover trusted-proxy scope regression (#49372)
* Gateway: cover trusted-proxy scope regression

* Changelog: note trusted-proxy regression coverage

* Gateway: format trusted-proxy regression test
2026-03-17 19:59:01 -07:00
Val Alexander
25e6cd38b6 UI: mute sidebar and chat input accent colors (#49390)
* Refactor CSS styles: replace hardcoded colors with CSS variables for accent colors and optimize spacing rules in layout files.

* Update CSS styles: streamline selectors, enhance hover effects, and adjust focus states for chat components and layout elements.

* Enhance focus styles for chat components: update border colors and box-shadow effects for improved accessibility and visual consistency.
2026-03-17 21:56:50 -05:00
Peter Steinberger
fa34cb887d fix: resolve rebase export collisions 2026-03-17 19:53:32 -07:00
Peter Steinberger
5b2c5ee2bc refactor: remove remaining extension src imports 2026-03-17 19:53:32 -07:00
Peter Steinberger
055632460d docs: reorder changelog sections by interest 2026-03-17 19:51:57 -07:00
Vincent Koc
889bb8a78a Plugins: internalize matrix and feishu SDK imports 2026-03-17 19:47:25 -07:00
Peter Steinberger
1313767825 refactor: enforce plugin boundary seams 2026-03-17 19:45:36 -07:00
Gustavo Madeira Santana
b942dacf48 Sessions: move session target shaping to plugins 2026-03-18 02:44:49 +00:00
Peter Steinberger
44521d6b20 test: stabilize plugin contract mocks 2026-03-18 02:44:30 +00:00
Vincent Koc
6f060d7e6c Deps: bump fast-xml-parser audit override (#49367)
* Deps: bump fast-xml-parser audit override

* Changelog: note fast-xml-parser audit fix [skip ci]
2026-03-17 19:43:15 -07:00
Peter Steinberger
841b1a59d7 docs: unify unreleased changelog sections 2026-03-17 19:41:17 -07:00
Peter Steinberger
01ae160108 chore: checkpoint ci triage 2026-03-18 02:41:06 +00:00
Gustavo Madeira Santana
d8b95d2315 Polls: scope Telegram poll extras to plugin schema 2026-03-18 02:34:33 +00:00
Gustavo Madeira Santana
fa73f5aeb5 Polls: defer shared parsing until plugin fallback 2026-03-18 02:34:25 +00:00
Gustavo Madeira Santana
9e8b9aba1f WhatsApp: isolate lazy action runtime boundary 2026-03-18 02:20:57 +00:00
Gustavo Madeira Santana
bb803a42ac Mattermost: normalize plugin imports 2026-03-18 02:18:06 +00:00
Gustavo Madeira Santana
09de192b77 Tlon: import channel account snapshot type 2026-03-18 02:18:02 +00:00
Gustavo Madeira Santana
8e98019b6a Nostr: remove plugin API import cycle 2026-03-18 02:17:56 +00:00
Gustavo Madeira Santana
fb0d04c834 Tests: migrate channel action discovery to describeMessageTool 2026-03-18 02:17:47 +00:00
Gustavo Madeira Santana
1c6676cd57 Plugins: remove first-party legacy message discovery shims 2026-03-18 02:17:40 +00:00
Gustavo Madeira Santana
ed7269518f Tlon: fix plugin-sdk import boundaries 2026-03-18 02:12:53 +00:00
Gustavo Madeira Santana
b5c38b1095 Docs: point message runtime docs and tests at plugin-owned code 2026-03-18 02:08:08 +00:00
Gustavo Madeira Santana
8165db758b WhatsApp: move action runtime into extension 2026-03-18 02:08:08 +00:00
Gustavo Madeira Santana
b3ae50c71c Slack: move action runtime into extension 2026-03-18 02:08:08 +00:00
Gustavo Madeira Santana
c3386d34d2 Telegram: move action runtime into extension 2026-03-18 02:08:07 +00:00
Gustavo Madeira Santana
9df3e9b617 Discord: move action runtime into extension 2026-03-18 02:08:07 +00:00
Gustavo Madeira Santana
4c36436fb4 Plugin SDK: add legacy message discovery helper 2026-03-18 02:08:07 +00:00
Vincent Koc
d3fc6c0cc7 Plugins: internalize mattermost and tlon SDK imports 2026-03-17 19:05:51 -07:00
686 changed files with 28996 additions and 19370 deletions

4
.github/labeler.yml vendored
View File

@@ -314,3 +314,7 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/xiaomi/**"
"extensions: fal":
- changed-files:
- any-glob-to-any-file:
- "extensions/fal/**"

View File

@@ -304,6 +304,194 @@ jobs:
- name: Enforce safe external URL opening policy
run: pnpm lint:ui:no-raw-window-open
plugin-extension-boundary:
name: "plugin-extension-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run plugin extension boundary guard with grace period
shell: bash
run: |
set -euo pipefail
tmp_output="$(mktemp)"
if pnpm run lint:plugins:no-extension-imports >"$tmp_output" 2>&1; then
cat "$tmp_output"
rm -f "$tmp_output"
exit 0
fi
status=$?
cat "$tmp_output"
rm -f "$tmp_output"
now_epoch="$(date -u +%s)"
enforce_epoch="$(date -u -d "$PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER" +%s)"
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:plugins:no-extension-imports', remove src/plugins/** -> extensions/** imports where possible, and if the remaining inventory is intentional for now update test/fixtures/plugin-extension-import-boundary-inventory.json in the same PR."
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
echo "::warning::Plugin extension import boundary violations are temporarily allowed until ${PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
exit 0
fi
echo "::error::Plugin extension import boundary grace period ended at ${PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
exit "$status"
web-search-provider-boundary:
name: "web-search-provider-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run web search provider boundary guard with grace period
shell: bash
run: |
set -euo pipefail
tmp_output="$(mktemp)"
if pnpm run lint:web-search-provider-boundaries >"$tmp_output" 2>&1; then
cat "$tmp_output"
rm -f "$tmp_output"
exit 0
fi
status=$?
cat "$tmp_output"
rm -f "$tmp_output"
now_epoch="$(date -u +%s)"
enforce_epoch="$(date -u -d "$WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER" +%s)"
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:web-search-provider-boundaries', move provider-specific web-search logic out of core, and if the remaining inventory is intentional for now update test/fixtures/web-search-provider-boundary-inventory.json in the same PR."
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
echo "::warning::Web search provider boundary violations are temporarily allowed until ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
exit 0
fi
echo "::error::Web search provider boundary grace period ended at ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
exit "$status"
extension-src-outside-plugin-sdk-boundary:
name: "extension-src-outside-plugin-sdk-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension src boundary guard with grace period
shell: bash
run: |
set -euo pipefail
tmp_output="$(mktemp)"
if pnpm run lint:extensions:no-src-outside-plugin-sdk >"$tmp_output" 2>&1; then
cat "$tmp_output"
rm -f "$tmp_output"
exit 0
fi
status=$?
cat "$tmp_output"
rm -f "$tmp_output"
now_epoch="$(date -u +%s)"
enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER" +%s)"
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-src-outside-plugin-sdk', move extension imports off core src paths and onto src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-src-outside-plugin-sdk-inventory.json in the same PR."
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
echo "::warning::Extension src boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
exit 0
fi
echo "::error::Extension src boundary grace period ended at ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
exit "$status"
extension-plugin-sdk-internal-boundary:
name: "extension-plugin-sdk-internal-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension plugin-sdk-internal guard with grace period
shell: bash
run: |
set -euo pipefail
tmp_output="$(mktemp)"
if pnpm run lint:extensions:no-plugin-sdk-internal >"$tmp_output" 2>&1; then
cat "$tmp_output"
rm -f "$tmp_output"
exit 0
fi
status=$?
cat "$tmp_output"
rm -f "$tmp_output"
now_epoch="$(date -u +%s)"
enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER" +%s)"
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-plugin-sdk-internal', remove extension imports of src/plugin-sdk-internal/** in favor of src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-plugin-sdk-internal-inventory.json in the same PR."
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
echo "::warning::Extension plugin-sdk-internal boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
exit 0
fi
echo "::error::Extension plugin-sdk-internal boundary grace period ended at ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. ${fix_instructions}"
exit "$status"
build-smoke:
name: "build-smoke"
needs: [docs-scope, changed-scope]

View File

@@ -114,6 +114,7 @@
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.

View File

@@ -39,15 +39,13 @@ Docs: https://docs.openclaw.ai
- Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor.
- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo.
- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant.
### Breaking
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
- 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.
- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
### Fixes
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
@@ -128,9 +126,6 @@ Docs: https://docs.openclaw.ai
- Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr.
- Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman.
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
### Fixes
- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus.
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
- macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67.
@@ -140,6 +135,18 @@ Docs: https://docs.openclaw.ai
- Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing.
- Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus.
- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob.
- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc.
- Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc.
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.
### Breaking
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
- 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.
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
## 2026.3.13
@@ -155,10 +162,6 @@ Docs: https://docs.openclaw.ai
- Cron/sessions: add `sessionTarget: "current"` and `session:<id>` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF.
- Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent.
### Breaking
- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei.
### Fixes
- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
@@ -218,8 +221,13 @@ Docs: https://docs.openclaw.ai
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
### Breaking
- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei.
## 2026.3.12
### Changes
@@ -320,10 +328,6 @@ Docs: https://docs.openclaw.ai
## 2026.3.11
### Security
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
### Changes
- OpenRouter/models: add temporary Hunter Alpha and Healer Alpha entries to the built-in catalog so OpenRouter users can try the new free stealth models during their roughly one-week availability window. (#43642) Thanks @ping-Toven.
@@ -345,10 +349,6 @@ Docs: https://docs.openclaw.ai
- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
- iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman.
### Breaking
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
### Fixes
- Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies.
@@ -461,6 +461,14 @@ Docs: https://docs.openclaw.ai
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
- Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025)
### Security
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
### Breaking
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
## 2026.3.8
### Changes
@@ -573,10 +581,6 @@ Docs: https://docs.openclaw.ai
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
### Breaking
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
### Fixes
- Models/MiniMax: stop advertising removed `MiniMax-M2.5-Lightning` in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as `MiniMax-M2.5-highspeed`.
@@ -902,6 +906,10 @@ Docs: https://docs.openclaw.ai
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
- Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk.
### Breaking
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
## 2026.3.2
### Changes
@@ -930,13 +938,6 @@ Docs: https://docs.openclaw.ai
- Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc.
- Zalo Personal plugin (`@openclaw/zalouser`): keep canonical DM routing while preserving legacy DM session continuity on upgrade, and preserve provider-native `g-`/`u-` target ids in outbound send and directory flows so #33992 lands without breaking existing sessions or stored targets. (#33992) Thanks @darkamenosa.
### Breaking
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
### Fixes
- Feishu/Outbound render mode: respect Feishu account `renderMode` in outbound sends so card mode (and auto-detected markdown tables/code blocks) uses markdown card delivery instead of always sending plain text. (#31562) Thanks @arkyu2077.
@@ -1123,6 +1124,13 @@ Docs: https://docs.openclaw.ai
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
- Control UI/markdown recursion fallback: catch markdown parser failures and safely render escaped plain-text fallback instead of crashing the Control UI on pathological markdown history payloads. (#36445, fixes #36213) Thanks @BinHPdev.
### Breaking
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
## 2026.3.1
### Changes
@@ -1150,11 +1158,6 @@ Docs: https://docs.openclaw.ai
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
### Breaking
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
### Fixes
- Feishu/Streaming card text fidelity: merge throttled/fragmented partial updates without dropping content and avoid newline injection when stitching chunk-style deltas so card-stream output matches final reply text. (#29616) Thanks @HaoHuaqing.
@@ -1249,6 +1252,11 @@ Docs: https://docs.openclaw.ai
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
### Breaking
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
## 2026.2.27
### Changes
@@ -1524,10 +1532,6 @@ Docs: https://docs.openclaw.ai
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.
### Breaking
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
### Fixes
- Slack/Identity: thread agent outbound identity (`chat:write.customize` overrides) through the channel reply delivery path so per-agent username, icon URL, and icon emoji are applied to all Slack replies including media messages. (#27134) Thanks @hou-rong.
@@ -1591,6 +1595,10 @@ Docs: https://docs.openclaw.ai
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
- Feishu/WebSocket proxy: pass a proxy agent to Feishu WS clients from standard proxy environment variables and include plugin-local runtime dependency wiring so websocket mode works in proxy-constrained installs. (#26397) Thanks @colin719.
### Breaking
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
## 2026.2.24
### Changes
@@ -1601,11 +1609,6 @@ Docs: https://docs.openclaw.ai
- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned.
### Breaking
- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:<id>`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
### Fixes
- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
@@ -1685,6 +1688,11 @@ Docs: https://docs.openclaw.ai
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
### Breaking
- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:<id>`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
## 2026.2.23
### Changes
@@ -1699,10 +1707,6 @@ Docs: https://docs.openclaw.ai
- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed.
- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera.
### Breaking
- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically.
### Fixes
- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305.
@@ -1748,6 +1752,10 @@ Docs: https://docs.openclaw.ai
- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc.
- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc.
### Breaking
- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically.
## 2026.2.22
### Changes
@@ -1772,14 +1780,6 @@ Docs: https://docs.openclaw.ai
- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead.
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
### Breaking
- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers.
- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`.
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
### Fixes
- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc.
@@ -2006,6 +2006,14 @@ Docs: https://docs.openclaw.ai
- Gateway/Daemon: verify gateway health after daemon restart.
- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
### Breaking
- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers.
- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`.
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
## 2026.2.21
### Changes
@@ -2654,10 +2662,6 @@ Docs: https://docs.openclaw.ai
- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy.
### Breaking
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
### Fixes
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
@@ -2760,6 +2764,10 @@ Docs: https://docs.openclaw.ai
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras.
### Breaking
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
## 2026.2.12
### Changes
@@ -2771,10 +2779,6 @@ Docs: https://docs.openclaw.ai
- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat.
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
### Breaking
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
### Fixes
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
@@ -2857,6 +2861,10 @@ Docs: https://docs.openclaw.ai
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
- Update/Daemon: fix post-update restart compatibility by generating `dist/cli/daemon-cli.js` with alias-aware exports from hashed daemon bundles, preventing `registerDaemonCli` import failures during `openclaw update`.
### Breaking
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
## 2026.2.9
### Added
@@ -2946,6 +2954,12 @@ Docs: https://docs.openclaw.ai
## 2026.2.6
### Added
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
### Changes
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
@@ -2961,12 +2975,6 @@ Docs: https://docs.openclaw.ai
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
### Added
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
### Fixes
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
@@ -3265,10 +3273,6 @@ Docs: https://docs.openclaw.ai
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
- Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar.
### Breaking
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
- Skills: update session-logs paths to use ~/.openclaw. (#4502) Thanks @bonald.
@@ -3321,6 +3325,10 @@ Docs: https://docs.openclaw.ai
- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
### Breaking
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
## 2026.1.24-3
### Fixes
@@ -3552,11 +3560,6 @@ Docs: https://docs.openclaw.ai
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
### Fixes
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
@@ -3579,6 +3582,11 @@ Docs: https://docs.openclaw.ai
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
- Embedded runner: forward sender identity into attempt execution so Feishu doc auto-grant receives requester context again. (#32915) Thanks @cszhouwei.
### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
## 2026.1.20
### Changes
@@ -3660,10 +3668,6 @@ Docs: https://docs.openclaw.ai
- macOS: stop syncing Peekaboo in postinstall.
- Swabble: use the tagged Commander Swift package release.
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any.
### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_moltbot-gw._tcp` and update discovery clients/docs.
@@ -3762,6 +3766,10 @@ Docs: https://docs.openclaw.ai
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any.
## 2026.1.16-2
### Changes
@@ -3780,15 +3788,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.openclaw.ai/concepts/session
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.openclaw.ai/tools/web
### Breaking
- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks
- **BREAKING:** `openclaw plugins install <path>` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading).
### Changes
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
@@ -3880,6 +3879,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
- Skills: fix skills watcher ignored list typing (tsc).
### Breaking
- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks
- **BREAKING:** `openclaw plugins install <path>` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading).
## 2026.1.15
### Highlights
@@ -3889,11 +3897,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
### Changes
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
@@ -3966,6 +3969,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
## 2026.1.14-1
### Highlights
@@ -4102,10 +4110,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
### Installer
- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
@@ -4131,6 +4135,10 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
### Installer
- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
### Maintenance
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
@@ -4182,15 +4190,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests.
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.
### Installer
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
- Postinstall: skip pnpm patch fallback when the new patcher is active.
- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped.
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
- Installer UX: add `install.sh --help` with flags/env and git install hint.
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
### Fixes
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
@@ -4229,6 +4228,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
### Installer
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
- Postinstall: skip pnpm patch fallback when the new patcher is active.
- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped.
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
- Installer UX: add `install.sh --help` with flags/env and git install hint.
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
## 2026.1.10
### Highlights
@@ -4337,11 +4345,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
### Breaking
- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
### New Features and Changes
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
@@ -4383,6 +4386,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
- Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck
### Breaking
- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
### Maintenance
- Dependencies: bump pi-\* stack to 0.42.2.
@@ -4402,6 +4410,18 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes.
- CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded.
### Fixes
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
### Breaking
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
@@ -4417,18 +4437,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
### Fixes
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
### Maintenance
- Skills additions (Himalaya email, CodexBar, 1Password).

View File

@@ -23200,6 +23200,56 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry",
"kind": "channel",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry.maxRetries",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmChannelRetry.timeoutMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.accounts.*.dmPolicy",
"kind": "channel",
@@ -23709,6 +23759,56 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmChannelRetry",
"kind": "channel",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "channels.mattermost.dmChannelRetry.initialDelayMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmChannelRetry.maxDelayMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmChannelRetry.maxRetries",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmChannelRetry.timeoutMs",
"kind": "channel",
"type": "integer",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.mattermost.dmPolicy",
"kind": "channel",
@@ -37601,12 +37701,13 @@
"path": "channels.zalouser.accounts.*.groupPolicy",
"kind": "channel",
"type": "string",
"required": false,
"required": true,
"enumValues": [
"open",
"disabled",
"allowlist"
],
"defaultValue": "allowlist",
"deprecated": false,
"sensitive": false,
"tags": [],
@@ -37903,12 +38004,13 @@
"path": "channels.zalouser.groupPolicy",
"kind": "channel",
"type": "string",
"required": false,
"required": true,
"enumValues": [
"open",
"disabled",
"allowlist"
],
"defaultValue": "allowlist",
"deprecated": false,
"sensitive": false,
"tags": [],
@@ -39699,7 +39801,7 @@
"network"
],
"label": "Control UI Allowed Origins",
"help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.",
"help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.",
"hasChildren": true
},
{
@@ -41038,7 +41140,7 @@
"access"
],
"label": "Hooks Allowed Agent IDs",
"help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.",
"help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.",
"hasChildren": true
},
{
@@ -42156,7 +42258,7 @@
"security"
],
"label": "Hooks Auth Token",
"help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.",
"help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.",
"hasChildren": false
},
{
@@ -46269,6 +46371,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.chutes",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/chutes-provider",
"help": "OpenClaw Chutes.ai provider plugin (plugin: chutes)",
"hasChildren": true
},
{
"path": "plugins.entries.chutes.config",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/chutes-provider Config",
"help": "Plugin-defined config payload for chutes.",
"hasChildren": false
},
{
"path": "plugins.entries.chutes.enabled",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Enable @openclaw/chutes-provider",
"hasChildren": false
},
{
"path": "plugins.entries.chutes.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.chutes.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.chutes.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.chutes.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.chutes.subagent.allowedModels.*",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "plugins.entries.chutes.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.cloudflare-ai-gateway",
"kind": "plugin",

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5457}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5476}
{"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}
@@ -2086,6 +2086,11 @@
{"recordType":"path","path":"channels.mattermost.accounts.*.commands.nativeSkills","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -2130,6 +2135,11 @@
{"recordType":"path","path":"channels.mattermost.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Config Writes","help":"Allow Mattermost to write config in response to channel events/commands (default: true).","hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.mattermost.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -3398,7 +3408,7 @@
{"recordType":"path","path":"channels.zalouser.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.accounts.*.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3426,7 +3436,7 @@
{"recordType":"path","path":"channels.zalouser.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.zalouser.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.zalouser.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3563,7 +3573,7 @@
{"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false}
{"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false}
{"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true}
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true}
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.","hasChildren":true}
{"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"gateway.controlUi.allowInsecureAuth","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access","advanced","network","security"],"label":"Insecure Control UI Auth Toggle","help":"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.","hasChildren":false}
{"recordType":"path","path":"gateway.controlUi.basePath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network","storage"],"label":"Control UI Base Path","help":"Optional URL prefix where the Control UI is served (e.g. /openclaw).","hasChildren":false}
@@ -3667,7 +3677,7 @@
{"recordType":"path","path":"gateway.trustedProxies","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Trusted Proxy CIDRs","help":"CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.","hasChildren":true}
{"recordType":"path","path":"gateway.trustedProxies.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"hooks","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks","help":"Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.","hasChildren":true}
{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.","hasChildren":true}
{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.","hasChildren":true}
{"recordType":"path","path":"hooks.allowedAgentIds.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"hooks.allowedSessionKeyPrefixes","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","storage"],"label":"Hooks Allowed Session Key Prefixes","help":"Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.","hasChildren":true}
{"recordType":"path","path":"hooks.allowedSessionKeyPrefixes.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3754,7 +3764,7 @@
{"recordType":"path","path":"hooks.path","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Endpoint Path","help":"HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.","hasChildren":false}
{"recordType":"path","path":"hooks.presets","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks Presets","help":"Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.","hasChildren":true}
{"recordType":"path","path":"hooks.presets.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false}
{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false}
{"recordType":"path","path":"hooks.transformsDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Transforms Directory","help":"Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.","hasChildren":false}
{"recordType":"path","path":"logging","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Logging","help":"Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.","hasChildren":true}
{"recordType":"path","path":"logging.consoleLevel","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"Console Log Level","help":"Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.","hasChildren":false}
@@ -4093,6 +4103,15 @@
{"recordType":"path","path":"plugins.entries.byteplus.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.byteplus.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.byteplus.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.chutes","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider","help":"OpenClaw Chutes.ai provider plugin (plugin: chutes)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.chutes.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider Config","help":"Plugin-defined config payload for chutes.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.chutes.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/chutes-provider","hasChildren":false}
{"recordType":"path","path":"plugins.entries.chutes.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.chutes.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.chutes.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.chutes.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.chutes.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.chutes.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.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false}

View File

@@ -8,7 +8,7 @@ title: "acp"
# acp
Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge that talks to a OpenClaw Gateway.
Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge that talks to an OpenClaw Gateway.
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
@@ -102,7 +102,7 @@ Permission model (client debug mode):
## How to use this
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
it to drive a OpenClaw Gateway session.
it to drive an OpenClaw Gateway session.
1. Ensure the Gateway is running (local or remote).
2. Configure the Gateway target (config or flags).

View File

@@ -276,9 +276,9 @@ Note: plugins can add additional top-level commands (for example `openclaw voice
## Secrets
- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot.
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift.
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply.
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported).
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift (`--allow-exec` to execute exec providers during audit).
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply (`--allow-exec` to execute exec providers during preflight and exec-containing apply flows).
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported; use `--allow-exec` to permit exec providers in dry-run and exec-containing write plans).
## Plugins

View File

@@ -21,6 +21,7 @@ Related:
```bash
openclaw plugins list
openclaw plugins install <path-or-spec>
openclaw plugins inspect <id>
openclaw plugins enable <id>
openclaw plugins disable <id>
@@ -31,8 +32,6 @@ openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
```
`info` is an alias for `inspect`.
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
activate them.
@@ -159,16 +158,18 @@ openclaw plugins inspect <id> --json
```
Deep introspection for a single plugin. Shows identity, load status, source,
plugin shape, registered capabilities, hooks, tools, commands, services,
gateway methods, HTTP routes, policy flags, diagnostics, and install metadata.
registered capabilities, hooks, tools, commands, services, gateway methods,
HTTP routes, policy flags, diagnostics, and install metadata.
Plugin shape is derived from actual registration behavior:
Each plugin is classified by what it actually registers at runtime:
- **plain-capability** — one capability type registered
- **hybrid-capability** — multiple capability types registered
- **plain-capability** — one capability type (e.g. a provider-only plugin)
- **hybrid-capability** — multiple capability types (e.g. text + speech + images)
- **hook-only** — only hooks, no capabilities or surfaces
- **non-capability** — tools/commands/services but no capabilities
See [Plugins](/tools/plugin#plugin-shapes) for more on the capability model.
The `--json` flag outputs a machine-readable report suitable for scripting and
auditing.

View File

@@ -14,9 +14,9 @@ Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot
Command roles:
- `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes).
- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift.
- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift (exec refs are skipped unless `--allow-exec` is set).
- `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required).
- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues.
- `apply`: execute a saved plan (`--dry-run` for validation only; dry-run skips exec checks by default, and write mode rejects exec-containing plans unless `--allow-exec` is set), then scrub targeted plaintext residues.
Recommended operator loop:
@@ -29,6 +29,8 @@ openclaw secrets audit --check
openclaw secrets reload
```
If your plan includes `exec` SecretRefs/providers, pass `--allow-exec` on both dry-run and write apply commands.
Exit code note for CI/gates:
- `audit --check` returns `1` on findings.
@@ -73,6 +75,7 @@ Header residue note:
openclaw secrets audit
openclaw secrets audit --check
openclaw secrets audit --json
openclaw secrets audit --allow-exec
```
Exit behavior:
@@ -83,6 +86,7 @@ Exit behavior:
Report shape highlights:
- `status`: `clean | findings | unresolved`
- `resolution`: `refsChecked`, `skippedExecRefs`, `resolvabilityComplete`
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount`
- finding codes:
- `PLAINTEXT_FOUND`
@@ -115,6 +119,7 @@ Flags:
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
- `--agent <id>`: scope `auth-profiles.json` target discovery and writes to one agent store.
- `--allow-exec`: allow exec SecretRef checks during preflight/apply (may execute provider commands).
Notes:
@@ -124,6 +129,7 @@ Notes:
- `configure` supports creating new `auth-profiles.json` mappings directly in the picker flow.
- Canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface).
- It performs preflight resolution before apply.
- If preflight/apply includes exec refs, keep `--allow-exec` set for both steps.
- Generated plans default to scrub options (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson` all enabled).
- Apply path is one-way for scrubbed plaintext values.
- Without `--apply`, CLI still prompts `Apply this plan now?` after preflight.
@@ -141,10 +147,19 @@ Apply or preflight a plan generated previously:
```bash
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json
```
Exec behavior:
- `--dry-run` validates preflight without writing files.
- exec SecretRef checks are skipped by default in dry-run.
- write mode rejects plans that contain exec SecretRefs/providers unless `--allow-exec` is set.
- Use `--allow-exec` to opt in to exec provider checks/execution in either mode.
Plan contract details (allowed target paths, validation rules, and failure semantics):
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)

View File

@@ -1055,6 +1055,7 @@
"plugins/zalouser",
"plugins/manifest",
"plugins/agent-tools",
"tools/capability-cookbook",
"prose"
]
},

View File

@@ -81,6 +81,12 @@ Invalid plan target path for models.providers.apiKey: models.providers.openai.ba
No writes are committed for an invalid plan.
## Exec provider consent behavior
- `--dry-run` skips exec SecretRef checks by default.
- Plans containing exec SecretRefs/providers are rejected in write mode unless `--allow-exec` is set.
- When validating/applying exec-containing plans, pass `--allow-exec` in both dry-run and write commands.
## Runtime and audit scope notes
- Ref-only `auth-profiles.json` entries (`keyRef`/`tokenRef`) are included in runtime resolution and audit coverage.
@@ -94,6 +100,10 @@ openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
# Then apply for real
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
# For exec-containing plans, opt in explicitly in both modes
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
```
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to a supported shape above.

View File

@@ -414,6 +414,11 @@ Findings include:
- precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs)
- legacy residues (`auth.json`, OAuth reminders)
Exec note:
- By default, audit skips exec SecretRef resolvability checks to avoid command side effects.
- Use `openclaw secrets audit --allow-exec` to execute exec providers during audit.
Header residue note:
- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`).
@@ -429,6 +434,11 @@ Interactive helper that:
- runs preflight resolution
- can apply immediately
Exec note:
- Preflight skips exec SecretRef checks unless `--allow-exec` is set.
- If you apply directly from `configure --apply` and the plan includes exec refs/providers, keep `--allow-exec` set for the apply step too.
Helpful modes:
- `openclaw secrets configure --providers-only`
@@ -447,9 +457,16 @@ Apply a saved plan:
```bash
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
```
Exec note:
- dry-run skips exec checks unless `--allow-exec` is set.
- write mode rejects plans containing exec SecretRefs/providers unless `--allow-exec` is set.
For strict target/path contract details and exact rejection rules, see:
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)

View File

@@ -1,5 +1,5 @@
---
summary: "Move (migrate) a OpenClaw install from one machine to another"
summary: "Move (migrate) an OpenClaw install from one machine to another"
read_when:
- You are moving OpenClaw to a new laptop/server
- You want to preserve sessions, auth, and channel logins (WhatsApp, etc.)
@@ -8,7 +8,7 @@ title: "Migration Guide"
# Migrating OpenClaw to a new machine
This guide migrates a OpenClaw Gateway from one machine to another **without redoing onboarding**.
This guide migrates an OpenClaw Gateway from one machine to another **without redoing onboarding**.
The migration is simple conceptually:

View File

@@ -119,19 +119,24 @@ src/agents/
│ ├── browser-tool.ts
│ ├── canvas-tool.ts
│ ├── cron-tool.ts
│ ├── discord-actions*.ts
│ ├── gateway-tool.ts
│ ├── image-tool.ts
│ ├── message-tool.ts
│ ├── nodes-tool.ts
│ ├── session*.ts
│ ├── slack-actions.ts
│ ├── telegram-actions.ts
│ ├── web-*.ts
│ └── whatsapp-actions.ts
│ └── ...
└── ...
```
Channel-specific message action runtimes now live in the plugin-owned extension
directories instead of under `src/agents/tools`, for example:
- `extensions/discord/src/actions/runtime*.ts`
- `extensions/slack/src/action-runtime.ts`
- `extensions/telegram/src/action-runtime.ts`
- `extensions/whatsapp/src/action-runtime.ts`
## Core Integration Flow
### 1. Running an Embedded Agent

View File

@@ -7,7 +7,7 @@ title: "Remote Control"
# Remote OpenClaw (macOS ⇄ remote host)
This flow lets the macOS app act as a full remote control for a OpenClaw gateway running on another host (desktop/server). Its the apps **Remote over SSH** (remote run) feature. All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from _Settings → General_.
This flow lets the macOS app act as a full remote control for an OpenClaw gateway running on another host (desktop/server). Its the apps **Remote over SSH** (remote run) feature. All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from _Settings → General_.
## Modes

View File

@@ -1,7 +1,7 @@
---
summary: "Plugin manifest + JSON schema requirements (strict config validation)"
read_when:
- You are building a OpenClaw plugin
- You are building an OpenClaw plugin
- You need to ship a plugin config schema or debug plugin validation errors
title: "Plugin Manifest"
---
@@ -32,7 +32,8 @@ Every native OpenClaw plugin **must** ship a `openclaw.plugin.json` file in the
plugin errors and block config validation.
See the full plugin system guide: [Plugins](/tools/plugin).
For the public capability model: [Capability model](/tools/plugin#public-capability-model).
For the native capability model and current external-compatibility guidance:
[Capability model](/tools/plugin#public-capability-model).
## Required fields
@@ -120,6 +121,8 @@ Example:
- If plugin config exists but the plugin is **disabled**, the config is kept and
a **warning** is surfaced in Doctor + logs.
See [Configuration reference](/configuration) for the full `plugins.*` schema.
## Notes
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
@@ -130,7 +133,9 @@ Example:
runtime just to inspect env names.
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
CLI flag registration before provider runtime loads.
CLI flag registration before provider runtime loads. For runtime wizard
metadata that requires provider code, see
[Provider runtime hooks](/tools/plugin#provider-runtime-hooks).
- Exclusive plugin kinds are selected through `plugins.slots.*`.
- `kind: "memory"` is selected by `plugins.slots.memory`.
- `kind: "context-engine"` is selected by `plugins.slots.contextEngine`

View File

@@ -47,6 +47,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [vLLM (local models)](/providers/vllm)
- [xAI](/providers/xai)
- [Xiaomi](/providers/xiaomi)
- [Z.AI](/providers/zai)

View File

@@ -39,6 +39,7 @@ model as `provider/model`.
- [Venice (Venice AI)](/providers/venice)
- [Amazon Bedrock](/providers/bedrock)
- [Qianfan](/providers/qianfan)
- [xAI](/providers/xai)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).

61
docs/providers/xai.md Normal file
View File

@@ -0,0 +1,61 @@
---
summary: "Use xAI Grok models in OpenClaw"
read_when:
- You want to use Grok models in OpenClaw
- You are configuring xAI auth or model ids
title: "xAI"
---
# xAI
OpenClaw ships a bundled `xai` provider plugin for Grok models.
## Setup
1. Create an API key in the xAI console.
2. Set `XAI_API_KEY`, or run:
```bash
openclaw onboard --auth-choice xai-api-key
```
3. Pick a model such as:
```json5
{
agents: { defaults: { model: { primary: "xai/grok-4" } } },
}
```
## Current bundled model catalog
OpenClaw now includes these xAI model families out of the box:
- `grok-4`, `grok-4-0709`
- `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`
- `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning`
- `grok-4.20-experimental-beta-0304-reasoning`
- `grok-4.20-experimental-beta-0304-non-reasoning`
- `grok-code-fast-1`
The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when
they follow the same API shape.
## Web search
The bundled `grok` web-search provider uses `XAI_API_KEY` too:
```bash
openclaw config set tools.web.search.provider grok
```
## Known limits
- Auth is API-key only today. There is no xAI OAuth/device-code flow in OpenClaw yet.
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the normal xAI provider path because it requires a different upstream API surface than the standard OpenClaw xAI transport.
- Native xAI server-side tools such as `x_search` and `code_execution` are not yet first-class model-provider features in the bundled plugin.
## Notes
- OpenClaw applies xAI-specific tool-schema and tool-call compatibility fixes automatically on the shared runner path.
- For the broader provider overview, see [Model providers](/providers/index).

View File

@@ -231,7 +231,7 @@ Triggered by a roof camera: ask OpenClaw to snap a sky photo whenever it looks p
<Card title="Visual Morning Briefing Scene" icon="robot" href="https://x.com/buddyhadry/status/2010005331925954739">
**@buddyhadry** • `automation` `briefing` `images` `telegram`
A scheduled prompt generates a single "scene" image each morning (weather, tasks, date, favorite post/quote) via a OpenClaw persona.
A scheduled prompt generates a single "scene" image each morning (weather, tasks, date, favorite post/quote) via an OpenClaw persona.
</Card>
<Card title="Padel Court Booking" icon="calendar-check" href="https://github.com/joshp123/padel-cli">

View File

@@ -191,7 +191,7 @@ Notes:
## Browserless (hosted remote CDP)
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
CDP endpoints over HTTPS. You can point a OpenClaw browser profile at a
CDP endpoints over HTTPS. You can point an OpenClaw browser profile at a
Browserless region endpoint and authenticate with your API key.
Example:

View File

@@ -66,7 +66,7 @@ pnpm add -g clawhub
## How it fits into OpenClaw
By default, the CLI installs skills into `./skills` under your current working directory. If a OpenClaw workspace is configured, `clawhub` falls back to that workspace unless you override `--workdir` (or `CLAWHUB_WORKDIR`). OpenClaw loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.openclaw/skills` or bundled skills, workspace skills take precedence.
By default, the CLI installs skills into `./skills` under your current working directory. If an OpenClaw workspace is configured, `clawhub` falls back to that workspace unless you override `--workdir` (or `CLAWHUB_WORKDIR`). OpenClaw loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.openclaw/skills` or bundled skills, workspace skills take precedence.
For more detail on how skills are loaded, shared, and gated, see
[Skills](/tools/skills).

View File

@@ -37,12 +37,8 @@ openclaw plugins list
openclaw plugins install @openclaw/voice-call
```
Npm specs are **registry-only** (package name + optional **exact version** or
**dist-tag**). Git/URL/file specs and semver ranges are rejected.
Bare specs and `@latest` stay on the stable track. If npm resolves either of
those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a
prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
Npm specs are registry-only. See [install rules](/cli/plugins#install) for
details on pinning, prerelease gating, and supported spec formats.
3. Restart the Gateway, then configure under `plugins.entries.<id>.config`.
@@ -107,8 +103,8 @@ conversation, and it runs after core approval handling finishes.
## Public capability model
Capabilities are the public plugin model. Every native OpenClaw plugin
registers against one or more capability types:
Capabilities are the public **native plugin** model inside OpenClaw. Every
native OpenClaw plugin registers against one or more capability types:
| Capability | Registration method | Example plugins |
| ------------------- | --------------------------------------------- | ------------------------- |
@@ -120,7 +116,31 @@ registers against one or more capability types:
| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` |
A plugin that registers zero capabilities but provides hooks, tools, or
services is a **legacy hook-only** plugin. That shape is still fully supported.
services is a **legacy hook-only** plugin. That pattern is still fully supported.
### External compatibility stance
The capability model is landed in core and used by bundled/native plugins
today, but external plugin compatibility still needs a tighter bar than "it is
exported, therefore it is frozen."
Current guidance:
- **existing external plugins:** keep hook-based integrations working; treat
this as the compatibility baseline
- **new bundled/native plugins:** prefer explicit capability registration over
vendor-specific reach-ins or new hook-only designs
- **external plugins adopting capability registration:** allowed, but treat the
capability-specific helper surfaces as evolving unless docs explicitly mark a
contract as stable
Practical rule:
- capability registration APIs are the intended direction
- legacy hooks remain the safest no-breakage path for external plugins during
the transition
- exported helper subpaths are not all equal; prefer the narrow documented
contract, not incidental helper exports
### Plugin shapes
@@ -140,13 +160,6 @@ registration behavior (not just static metadata):
Use `openclaw plugins inspect <id>` to see a plugin's shape and capability
breakdown. See [CLI reference](/cli/plugins#inspect) for details.
### Capability labels
Plugin capabilities use two stability labels:
- `public` — stable, documented, and safe to depend on
- `experimental` — may change between releases
### Legacy hooks
The `before_agent_start` hook remains supported as a compatibility path for
@@ -202,7 +215,7 @@ The current boundary is:
channel-specific schema fragments
- channel plugins execute the final action through their action adapter
For channel plugins, the preferred SDK surface is
For channel plugins, the SDK surface is
`ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery
call lets a plugin return its visible actions, capabilities, and schema
contributions together so those pieces do not drift apart.
@@ -228,6 +241,26 @@ responsible for forwarding the current chat/session identity into the plugin
discovery boundary so the shared `message` tool exposes the right channel-owned
surface for the current turn.
For channel-owned execution helpers, bundled plugins should keep the execution
runtime inside their own extension modules. Core no longer owns the Discord,
Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`.
We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled
plugins should import their own local runtime code directly from their
extension-owned modules.
For polls specifically, there are two execution paths:
- `outbound.sendPoll` is the shared baseline for channels that fit the common
poll model
- `actions.handleAction("poll")` is the preferred path for channel-specific
poll semantics or extra poll parameters
Core now defers shared poll parsing until after plugin poll dispatch declines
the action, so plugin-owned poll handlers can accept channel-specific poll
fields without being blocked by the generic poll parser first.
See [Load pipeline](#load-pipeline) for the full startup sequence.
## Capability ownership model
OpenClaw treats a native plugin as the ownership boundary for a **company** or a
@@ -407,7 +440,7 @@ native OpenClaw plugin sources. OpenClaw resolves the marketplace entry first,
then runs the normal install path for the resolved source.
They are shown in the plugin list as `format=bundle`, with a subtype of
`codex` or `claude` in verbose/info output.
`codex`, `claude`, or `cursor` in verbose/inspect output.
See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping
behavior, and current support matrix.
@@ -537,7 +570,8 @@ Native OpenClaw plugins can register capabilities and surfaces:
- **Skills** (by listing `skills` directories in the plugin manifest)
- **Auto-reply commands** (execute without invoking the AI agent)
Native OpenClaw plugins run **inprocess** with the Gateway, so treat them as trusted code.
Native OpenClaw plugins run in-process with the Gateway (see
[Execution model](#execution-model) for trust implications).
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
Think of these registrations as **capability claims**. A plugin is not supposed
@@ -662,112 +696,35 @@ one-flag auth wiring without loading provider runtime. Keep provider runtime
`envVars` for operator-facing hints such as onboarding labels or OAuth
client-id/client-secret setup vars.
### Hook order
### Hook order and usage
For model/provider plugins, OpenClaw uses hooks in this rough order:
For model/provider plugins, OpenClaw calls hooks in this rough order.
The "When to use" column is the quick decision guide.
1. `catalog`
Publish provider config into `models.providers` during `models.json`
generation.
2. built-in/discovered model lookup
OpenClaw tries the normal registry/catalog path first.
3. `resolveDynamicModel`
Sync fallback for provider-owned model ids that are not in the local
registry yet.
4. `prepareDynamicModel`
Async warm-up only on async model resolution paths, then
`resolveDynamicModel` runs again.
5. `normalizeResolvedModel`
Final rewrite before the embedded runner uses the resolved model.
6. `capabilities`
Provider-owned transcript/tooling metadata used by shared core logic.
7. `prepareExtraParams`
Provider-owned request-param normalization before generic stream option wrappers.
8. `wrapStreamFn`
Provider-owned stream wrapper after generic wrappers are applied.
9. `formatApiKey`
Provider-owned auth-profile formatter used when a stored auth profile needs
to become the runtime `apiKey` string.
10. `refreshOAuth`
Provider-owned OAuth refresh override for custom refresh endpoints or
refresh-failure policy.
11. `buildAuthDoctorHint`
Provider-owned repair hint appended when OAuth refresh fails.
12. `isCacheTtlEligible`
Provider-owned prompt-cache policy for proxy/backhaul providers.
13. `buildMissingAuthMessage`
Provider-owned replacement for the generic missing-auth recovery message.
14. `suppressBuiltInModel`
Provider-owned stale upstream model suppression plus optional user-facing
error hint.
15. `augmentModelCatalog`
Provider-owned synthetic/final catalog rows appended after discovery.
16. `isBinaryThinking`
Provider-owned on/off reasoning toggle for binary-thinking providers.
17. `supportsXHighThinking`
Provider-owned `xhigh` reasoning support for selected models.
18. `resolveDefaultThinkingLevel`
Provider-owned default `/think` level for a specific model family.
19. `isModernModelRef`
Provider-owned modern-model matcher used by live profile filters and smoke
selection.
20. `prepareRuntimeAuth`
Exchanges a configured credential into the actual runtime token/key just
before inference.
21. `resolveUsageAuth`
Resolves usage/billing credentials for `/usage` and related status
surfaces.
22. `fetchUsageSnapshot`
Fetches and normalizes provider-specific usage/quota snapshots after auth
is resolved.
### Which hook to use
- `catalog`: publish provider config and model catalogs into `models.providers`
- `resolveDynamicModel`: handle pass-through or forward-compat model ids that are not in the local registry yet
- `prepareDynamicModel`: async warm-up before retrying dynamic resolution (for example refresh provider metadata cache)
- `normalizeResolvedModel`: rewrite a resolved model's transport/base URL/compat before inference
- `capabilities`: publish provider-family and transcript/tooling quirks without hardcoding provider ids in core
- `prepareExtraParams`: set provider defaults or normalize provider-specific per-model params before generic stream wrapping
- `wrapStreamFn`: add provider-specific headers/payload/model compat patches while still using the normal `pi-ai` execution path
- `formatApiKey`: turn a stored auth profile into the runtime `apiKey` string without hardcoding provider token blobs in core
- `refreshOAuth`: own OAuth refresh for providers that do not fit the shared `pi-ai` refreshers
- `buildAuthDoctorHint`: append provider-owned auth repair guidance when refresh fails
- `isCacheTtlEligible`: decide whether provider/model pairs should use cache TTL metadata
- `buildMissingAuthMessage`: replace the generic auth-store error with a provider-specific recovery hint
- `suppressBuiltInModel`: hide stale upstream rows and optionally return a provider-owned error for direct resolution failures
- `augmentModelCatalog`: append synthetic/final catalog rows after discovery and config merging
- `isBinaryThinking`: expose binary on/off reasoning UX without hardcoding provider ids in `/think`
- `supportsXHighThinking`: opt specific models into the `xhigh` reasoning level
- `resolveDefaultThinkingLevel`: keep provider/model default reasoning policy out of core
- `isModernModelRef`: keep live/smoke model family inclusion rules with the provider
- `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests
- `resolveUsageAuth`: resolve provider-owned credentials for usage/billing endpoints without hardcoding token parsing in core
- `fetchUsageSnapshot`: own provider-specific usage endpoint fetch/parsing while core keeps summary fan-out and formatting
Rule of thumb:
- provider owns a catalog or base URL defaults: use `catalog`
- provider accepts arbitrary upstream model ids: use `resolveDynamicModel`
- provider needs network metadata before resolving unknown ids: add `prepareDynamicModel`
- provider needs transport rewrites but still uses a core transport: use `normalizeResolvedModel`
- provider needs transcript/provider-family quirks: use `capabilities`
- provider needs default request params or per-provider param cleanup: use `prepareExtraParams`
- provider needs request headers/body/model compat wrappers without a custom transport: use `wrapStreamFn`
- provider stores extra metadata in auth profiles and needs a custom runtime token shape: use `formatApiKey`
- provider needs a custom OAuth refresh endpoint or refresh failure policy: use `refreshOAuth`
- provider needs provider-owned auth repair guidance after refresh failure: use `buildAuthDoctorHint`
- provider needs proxy-specific cache TTL gating: use `isCacheTtlEligible`
- provider needs a provider-specific missing-auth recovery hint: use `buildMissingAuthMessage`
- provider needs to hide stale upstream rows or replace them with a vendor hint: use `suppressBuiltInModel`
- provider needs synthetic forward-compat rows in `models list` and pickers: use `augmentModelCatalog`
- provider exposes only binary thinking on/off: use `isBinaryThinking`
- provider wants `xhigh` on only a subset of models: use `supportsXHighThinking`
- provider owns default `/think` policy for a model family: use `resolveDefaultThinkingLevel`
- provider owns live/smoke preferred-model matching: use `isModernModelRef`
- provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth`
- provider needs custom usage/quota token parsing or a different usage credential: use `resolveUsageAuth`
- provider needs a provider-specific usage endpoint or payload parser: use `fetchUsageSnapshot`
| # | Hook | What it does | When to use |
| --- | ----------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| 1 | `catalog` | Publish provider config into `models.providers` during `models.json` generation | Provider owns a catalog or base URL defaults |
| — | _(built-in model lookup)_ | OpenClaw tries the normal registry/catalog path first | _(not a plugin hook)_ |
| 2 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids |
| 3 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids |
| 4 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport |
| 5 | `capabilities` | Provider-owned transcript/tooling metadata used by shared core logic | Provider needs transcript/provider-family quirks |
| 6 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup |
| 7 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport |
| 8 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape |
| 9 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers |
| 10 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure |
| 11 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
| 12 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
| 13 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint |
| 14 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers |
| 15 | `isBinaryThinking` | On/off reasoning toggle for binary-thinking providers | Provider exposes only binary thinking on/off |
| 16 | `supportsXHighThinking` | `xhigh` reasoning support for selected models | Provider wants `xhigh` on only a subset of models |
| 17 | `resolveDefaultThinkingLevel` | Default `/think` level for a specific model family | Provider owns default `/think` policy for a model family |
| 18 | `isModernModelRef` | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching |
| 19 | `prepareRuntimeAuth` | Exchange a configured credential into the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential |
| 20 | `resolveUsageAuth` | Resolve usage/billing credentials for `/usage` and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential |
| 21 | `fetchUsageSnapshot` | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser |
If the provider needs a fully custom wire protocol or custom request executor,
that is a different class of extension. These hooks are for provider behavior
@@ -1126,14 +1083,24 @@ Use SDK subpaths instead of the monolithic `openclaw/plugin-sdk` import when
authoring plugins:
- `openclaw/plugin-sdk/core` for the smallest generic plugin-facing contract.
It also carries small assembly helpers such as
`definePluginEntry`, `defineChannelPluginEntry`, `defineSetupPluginEntry`,
and `createChannelPluginBase` for bundled or third-party plugin entry wiring.
- Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`,
`openclaw/plugin-sdk/channel-config-schema`,
`openclaw/plugin-sdk/channel-policy`,
`openclaw/plugin-sdk/channel-runtime`,
`openclaw/plugin-sdk/config-runtime`,
`openclaw/plugin-sdk/agent-runtime`,
`openclaw/plugin-sdk/lazy-runtime`,
`openclaw/plugin-sdk/reply-history`,
`openclaw/plugin-sdk/routing`,
`openclaw/plugin-sdk/runtime-store`, and
`openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers.
- Narrow channel-core subpaths such as `openclaw/plugin-sdk/discord-core`,
`openclaw/plugin-sdk/telegram-core`, `openclaw/plugin-sdk/whatsapp-core`,
and `openclaw/plugin-sdk/line-core` for channel-specific primitives that
should stay smaller than the full channel helper barrels.
- `openclaw/plugin-sdk/compat` remains as a legacy migration surface for older
external plugins. Bundled plugins should not use it, and non-test imports emit
a one-time deprecation warning outside test environments.
@@ -1173,6 +1140,54 @@ authoring plugins:
`openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/voice-call`,
`openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`.
## Channel target resolution
Channel plugins should own channel-specific target semantics. Keep the shared
outbound host generic and use the messaging adapter surface for provider rules:
- `messaging.inferTargetChatType({ to })` decides whether a normalized target
should be treated as `direct`, `group`, or `channel` before directory lookup.
- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
input should skip straight to id-like resolution instead of directory search.
- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
core needs a final provider-owned resolution after normalization or after a
directory miss.
- `messaging.resolveOutboundSessionRoute(...)` owns provider-specific session
route construction once a target is resolved.
Recommended split:
- Use `inferTargetChatType` for category decisions that should happen before
searching peers/groups.
- Use `looksLikeId` for “treat this as an explicit/native target id” checks.
- Use `resolveTarget` for provider-specific normalization fallback, not for
broad directory search.
- Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room
ids inside `target` values or provider-specific params, not in generic SDK
fields.
## Config-backed directories
Plugins that derive directory entries from config should keep that logic in the
plugin and reuse the shared helpers from
`openclaw/plugin-sdk/directory-runtime`.
Use this when a channel needs config-backed peers/groups such as:
- allowlist-driven DM peers
- configured channel/group maps
- account-scoped static directory fallbacks
The shared helpers in `directory-runtime` only handle generic operations:
- query filtering
- limit application
- deduping/normalization helpers
- building `ChannelDirectoryEntry[]`
Channel-specific account inspection and id normalization should stay in the
plugin implementation.
## Provider catalogs
Provider plugins can define model catalogs for inference with
@@ -1209,6 +1224,10 @@ Compatibility note:
- New and migrated bundled plugins should use channel or extension-specific
subpaths; use `core` plus explicit domain subpaths for generic surfaces, and
treat `compat` as migration-only.
- Capability-specific subpaths such as `image-generation`,
`media-understanding`, and `speech` exist because bundled/native plugins use
them today. Their presence does not by itself mean every exported helper is a
long-term frozen external contract.
## Read-only channel inspection
@@ -1332,6 +1351,7 @@ Compatible bundles may instead provide one of:
- `.codex-plugin/plugin.json`
- `.claude-plugin/plugin.json`
- `.cursor-plugin/plugin.json`
Bundle directories are discovered from the same roots as native plugins.
@@ -1536,7 +1556,8 @@ Fields:
- `slots`: exclusive slot selectors such as `memory` and `contextEngine`
- `entries.<id>`: perplugin toggles + config
Config changes **require a gateway restart**.
Config changes **require a gateway restart**. See
[Configuration reference](/configuration) for the full config schema.
Validation rules (strict):
@@ -1582,6 +1603,7 @@ Supported exclusive slots:
If multiple plugins declare `kind: "memory"` or `kind: "context-engine"`, only
the selected plugin loads for that slot. Others are disabled with diagnostics.
Declare `kind` in your [plugin manifest](/plugins/manifest).
### Context engine plugins
@@ -1636,7 +1658,7 @@ openclaw plugins install ./extensions/voice-call # relative path ok
openclaw plugins install ./plugin.tgz # install from a local tarball
openclaw plugins install ./plugin.zip # install from a local zip
openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev
openclaw plugins install @openclaw/voice-call # install from npm
openclaw plugins install @openclaw/voice-call # install from npm
openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version
openclaw plugins update <id>
openclaw plugins update --all
@@ -1645,14 +1667,11 @@ openclaw plugins disable <id>
openclaw plugins doctor
```
`openclaw plugins list` shows the top-level format as `openclaw` or `bundle`.
Verbose list/info output also shows bundle subtype (`codex` or `claude`) plus
detected bundle capabilities.
See [`openclaw plugins` CLI reference](/cli/plugins) for full details on each
command (install rules, inspect output, marketplace installs, uninstall).
`plugins update` only works for npm installs tracked under `plugins.installs`.
If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts).
Plugins may also register their own toplevel commands (example: `openclaw voicecall`).
Plugins may also register their own top-level commands (example:
`openclaw voicecall`).
## Plugin API (overview)
@@ -1710,7 +1729,8 @@ Recommended sequence:
Add tests so ownership and registration shape stay explicit over time.
This is how OpenClaw stays opinionated without becoming hardcoded to one
provider's worldview.
provider's worldview. See the [Capability Cookbook](/tools/capability-cookbook)
for a concrete file checklist and worked example.
### Capability checklist
@@ -2459,7 +2479,7 @@ See [Voice Call](/plugins/voice-call) and `extensions/voice-call/README.md` for
## Safety notes
Plugins run in-process with the Gateway. Treat them as trusted code:
Plugins run in-process with the Gateway (see [Execution model](#execution-model)):
- Only install plugins you trust.
- Prefer `plugins.allow` allowlists.

View File

@@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-toke
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is preferably imported from the URL fragment, stored in sessionStorage for the current browser tab session and selected gateway URL, and stripped from the URL; legacy `?token=` query params are also imported once for compatibility and then removed.
- `token` should be passed via the URL fragment (`#token=...`) whenever possible. Fragments are not sent to the server, which avoids request-log and Referer leakage. Legacy `?token=` query params are still imported once for compatibility, but only as a fallback, and are stripped immediately after bootstrap.
- `password` is kept in memory only.
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.

View File

@@ -19,4 +19,27 @@ describe("amazon-bedrock provider plugin", () => {
} as never),
).toBeUndefined();
});
it("disables prompt caching for non-Anthropic Bedrock models", () => {
const provider = registerSingleProviderPlugin(amazonBedrockPlugin);
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: "amazon.nova-micro-v1:0",
streamFn: (_model, _context, options) => options,
} as never);
expect(
wrapped?.(
{
api: "openai-completions",
provider: "amazon-bedrock",
id: "amazon.nova-micro-v1:0",
} as never,
{ messages: [] } as never,
{},
),
).toMatchObject({
cacheRetention: "none",
});
});
});

View File

@@ -1,4 +1,8 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import {
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,
} from "openclaw/plugin-sdk/provider-stream";
const PROVIDER_ID = "amazon-bedrock";
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
@@ -13,6 +17,8 @@ export default definePluginEntry({
label: "Amazon Bedrock",
docsPath: "/providers/models",
auth: [],
wrapStreamFn: ({ modelId, streamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn),
resolveDefaultThinkingLevel: ({ modelId }) =>
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,
});

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { normalizeResolvedSecretInputString } from "./secret-input.js";
export type BlueBubblesAccountResolveOpts = {

View File

@@ -1,5 +1,5 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { createAccountListHelpers, type OpenClawConfig } from "./runtime-api.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";

View File

@@ -46,7 +46,7 @@ vi.mock("./probe.js", () => ({
}));
describe("bluebubblesMessageActions", () => {
const listActions = bluebubblesMessageActions.listActions!;
const describeMessageTool = bluebubblesMessageActions.describeMessageTool!;
const supportsAction = bluebubblesMessageActions.supportsAction!;
const extractToolSend = bluebubblesMessageActions.extractToolSend!;
const handleAction = bluebubblesMessageActions.handleAction!;
@@ -74,12 +74,12 @@ describe("bluebubblesMessageActions", () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
describe("listActions", () => {
describe("describeMessageTool", () => {
it("returns empty array when account is not enabled", () => {
const cfg: OpenClawConfig = {
channels: { bluebubbles: { enabled: false } },
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toEqual([]);
});
@@ -87,7 +87,7 @@ describe("bluebubblesMessageActions", () => {
const cfg: OpenClawConfig = {
channels: { bluebubbles: { enabled: true } },
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toEqual([]);
});
@@ -101,7 +101,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toContain("react");
});
@@ -116,7 +116,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).not.toContain("react");
// Other actions should still be present
expect(actions).toContain("edit");
@@ -134,7 +134,7 @@ describe("bluebubblesMessageActions", () => {
},
},
};
const actions = listActions({ cfg });
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toContain("sendAttachment");
expect(actions).not.toContain("react");
expect(actions).not.toContain("reply");

View File

@@ -1,3 +1,6 @@
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
@@ -10,10 +13,7 @@ import {
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "openclaw/plugin-sdk/bluebubbles";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
} from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import {
normalizeBlueBubblesHandle,
@@ -67,10 +67,10 @@ const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg, currentChannelId }) => {
describeMessageTool: ({ cfg, currentChannelId }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg });
if (!account.enabled || !account.configured) {
return [];
return null;
}
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>();
@@ -107,7 +107,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
}
}
}
return Array.from(actions);
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),

View File

@@ -1,6 +1,5 @@
import crypto from "node:crypto";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
import {
@@ -8,6 +7,7 @@ import {
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { resolveRequestUrl } from "./request-url.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";

View File

@@ -1,23 +1,10 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/bluebubbles";
import {
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
PAIRING_APPROVED_MESSAGE,
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/bluebubbles";
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenGroupPolicyRestrictSendersWarnings,
} from "openclaw/plugin-sdk/channel-policy";
import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
listBlueBubblesAccountIds,
@@ -28,10 +15,26 @@ import {
import { bluebubblesMessageActions } from "./actions.js";
import type { BlueBubblesProbe } from "./channel.runtime.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
import {
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
} from "./runtime-api.js";
import { resolveBlueBubblesOutboundSessionRoute } from "./session-route.js";
import { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.js";
import {
extractHandleFromChatGuid,
inferBlueBubblesTargetChatType,
looksLikeBlueBubblesExplicitTargetId,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
@@ -43,6 +46,28 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
"blueBubblesChannelRuntime",
);
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
});
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
channelKey: "bluebubbles",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
});
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
@@ -85,24 +110,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
setupWizard: blueBubblesSetupWizard,
config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg,
sectionKey: "bluebubbles",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg,
sectionKey: "bluebubbles",
accountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
}),
...bluebubblesConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
@@ -111,28 +119,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
configured: account.configured,
baseUrl: account.baseUrl,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
mapAllowFromEntries(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom),
formatAllowFrom: ({ allowFrom }) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
},
actions: bluebubblesMessageActions,
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "bluebubbles",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
});
},
resolveDmPolicy: resolveBlueBubblesDmPolicy,
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
return collectOpenGroupPolicyRestrictSendersWarnings({
@@ -147,9 +137,26 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeBlueBubblesTargetId,
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
resolveTarget: async ({ normalized }) => {
const to = normalized?.trim();
if (!to) {
return null;
}
const chatType = inferBlueBubblesTargetChatType(to);
if (!chatType) {
return null;
}
return {
to,
kind: chatType === "direct" ? "user" : "group",
source: "normalized" as const,
};
},
},
formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => {

View File

@@ -1,9 +1,9 @@
import crypto from "node:crypto";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesChatOpts = {

View File

@@ -1,4 +1,4 @@
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "./runtime-api.js";
type BlueBubblesConfigPatch = {
serverUrl?: string;

View File

@@ -1,4 +1,3 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
import {
AllowFromListSchema,
buildCatchallMultiAccountChannelSchema,
@@ -6,6 +5,7 @@ import {
GroupPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
import { MarkdownConfigSchema, ToolPolicySchema } from "./runtime-api.js";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const bluebubblesActionSchema = z

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
describe("bluebubbles group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
bluebubbles: {
groups: {
"chat:primary": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
deny: ["exec"],
});
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
allow: ["message.send"],
});
});
});

View File

@@ -0,0 +1,40 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
type BlueBubblesGroupContext = {
cfg: OpenClawConfig;
accountId?: string | null;
groupId?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
};
export function resolveBlueBubblesGroupRequireMention(params: BlueBubblesGroupContext): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "bluebubbles",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveBlueBubblesGroupToolPolicy(
params: BlueBubblesGroupContext,
): GroupToolPolicyConfig | undefined {
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "bluebubbles",
groupId: params.groupId,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
}

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesHistoryEntry = {

View File

@@ -3,10 +3,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "./runtime-api.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles } from "./send.js";

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
import type { OpenClawConfig } from "./runtime-api.js";
/**
* Entry type for debouncing inbound messages.

View File

@@ -1,4 +1,4 @@
import { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles";
import { parseFiniteNumber } from "./runtime-api.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";

View File

@@ -1,22 +1,3 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import {
DM_GROUP_ACCESS_REASON,
createScopedPairingAccess,
createReplyPrefixOptions,
evictOldHistoryKeys,
issuePairingChallenge,
logAckFailure,
logInboundDrop,
logTypingFailure,
mapAllowFromEntries,
readStoreAllowFromForDmPolicy,
recordPendingHistoryEntryIfEnabled,
resolveAckReaction,
resolveDmGroupAccessWithLists,
resolveControlCommandGate,
stripMarkdown,
type HistoryEntry,
} from "openclaw/plugin-sdk/bluebubbles";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { fetchBlueBubblesHistory } from "./history.js";
@@ -49,6 +30,25 @@ import type {
} from "./monitor-shared.js";
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import type { OpenClawConfig } from "./runtime-api.js";
import {
DM_GROUP_ACCESS_REASON,
createScopedPairingAccess,
createReplyPrefixOptions,
evictOldHistoryKeys,
issuePairingChallenge,
logAckFailure,
logInboundDrop,
logTypingFailure,
mapAllowFromEntries,
readStoreAllowFromForDmPolicy,
recordPendingHistoryEntryIfEnabled,
resolveAckReaction,
resolveDmGroupAccessWithLists,
resolveControlCommandGate,
stripMarkdown,
type HistoryEntry,
} from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import {

View File

@@ -1,5 +1,5 @@
import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { normalizeWebhookPath, type OpenClawConfig } from "./runtime-api.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import type { BlueBubblesAccountConfig } from "./types.js";

View File

@@ -1,12 +1,5 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import {
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "openclaw/plugin-sdk/bluebubbles";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
@@ -22,6 +15,13 @@ import {
type WebhookTarget,
} from "./monitor-shared.js";
import { fetchBlueBubblesServerInfo } from "./probe.js";
import {
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "./runtime-api.js";
import { getBlueBubblesRuntime } from "./runtime.js";
const webhookTargets = new Map<string, WebhookTarget[]>();

View File

@@ -1,4 +1,4 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/bluebubbles";
import type { BaseProbeResult } from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
@@ -73,7 +73,7 @@ export async function fetchBlueBubblesServerInfo(params: {
}
/**
* Get cached server info synchronously (for use in listActions).
* Get cached server info synchronously (for use in describeMessageTool).
* Returns null if not cached or expired.
*/
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesReactionOpts = {

View File

@@ -1 +1 @@
export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles";
export { resolveRequestUrl } from "./runtime-api.js";

View File

@@ -0,0 +1 @@
export * from "openclaw/plugin-sdk/bluebubbles";

View File

@@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
type LegacyRuntimeLogShape = { log?: (message: string) => void };

View File

@@ -3,7 +3,7 @@ import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/bluebubbles";
} from "./runtime-api.js";
export {
buildSecretInputSchema,

View File

@@ -1,11 +1,11 @@
import crypto from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { stripMarkdown } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesAccount } from "./accounts.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { stripMarkdown } from "./runtime-api.js";
import { warnBlueBubbles } from "./runtime.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";

View File

@@ -0,0 +1,37 @@
import {
buildChannelOutboundSessionRoute,
stripChannelTargetPrefix,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/core";
import { parseBlueBubblesTarget } from "./targets.js";
export function resolveBlueBubblesOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
const stripped = stripChannelTargetPrefix(params.target, "bluebubbles");
if (!stripped) {
return null;
}
const parsed = parseBlueBubblesTarget(stripped);
const isGroup =
parsed.kind === "chat_id" || parsed.kind === "chat_guid" || parsed.kind === "chat_identifier";
const peerId =
parsed.kind === "chat_id"
? String(parsed.chatId)
: parsed.kind === "chat_guid"
? parsed.chatGuid
: parsed.kind === "chat_identifier"
? parsed.chatIdentifier
: parsed.to;
return buildChannelOutboundSessionRoute({
cfg: params.cfg,
agentId: params.agentId,
channel: "bluebubbles",
accountId: params.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
chatType: isGroup ? "group" : "direct",
from: isGroup ? `group:${peerId}` : `bluebubbles:${peerId}`,
to: `bluebubbles:${stripped}`,
});
}

View File

@@ -1,8 +1,8 @@
import {
createTopLevelChannelDmPolicySetter,
normalizeAccountId,
patchScopedAccountConfig,
prepareScopedSetupConfig,
setTopLevelChannelDmPolicyWithAllowFrom,
type ChannelSetupAdapter,
type DmPolicy,
type OpenClawConfig,
@@ -10,13 +10,12 @@ import {
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
const channel = "bluebubbles" as const;
const setBlueBubblesTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({
channel,
});
export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
});
return setBlueBubblesTopLevelDmPolicy(cfg, dmPolicy);
}
export function setBlueBubblesAllowFrom(

View File

@@ -1,11 +1,10 @@
import {
createAllowFromSection,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
resolveSetupAccountId,
promptParsedAllowFromForAccount,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
@@ -55,14 +54,13 @@ async function promptBlueBubblesAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveSetupAccountId({
return await promptParsedAllowFromForAccount({
cfg: params.cfg,
accountId: params.accountId,
defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg),
});
const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
await params.prompter.note(
[
prompter: params.prompter,
noteTitle: "BlueBubbles allowlist",
noteLines: [
"Allowlist BlueBubbles DMs by handle or chat target.",
"Examples:",
"- +15555550123",
@@ -71,30 +69,23 @@ async function promptBlueBubblesAllowFrom(params: {
"- chat_guid:iMessage;-;+15555550123",
"Multiple entries: comma- or newline-separated.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
].join("\n"),
"BlueBubbles allowlist",
);
const entry = await params.prompter.text({
],
message: "BlueBubbles allowFrom (handle or chat_id)",
placeholder: "+15555550123, user@example.com, chat_id:123",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
const parts = parseBlueBubblesAllowFromInput(raw);
for (const part of parts) {
if (!validateBlueBubblesAllowFromEntry(part)) {
return `Invalid entry: ${part}`;
parseEntries: (raw) => {
const entries = parseBlueBubblesAllowFromInput(raw);
for (const entry of entries) {
if (!validateBlueBubblesAllowFromEntry(entry)) {
return { entries: [], error: `Invalid entry: ${entry}` };
}
}
return undefined;
return { entries };
},
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveBlueBubblesAccount({ cfg, accountId }).config.allowFrom ?? [],
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
setBlueBubblesAllowFrom(cfg, accountId, allowFrom),
});
const parts = parseBlueBubblesAllowFromInput(String(entry));
const unique = mergeAllowFromEntries(undefined, parts);
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
}
function validateBlueBubblesServerUrlInput(value: unknown): string | undefined {
@@ -272,7 +263,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
],
},
dmPolicy,
allowFrom: {
allowFrom: createAllowFromSection({
helpTitle: "BlueBubbles allowlist",
helpLines: [
"Allowlist BlueBubbles DMs by handle or chat target.",
@@ -290,15 +281,9 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
"Use a BlueBubbles handle or chat target like +15555550123 or chat_id:123.",
parseInputs: parseBlueBubblesAllowFromInput,
parseId: (raw) => validateBlueBubblesAllowFromEntry(raw),
resolveEntries: async ({ entries }) =>
entries.map((entry) => ({
input: entry,
resolved: Boolean(validateBlueBubblesAllowFromEntry(entry)),
id: validateBlueBubblesAllowFromEntry(entry),
})),
apply: async ({ cfg, accountId, allowFrom }) =>
setBlueBubblesAllowFrom(cfg, accountId, allowFrom),
},
}),
disable: (cfg) => ({
...cfg,
channels: {

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
inferBlueBubblesTargetChatType,
looksLikeBlueBubblesExplicitTargetId,
isAllowedBlueBubblesSender,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
@@ -101,6 +103,30 @@ describe("looksLikeBlueBubblesTargetId", () => {
});
});
describe("looksLikeBlueBubblesExplicitTargetId", () => {
it("treats explicit chat targets as immediate ids", () => {
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
});
it("prefers directory fallback for bare handles and phone numbers", () => {
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
});
});
describe("inferBlueBubblesTargetChatType", () => {
it("infers direct chat for handles and dm chat_guids", () => {
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
});
it("infers group chat for explicit group targets", () => {
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({

View File

@@ -5,7 +5,7 @@ import {
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "openclaw/plugin-sdk/bluebubbles";
} from "./runtime-api.js";
export type BlueBubblesService = "imessage" | "sms" | "auto";
@@ -237,6 +237,63 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
return false;
}
export function looksLikeBlueBubblesExplicitTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) {
return false;
}
const lowered = candidate.toLowerCase();
if (/^(imessage|sms|auto):/.test(lowered)) {
return true;
}
if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered,
)
) {
return true;
}
if (parseRawChatGuid(candidate) || looksLikeRawChatIdentifier(candidate)) {
return true;
}
if (normalized) {
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) {
return false;
}
const normalizedLower = normalizedTrimmed.toLowerCase();
if (
/^(imessage|sms|auto):/.test(normalizedLower) ||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
) {
return true;
}
}
return false;
}
export function inferBlueBubblesTargetChatType(raw: string): "direct" | "group" | undefined {
try {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
return "direct";
}
if (parsed.kind === "chat_guid") {
return parsed.chatGuid.includes(";+;") ? "group" : "direct";
}
if (parsed.kind === "chat_id" || parsed.kind === "chat_identifier") {
return "group";
}
} catch {
return undefined;
}
return undefined;
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = stripBlueBubblesPrefix(raw);
if (!trimmed) {

View File

@@ -1,6 +1,6 @@
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles";
import type { DmPolicy, GroupPolicy } from "./runtime-api.js";
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles";
export type { DmPolicy, GroupPolicy } from "./runtime-api.js";
export type BlueBubblesGroupConfig = {
/** If true, only respond in this group when mentioned. */

View File

@@ -1,28 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import {
createPluginBackedWebSearchProvider,
getTopLevelCredentialValue,
setTopLevelCredentialValue,
} from "openclaw/plugin-sdk/provider-web-search";
import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
export default definePluginEntry({
id: "brave",
name: "Brave Plugin",
description: "Bundled Brave plugin",
register(api) {
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({
id: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
getCredentialValue: getTopLevelCredentialValue,
setCredentialValue: setTopLevelCredentialValue,
}),
);
api.registerWebSearchProvider(createBraveWebSearchProvider());
},
});

View File

@@ -1,8 +1,34 @@
{
"id": "brave",
"uiHints": {
"webSearch.apiKey": {
"label": "Brave Search API Key",
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"sensitive": true,
"placeholder": "BSA..."
},
"webSearch.mode": {
"label": "Brave Search Mode",
"help": "Brave Search mode: web or llm-context."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"mode": {
"type": "string",
"enum": ["web", "llm-context"]
}
}
}
}
}
}

View File

@@ -0,0 +1,648 @@
import { Type } from "@sinclair/typebox";
import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
MAX_SEARCH_COUNT,
normalizeFreshness,
normalizeToIsoDate,
readCachedSearchPayload,
readConfiguredSecretString,
readProviderEnvValue,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
} from "../../../src/plugins/types.js";
import { wrapWebContent } from "../../../src/security/external-content.js";
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
const BRAVE_SEARCH_LANG_CODES = new Set([
"ar",
"eu",
"bn",
"bg",
"ca",
"zh-hans",
"zh-hant",
"hr",
"cs",
"da",
"nl",
"en",
"en-gb",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"gu",
"he",
"hi",
"hu",
"is",
"it",
"jp",
"kn",
"ko",
"lv",
"lt",
"ms",
"ml",
"mr",
"nb",
"pl",
"pt-br",
"pt-pt",
"pa",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"ta",
"te",
"th",
"tr",
"uk",
"vi",
]);
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
ja: "jp",
zh: "zh-hans",
"zh-cn": "zh-hans",
"zh-hk": "zh-hant",
"zh-sg": "zh-hans",
"zh-tw": "zh-hant",
};
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
type BraveConfig = {
apiKey?: unknown;
mode?: string;
};
type BraveSearchResult = {
title?: string;
url?: string;
description?: string;
age?: string;
};
type BraveSearchResponse = {
web?: {
results?: BraveSearchResult[];
};
};
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
type BraveLlmContextResponse = {
grounding: { generic?: BraveLlmContextResult[] };
sources?: { url?: string; hostname?: string; date?: string }[];
};
function resolveBraveConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): BraveConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "brave");
if (pluginConfig) {
return pluginConfig as BraveConfig;
}
const scoped = (searchConfig as Record<string, unknown> | undefined)?.brave;
return scoped && typeof scoped === "object" && !Array.isArray(scoped)
? ({
...(scoped as BraveConfig),
apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey,
} as BraveConfig)
: ({ apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey } as BraveConfig);
}
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
return brave.mode === "llm-context" ? "llm-context" : "web";
}
function resolveBraveApiKey(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): string | undefined {
const braveConfig = resolveBraveConfig(config, searchConfig);
return (
readConfiguredSecretString(
braveConfig.apiKey,
"plugins.entries.brave.config.webSearch.apiKey",
) ??
readConfiguredSecretString(
(searchConfig as Record<string, unknown> | undefined)?.apiKey,
"tools.web.search.apiKey",
) ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
return undefined;
}
return canonical;
}
function normalizeBraveUiLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
if (!match) {
return undefined;
}
const [, language, region] = match;
return `${language.toLowerCase()}-${region.toUpperCase()}`;
}
function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
search_lang?: string;
ui_lang?: string;
invalidField?: "search_lang" | "ui_lang";
} {
const rawSearchLang = params.search_lang?.trim() || undefined;
const rawUiLang = params.ui_lang?.trim() || undefined;
let searchLangCandidate = rawSearchLang;
let uiLangCandidate = rawUiLang;
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
searchLangCandidate = rawUiLang;
uiLangCandidate = rawSearchLang;
}
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
if (searchLangCandidate && !search_lang) {
return { invalidField: "search_lang" };
}
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
if (uiLangCandidate && !ui_lang) {
return { invalidField: "ui_lang" };
}
return { search_lang, ui_lang };
}
function mapBraveLlmContextResults(
data: BraveLlmContextResponse,
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
return genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0),
siteName: resolveSiteName(entry.url) || undefined,
}));
}
async function runBraveLlmContextSearch(params: {
query: string;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
freshness?: string;
}): Promise<{
results: Array<{
url: string;
title: string;
snippets: string[];
siteName?: string;
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as BraveLlmContextResponse;
return { results: mapBraveLlmContextResults(data), sources: data.sources };
},
);
}
async function runBraveWebSearch(params: {
query: string;
count: number;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
}): Promise<Array<Record<string, unknown>>> {
const url = new URL(BRAVE_SEARCH_ENDPOINT);
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(params.count));
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
} else if (params.dateAfter && params.dateBefore) {
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
} else if (params.dateAfter) {
url.searchParams.set(
"freshness",
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
);
} else if (params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as BraveSearchResponse;
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
},
);
}
function createBraveSchema() {
return Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
country: Type.Optional(
Type.String({
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
language: Type.Optional(
Type.String({
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
}),
),
date_after: Type.Optional(
Type.String({
description: "Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description: "Only results published before this date (YYYY-MM-DD).",
}),
),
search_lang: Type.Optional(
Type.String({
description:
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
}),
),
ui_lang: Type.Optional(
Type.String({
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
}),
),
});
}
function missingBraveKeyPayload() {
return {
error: "missing_brave_api_key",
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
function createBraveToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
const braveConfig = resolveBraveConfig(config, searchConfig);
const braveMode = resolveBraveMode(braveConfig);
return {
description:
braveMode === "llm-context"
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: createBraveSchema(),
execute: async (args) => {
const apiKey = resolveBraveApiKey(config, searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
const params = args as Record<string, unknown>;
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const country = readStringParam(params, "country");
const language = readStringParam(params, "language");
const search_lang = readStringParam(params, "search_lang");
const ui_lang = readStringParam(params, "ui_lang");
const normalizedLanguage = normalizeBraveLanguageParams({
search_lang: search_lang || language,
ui_lang,
});
if (normalizedLanguage.invalidField === "search_lang") {
return {
error: "invalid_search_lang",
message:
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.invalidField === "ui_lang") {
return {
error: "invalid_ui_lang",
message: "ui_lang must be a language-region locale like 'en-US'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
return {
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawFreshness = readStringParam(params, "freshness");
if (rawFreshness && braveMode === "llm-context") {
return {
error: "unsupported_freshness",
message:
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawDateAfter = readStringParam(params, "date_after");
const rawDateBefore = readStringParam(params, "date_before");
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") {
return {
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
if (rawDateAfter && !dateAfter) {
return {
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
if (rawDateBefore && !dateBefore) {
return {
error: "invalid_date",
message: "date_before must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (dateAfter && dateBefore && dateAfter > dateBefore) {
return {
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const cacheKey = buildSearchCacheKey([
"brave",
braveMode,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
normalizedLanguage.search_lang,
normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
if (braveMode === "llm-context") {
const { results, sources } = await runBraveLlmContextSearch({
query,
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
freshness,
});
const payload = {
query,
provider: "brave",
mode: "llm-context" as const,
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results: results.map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
siteName: entry.siteName,
})),
sources,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}
const results = await runBraveWebSearch({
query,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
ui_lang: normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
});
const payload = {
query,
provider: "brave",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
},
};
}
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
searchConfigTarget.apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
},
createTool: (ctx) =>
createBraveToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
};
}
export const __testing = {
normalizeFreshness,
normalizeBraveLanguageParams,
resolveBraveMode,
mapBraveLlmContextResults,
} as const;

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.3.17",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",

View File

@@ -8,7 +8,7 @@
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
},
"dependencies": {
"@pierre/diffs": "1.1.0",
"@pierre/diffs": "1.1.1",
"@sinclair/typebox": "0.34.48",
"playwright-core": "1.58.2"
},

View File

@@ -3,6 +3,7 @@ export * from "./src/accounts.js";
export * from "./src/actions/handle-action.guild-admin.js";
export * from "./src/actions/handle-action.js";
export * from "./src/components.js";
export * from "./src/group-policy.js";
export * from "./src/normalize.js";
export * from "./src/pluralkit.js";
export * from "./src/session-key-normalization.js";

View File

@@ -1,4 +1,7 @@
export * from "./src/audit.js";
export * from "./src/actions/runtime.js";
export * from "./src/actions/runtime.moderation-shared.js";
export * from "./src/actions/runtime.shared.js";
export * from "./src/channel-actions.js";
export * from "./src/directory-live.js";
export * from "./src/monitor.js";

View File

@@ -1,18 +1,16 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/discord";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
resolveDiscordAccountConfig,
} from "./accounts.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
hasConfiguredSecretInput,
normalizeSecretInputString,
type OpenClawConfig,
type DiscordAccountConfig,
} from "./runtime-api.js";
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";

View File

@@ -4,8 +4,9 @@ import {
normalizeAccountId,
resolveAccountEntry,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord";
type DiscordAccountConfig,
type DiscordActionConfig,
} from "./runtime-api.js";
import { resolveDiscordToken } from "./token.js";
export type ResolvedDiscordAccount = {

View File

@@ -5,12 +5,12 @@ import {
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
import { handleDiscordAction } from "./runtime.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
} from "openclaw/plugin-sdk/agent-runtime";
import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
} from "./runtime.moderation-shared.js";
type Ctx = Pick<
ChannelMessageActionContext,

View File

@@ -4,8 +4,6 @@ import {
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
import { readDiscordParentIdParam } from "openclaw/plugin-sdk/agent-runtime";
import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
@@ -13,6 +11,8 @@ import { normalizeInteractiveReply } from "openclaw/plugin-sdk/channel-runtime";
import { buildDiscordInteractiveComponents } from "../shared-interactive.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
import { handleDiscordAction } from "./runtime.js";
import { readDiscordParentIdParam } from "./runtime.shared.js";
const providerId = "discord";

View File

@@ -1,5 +1,14 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import { getPresence } from "../monitor/presence-cache.js";
import {
type ActionGate,
jsonResult,
parseAvailableTags,
readNumberParam,
readStringArrayParam,
readStringParam,
type DiscordActionConfig,
} from "../runtime-api.js";
import {
addRoleDiscord,
createChannelDiscord,
@@ -19,17 +28,29 @@ import {
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "../../plugin-sdk/discord.js";
import { getPresence } from "../../plugin-sdk/discord.js";
import {
type ActionGate,
jsonResult,
parseAvailableTags,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "./common.js";
import { readDiscordParentIdParam } from "./discord-actions-shared.js";
} from "../send.js";
import { readDiscordParentIdParam } from "./runtime.shared.js";
export const discordGuildActionRuntime = {
addRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listScheduledEventsDiscord,
moveChannelDiscord,
removeChannelPermissionDiscord,
removeRoleDiscord,
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
};
type DiscordRoleMutation = (params: {
guildId: string;
@@ -85,8 +106,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const member = accountId
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
: await fetchMemberInfoDiscord(guildId, userId);
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId })
: await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId);
const presence = getPresence(accountId, userId);
const activities = presence?.activities ?? undefined;
const status = presence?.status ?? undefined;
@@ -100,8 +121,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const roles = accountId
? await fetchRoleInfoDiscord(guildId, { accountId })
: await fetchRoleInfoDiscord(guildId);
? await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId, { accountId })
: await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId);
return jsonResult({ ok: true, roles });
}
case "emojiList": {
@@ -112,8 +133,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const emojis = accountId
? await listGuildEmojisDiscord(guildId, { accountId })
: await listGuildEmojisDiscord(guildId);
? await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, { accountId })
: await discordGuildActionRuntime.listGuildEmojisDiscord(guildId);
return jsonResult({ ok: true, emojis });
}
case "emojiUpload": {
@@ -129,7 +150,7 @@ export async function handleDiscordGuildAction(
});
const roleIds = readStringArrayParam(params, "roleIds");
const emoji = accountId
? await uploadEmojiDiscord(
? await discordGuildActionRuntime.uploadEmojiDiscord(
{
guildId,
name,
@@ -138,7 +159,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await uploadEmojiDiscord({
: await discordGuildActionRuntime.uploadEmojiDiscord({
guildId,
name,
mediaUrl,
@@ -162,7 +183,7 @@ export async function handleDiscordGuildAction(
required: true,
});
const sticker = accountId
? await uploadStickerDiscord(
? await discordGuildActionRuntime.uploadStickerDiscord(
{
guildId,
name,
@@ -172,7 +193,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await uploadStickerDiscord({
: await discordGuildActionRuntime.uploadStickerDiscord({
guildId,
name,
description,
@@ -185,14 +206,22 @@ export async function handleDiscordGuildAction(
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
await runRoleMutation({ accountId, values: params, mutate: addRoleDiscord });
await runRoleMutation({
accountId,
values: params,
mutate: discordGuildActionRuntime.addRoleDiscord,
});
return jsonResult({ ok: true });
}
case "roleRemove": {
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
await runRoleMutation({ accountId, values: params, mutate: removeRoleDiscord });
await runRoleMutation({
accountId,
values: params,
mutate: discordGuildActionRuntime.removeRoleDiscord,
});
return jsonResult({ ok: true });
}
case "channelInfo": {
@@ -203,8 +232,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const channel = accountId
? await fetchChannelInfoDiscord(channelId, { accountId })
: await fetchChannelInfoDiscord(channelId);
? await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId, { accountId })
: await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId);
return jsonResult({ ok: true, channel });
}
case "channelList": {
@@ -215,8 +244,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const channels = accountId
? await listGuildChannelsDiscord(guildId, { accountId })
: await listGuildChannelsDiscord(guildId);
? await discordGuildActionRuntime.listGuildChannelsDiscord(guildId, { accountId })
: await discordGuildActionRuntime.listGuildChannelsDiscord(guildId);
return jsonResult({ ok: true, channels });
}
case "voiceStatus": {
@@ -230,8 +259,10 @@ export async function handleDiscordGuildAction(
required: true,
});
const voice = accountId
? await fetchVoiceStatusDiscord(guildId, userId, { accountId })
: await fetchVoiceStatusDiscord(guildId, userId);
? await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId, {
accountId,
})
: await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId);
return jsonResult({ ok: true, voice });
}
case "eventList": {
@@ -242,8 +273,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const events = accountId
? await listScheduledEventsDiscord(guildId, { accountId })
: await listScheduledEventsDiscord(guildId);
? await discordGuildActionRuntime.listScheduledEventsDiscord(guildId, { accountId })
: await discordGuildActionRuntime.listScheduledEventsDiscord(guildId);
return jsonResult({ ok: true, events });
}
case "eventCreate": {
@@ -274,8 +305,10 @@ export async function handleDiscordGuildAction(
privacy_level: 2,
};
const event = accountId
? await createScheduledEventDiscord(guildId, payload, { accountId })
: await createScheduledEventDiscord(guildId, payload);
? await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload, {
accountId,
})
: await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event });
}
case "channelCreate": {
@@ -290,7 +323,7 @@ export async function handleDiscordGuildAction(
const position = readNumberParam(params, "position", { integer: true });
const nsfw = params.nsfw as boolean | undefined;
const channel = accountId
? await createChannelDiscord(
? await discordGuildActionRuntime.createChannelDiscord(
{
guildId,
name,
@@ -302,7 +335,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await createChannelDiscord({
: await discordGuildActionRuntime.createChannelDiscord({
guildId,
name,
type: type ?? undefined,
@@ -348,8 +381,8 @@ export async function handleDiscordGuildAction(
availableTags,
};
const channel = accountId
? await editChannelDiscord(editPayload, { accountId })
: await editChannelDiscord(editPayload);
? await discordGuildActionRuntime.editChannelDiscord(editPayload, { accountId })
: await discordGuildActionRuntime.editChannelDiscord(editPayload);
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
@@ -360,8 +393,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const result = accountId
? await deleteChannelDiscord(channelId, { accountId })
: await deleteChannelDiscord(channelId);
? await discordGuildActionRuntime.deleteChannelDiscord(channelId, { accountId })
: await discordGuildActionRuntime.deleteChannelDiscord(channelId);
return jsonResult(result);
}
case "channelMove": {
@@ -375,7 +408,7 @@ export async function handleDiscordGuildAction(
const parentId = readDiscordParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
if (accountId) {
await moveChannelDiscord(
await discordGuildActionRuntime.moveChannelDiscord(
{
guildId,
channelId,
@@ -385,7 +418,7 @@ export async function handleDiscordGuildAction(
{ accountId },
);
} else {
await moveChannelDiscord({
await discordGuildActionRuntime.moveChannelDiscord({
guildId,
channelId,
parentId,
@@ -402,7 +435,7 @@ export async function handleDiscordGuildAction(
const name = readStringParam(params, "name", { required: true });
const position = readNumberParam(params, "position", { integer: true });
const channel = accountId
? await createChannelDiscord(
? await discordGuildActionRuntime.createChannelDiscord(
{
guildId,
name,
@@ -411,7 +444,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await createChannelDiscord({
: await discordGuildActionRuntime.createChannelDiscord({
guildId,
name,
type: 4,
@@ -429,7 +462,7 @@ export async function handleDiscordGuildAction(
const name = readStringParam(params, "name");
const position = readNumberParam(params, "position", { integer: true });
const channel = accountId
? await editChannelDiscord(
? await discordGuildActionRuntime.editChannelDiscord(
{
channelId: categoryId,
name: name ?? undefined,
@@ -437,7 +470,7 @@ export async function handleDiscordGuildAction(
},
{ accountId },
)
: await editChannelDiscord({
: await discordGuildActionRuntime.editChannelDiscord({
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
@@ -452,8 +485,8 @@ export async function handleDiscordGuildAction(
required: true,
});
const result = accountId
? await deleteChannelDiscord(categoryId, { accountId })
: await deleteChannelDiscord(categoryId);
? await discordGuildActionRuntime.deleteChannelDiscord(categoryId, { accountId })
: await discordGuildActionRuntime.deleteChannelDiscord(categoryId);
return jsonResult(result);
}
case "channelPermissionSet": {
@@ -468,7 +501,7 @@ export async function handleDiscordGuildAction(
const allow = readStringParam(params, "allow");
const deny = readStringParam(params, "deny");
if (accountId) {
await setChannelPermissionDiscord(
await discordGuildActionRuntime.setChannelPermissionDiscord(
{
channelId,
targetId,
@@ -479,7 +512,7 @@ export async function handleDiscordGuildAction(
{ accountId },
);
} else {
await setChannelPermissionDiscord({
await discordGuildActionRuntime.setChannelPermissionDiscord({
channelId,
targetId,
targetType,
@@ -495,9 +528,11 @@ export async function handleDiscordGuildAction(
}
const { channelId, targetId } = readChannelPermissionTarget(params);
if (accountId) {
await removeChannelPermissionDiscord(channelId, targetId, { accountId });
await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId, {
accountId,
});
} else {
await removeChannelPermissionDiscord(channelId, targetId);
await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId);
}
return jsonResult({ ok: true });
}

View File

@@ -1,7 +1,19 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import { readDiscordComponentSpec } from "../components.js";
import {
assertMediaNotDataUrl,
type ActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
resolvePollMaxSelections,
type DiscordActionConfig,
type OpenClawConfig,
withNormalizedTimestamp,
readBooleanParam,
} from "../runtime-api.js";
import {
createThreadDiscord,
deleteMessageDiscord,
@@ -23,20 +35,34 @@ import {
sendStickerDiscord,
sendVoiceMessageDiscord,
unpinMessageDiscord,
} from "../../plugin-sdk/discord.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js";
import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
import {
type ActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "./common.js";
} from "../send.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js";
import { resolveDiscordChannelId } from "../targets.js";
export const discordMessagingActionRuntime = {
createThreadDiscord,
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchMessageDiscord,
fetchReactionsDiscord,
listPinsDiscord,
listThreadsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readDiscordComponentSpec,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
resolveDiscordChannelId,
searchMessagesDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendVoiceMessageDiscord,
unpinMessageDiscord,
};
function parseDiscordMessageLink(link: string) {
const normalized = link.trim();
@@ -65,7 +91,7 @@ export async function handleDiscordMessagingAction(
cfg?: OpenClawConfig,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
resolveDiscordChannelId(
discordMessagingActionRuntime.resolveDiscordChannelId(
readStringParam(params, "channelId", {
required: true,
}),
@@ -95,28 +121,45 @@ export async function handleDiscordMessagingAction(
});
if (remove) {
if (accountId) {
await removeReactionDiscord(channelId, messageId, emoji, {
await discordMessagingActionRuntime.removeReactionDiscord(channelId, messageId, emoji, {
...cfgOptions,
accountId,
});
} else {
await removeReactionDiscord(channelId, messageId, emoji, cfgOptions);
await discordMessagingActionRuntime.removeReactionDiscord(
channelId,
messageId,
emoji,
cfgOptions,
);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = accountId
? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId })
: await removeOwnReactionsDiscord(channelId, messageId, cfgOptions);
? await discordMessagingActionRuntime.removeOwnReactionsDiscord(channelId, messageId, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.removeOwnReactionsDiscord(
channelId,
messageId,
cfgOptions,
);
return jsonResult({ ok: true, removed: removed.removed });
}
if (accountId) {
await reactMessageDiscord(channelId, messageId, emoji, {
await discordMessagingActionRuntime.reactMessageDiscord(channelId, messageId, emoji, {
...cfgOptions,
accountId,
});
} else {
await reactMessageDiscord(channelId, messageId, emoji, cfgOptions);
await discordMessagingActionRuntime.reactMessageDiscord(
channelId,
messageId,
emoji,
cfgOptions,
);
}
return jsonResult({ ok: true, added: emoji });
}
@@ -129,11 +172,15 @@ export async function handleDiscordMessagingAction(
required: true,
});
const limit = readNumberParam(params, "limit");
const reactions = await fetchReactionsDiscord(channelId, messageId, {
...cfgOptions,
...(accountId ? { accountId } : {}),
limit,
});
const reactions = await discordMessagingActionRuntime.fetchReactionsDiscord(
channelId,
messageId,
{
...cfgOptions,
...(accountId ? { accountId } : {}),
limit,
},
);
return jsonResult({ ok: true, reactions });
}
case "sticker": {
@@ -146,7 +193,7 @@ export async function handleDiscordMessagingAction(
required: true,
label: "stickerIds",
});
await sendStickerDiscord(to, stickerIds, {
await discordMessagingActionRuntime.sendStickerDiscord(to, stickerIds, {
...cfgOptions,
...(accountId ? { accountId } : {}),
content,
@@ -169,7 +216,7 @@ export async function handleDiscordMessagingAction(
const allowMultiselect = readBooleanParam(params, "allowMultiselect");
const durationHours = readNumberParam(params, "durationHours");
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
await sendPollDiscord(
await discordMessagingActionRuntime.sendPollDiscord(
to,
{ question, options: answers, maxSelections, durationHours },
{ ...cfgOptions, ...(accountId ? { accountId } : {}), content },
@@ -182,8 +229,11 @@ export async function handleDiscordMessagingAction(
}
const channelId = resolveChannelId();
const permissions = accountId
? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId })
: await fetchChannelPermissionsDiscord(channelId, cfgOptions);
? await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, cfgOptions);
return jsonResult({ ok: true, permissions });
}
case "fetchMessage": {
@@ -206,8 +256,11 @@ export async function handleDiscordMessagingAction(
);
}
const message = accountId
? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId })
: await fetchMessageDiscord(channelId, messageId, cfgOptions);
? await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, cfgOptions);
return jsonResult({
ok: true,
message: normalizeMessage(message),
@@ -228,8 +281,11 @@ export async function handleDiscordMessagingAction(
around: readStringParam(params, "around"),
};
const messages = accountId
? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId })
: await readMessagesDiscord(channelId, query, cfgOptions);
? await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, cfgOptions);
return jsonResult({
ok: true,
messages: messages.map((message) => normalizeMessage(message)),
@@ -245,7 +301,7 @@ export async function handleDiscordMessagingAction(
const rawComponents = params.components;
const componentSpec =
rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents)
? readDiscordComponentSpec(rawComponents)
? discordMessagingActionRuntime.readDiscordComponentSpec(rawComponents)
: null;
const components: DiscordSendComponents | undefined =
Array.isArray(rawComponents) || typeof rawComponents === "function"
@@ -279,16 +335,20 @@ export async function handleDiscordMessagingAction(
const payload = componentSpec.text
? componentSpec
: { ...componentSpec, text: normalizedContent };
const result = await sendDiscordComponentMessage(to, payload, {
...cfgOptions,
...(accountId ? { accountId } : {}),
silent,
replyTo: replyTo ?? undefined,
sessionKey: sessionKey ?? undefined,
agentId: agentId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
});
const result = await discordMessagingActionRuntime.sendDiscordComponentMessage(
to,
payload,
{
...cfgOptions,
...(accountId ? { accountId } : {}),
silent,
replyTo: replyTo ?? undefined,
sessionKey: sessionKey ?? undefined,
agentId: agentId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
},
);
return jsonResult({ ok: true, result, components: true });
}
@@ -305,7 +365,7 @@ export async function handleDiscordMessagingAction(
);
}
assertMediaNotDataUrl(mediaUrl);
const result = await sendVoiceMessageDiscord(to, mediaUrl, {
const result = await discordMessagingActionRuntime.sendVoiceMessageDiscord(to, mediaUrl, {
...cfgOptions,
...(accountId ? { accountId } : {}),
replyTo,
@@ -314,7 +374,7 @@ export async function handleDiscordMessagingAction(
return jsonResult({ ok: true, result, voiceMessage: true });
}
const result = await sendMessageDiscord(to, content ?? "", {
const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", {
...cfgOptions,
...(accountId ? { accountId } : {}),
mediaUrl,
@@ -338,8 +398,18 @@ export async function handleDiscordMessagingAction(
required: true,
});
const message = accountId
? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId })
: await editMessageDiscord(channelId, messageId, { content }, cfgOptions);
? await discordMessagingActionRuntime.editMessageDiscord(
channelId,
messageId,
{ content },
{ ...cfgOptions, accountId },
)
: await discordMessagingActionRuntime.editMessageDiscord(
channelId,
messageId,
{ content },
cfgOptions,
);
return jsonResult({ ok: true, message });
}
case "deleteMessage": {
@@ -351,9 +421,12 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, {
...cfgOptions,
accountId,
});
} else {
await deleteMessageDiscord(channelId, messageId, cfgOptions);
await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@@ -375,8 +448,11 @@ export async function handleDiscordMessagingAction(
appliedTags: appliedTags ?? undefined,
};
const thread = accountId
? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId })
: await createThreadDiscord(channelId, payload, cfgOptions);
? await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, cfgOptions);
return jsonResult({ ok: true, thread });
}
case "threadList": {
@@ -391,7 +467,7 @@ export async function handleDiscordMessagingAction(
const before = readStringParam(params, "before");
const limit = readNumberParam(params, "limit");
const threads = accountId
? await listThreadsDiscord(
? await discordMessagingActionRuntime.listThreadsDiscord(
{
guildId,
channelId,
@@ -401,7 +477,7 @@ export async function handleDiscordMessagingAction(
},
{ ...cfgOptions, accountId },
)
: await listThreadsDiscord(
: await discordMessagingActionRuntime.listThreadsDiscord(
{
guildId,
channelId,
@@ -423,13 +499,17 @@ export async function handleDiscordMessagingAction(
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
...cfgOptions,
...(accountId ? { accountId } : {}),
mediaUrl,
mediaLocalRoots: options?.mediaLocalRoots,
replyTo,
});
const result = await discordMessagingActionRuntime.sendMessageDiscord(
`channel:${channelId}`,
content,
{
...cfgOptions,
...(accountId ? { accountId } : {}),
mediaUrl,
mediaLocalRoots: options?.mediaLocalRoots,
replyTo,
},
);
return jsonResult({ ok: true, result });
}
case "pinMessage": {
@@ -441,9 +521,12 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, {
...cfgOptions,
accountId,
});
} else {
await pinMessageDiscord(channelId, messageId, cfgOptions);
await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@@ -456,9 +539,12 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, {
...cfgOptions,
accountId,
});
} else {
await unpinMessageDiscord(channelId, messageId, cfgOptions);
await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@@ -468,8 +554,11 @@ export async function handleDiscordMessagingAction(
}
const channelId = resolveChannelId();
const pins = accountId
? await listPinsDiscord(channelId, { ...cfgOptions, accountId })
: await listPinsDiscord(channelId, cfgOptions);
? await discordMessagingActionRuntime.listPinsDiscord(channelId, {
...cfgOptions,
accountId,
})
: await discordMessagingActionRuntime.listPinsDiscord(channelId, cfgOptions);
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
case "searchMessages": {
@@ -490,7 +579,7 @@ export async function handleDiscordMessagingAction(
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
const results = accountId
? await searchMessagesDiscord(
? await discordMessagingActionRuntime.searchMessagesDiscord(
{
guildId,
content,
@@ -500,7 +589,7 @@ export async function handleDiscordMessagingAction(
},
{ ...cfgOptions, accountId },
)
: await searchMessagesDiscord(
: await discordMessagingActionRuntime.searchMessagesDiscord(
{
guildId,
content,

View File

@@ -1,5 +1,5 @@
import { PermissionFlagsBits } from "discord-api-types/v10";
import { readNumberParam, readStringParam } from "./common.js";
import { readNumberParam, readStringParam } from "../runtime-api.js";
export type DiscordModerationAction = "timeout" | "kick" | "ban";

View File

@@ -1,25 +1,30 @@
import { PermissionFlagsBits } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../config/config.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import {
discordModerationActionRuntime,
handleDiscordModerationAction,
} from "./runtime.moderation.js";
const discordSendMocks = vi.hoisted(() => ({
banMemberDiscord: vi.fn(async () => ({ ok: true })),
kickMemberDiscord: vi.fn(async () => ({ ok: true })),
timeoutMemberDiscord: vi.fn(async () => ({ id: "user-1" })),
hasAnyGuildPermissionDiscord: vi.fn(async () => false),
}));
const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasAnyGuildPermissionDiscord } =
discordSendMocks;
vi.mock("../../../extensions/discord/src/send.js", () => ({
...discordSendMocks,
}));
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };
const banMemberDiscord = vi.fn(async () => ({ ok: true }));
const kickMemberDiscord = vi.fn(async () => ({ ok: true }));
const timeoutMemberDiscord = vi.fn(async () => ({ id: "user-1" }));
const hasAnyGuildPermissionDiscord = vi.fn(async () => false);
const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true;
describe("discord moderation sender authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(discordModerationActionRuntime, originalDiscordModerationActionRuntime, {
banMemberDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
hasAnyGuildPermissionDiscord,
});
});
it("rejects ban when sender lacks BAN_MEMBERS", async () => {
hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false);

View File

@@ -1,17 +1,28 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
type ActionGate,
jsonResult,
readStringParam,
type DiscordActionConfig,
} from "../runtime-api.js";
import {
banMemberDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
} from "../../plugin-sdk/discord.js";
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
} from "../send.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
requiredGuildPermissionForModerationAction,
} from "./discord-actions-moderation-shared.js";
} from "./runtime.moderation-shared.js";
export const discordModerationActionRuntime = {
banMemberDiscord,
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
};
async function verifySenderModerationPermission(params: {
guildId: string;
@@ -23,7 +34,7 @@ async function verifySenderModerationPermission(params: {
if (!params.senderUserId) {
return;
}
const hasPermission = await hasAnyGuildPermissionDiscord(
const hasPermission = await discordModerationActionRuntime.hasAnyGuildPermissionDiscord(
params.guildId,
params.senderUserId,
[params.requiredPermission],
@@ -57,7 +68,7 @@ export async function handleDiscordModerationAction(
switch (command.action) {
case "timeout": {
const member = accountId
? await timeoutMemberDiscord(
? await discordModerationActionRuntime.timeoutMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
@@ -67,7 +78,7 @@ export async function handleDiscordModerationAction(
},
{ accountId },
)
: await timeoutMemberDiscord({
: await discordModerationActionRuntime.timeoutMemberDiscord({
guildId: command.guildId,
userId: command.userId,
durationMinutes: command.durationMinutes,
@@ -78,7 +89,7 @@ export async function handleDiscordModerationAction(
}
case "kick": {
if (accountId) {
await kickMemberDiscord(
await discordModerationActionRuntime.kickMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
@@ -87,7 +98,7 @@ export async function handleDiscordModerationAction(
{ accountId },
);
} else {
await kickMemberDiscord({
await discordModerationActionRuntime.kickMemberDiscord({
guildId: command.guildId,
userId: command.userId,
reason: command.reason,
@@ -97,7 +108,7 @@ export async function handleDiscordModerationAction(
}
case "ban": {
if (accountId) {
await banMemberDiscord(
await discordModerationActionRuntime.banMemberDiscord(
{
guildId: command.guildId,
userId: command.userId,
@@ -107,7 +118,7 @@ export async function handleDiscordModerationAction(
{ accountId },
);
} else {
await banMemberDiscord({
await discordModerationActionRuntime.banMemberDiscord({
guildId: command.guildId,
userId: command.userId,
reason: command.reason,

View File

@@ -1,12 +1,9 @@
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearGateways,
registerGateway,
} from "../../../extensions/discord/src/monitor/gateway-registry.js";
import type { DiscordActionConfig } from "../../config/config.js";
import type { ActionGate } from "./common.js";
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
import type { ActionGate } from "../../../../src/agents/tools/common.js";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import { clearGateways, registerGateway } from "../monitor/gateway-registry.js";
import { handleDiscordPresenceAction } from "./runtime.presence.js";
const mockUpdatePresence = vi.fn();

View File

@@ -1,8 +1,12 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import { getGateway } from "../../plugin-sdk/discord.js";
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
import { getGateway } from "../monitor/gateway-registry.js";
import {
type ActionGate,
jsonResult,
readStringParam,
type DiscordActionConfig,
} from "../runtime-api.js";
const ACTIVITY_TYPE_MAP: Record<string, number> = {
playing: 0,

View File

@@ -1,4 +1,4 @@
import { readStringParam } from "./common.js";
import { readStringParam } from "../runtime-api.js";
export function readDiscordParentIdParam(
params: Record<string, unknown>,

View File

@@ -1,11 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig, OpenClawConfig } from "../../config/config.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { handleDiscordAction } from "./discord-actions.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js";
import { handleDiscordAction } from "./runtime.js";
import {
discordMessagingActionRuntime,
handleDiscordMessagingAction,
} from "./runtime.messaging.js";
import {
discordModerationActionRuntime,
handleDiscordModerationAction,
} from "./runtime.moderation.js";
const discordSendMocks = vi.hoisted(() => ({
const originalDiscordMessagingActionRuntime = { ...discordMessagingActionRuntime };
const originalDiscordGuildActionRuntime = { ...discordGuildActionRuntime };
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };
const discordSendMocks = {
banMemberDiscord: vi.fn(async () => ({})),
createChannelDiscord: vi.fn(async () => ({
id: "new-channel",
@@ -42,7 +53,7 @@ const discordSendMocks = vi.hoisted(() => ({
setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
timeoutMemberDiscord: vi.fn(async () => ({})),
unpinMessageDiscord: vi.fn(async () => ({})),
}));
};
const {
createChannelDiscord,
@@ -67,21 +78,28 @@ const {
timeoutMemberDiscord,
} = discordSendMocks;
vi.mock("../../../extensions/discord/src/send.js", () => ({
...discordSendMocks,
}));
const enableAllActions = () => true;
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation";
describe("handleDiscordMessagingAction", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
Object.assign(
discordMessagingActionRuntime,
originalDiscordMessagingActionRuntime,
discordSendMocks,
);
Object.assign(discordGuildActionRuntime, originalDiscordGuildActionRuntime, discordSendMocks);
Object.assign(
discordModerationActionRuntime,
originalDiscordModerationActionRuntime,
discordSendMocks,
);
});
describe("handleDiscordMessagingAction", () => {
it.each([
{
name: "without account",

View File

@@ -1,11 +1,10 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { createDiscordActionGate } from "../../plugin-sdk/discord.js";
import { readStringParam } from "./common.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
import { createDiscordActionGate } from "../accounts.js";
import { readStringParam, type OpenClawConfig } from "../runtime-api.js";
import { handleDiscordGuildAction } from "./runtime.guild.js";
import { handleDiscordMessagingAction } from "./runtime.messaging.js";
import { handleDiscordModerationAction } from "./runtime.moderation.js";
import { handleDiscordPresenceAction } from "./runtime.presence.js";
const messagingActions = new Set([
"react",

View File

@@ -1,5 +1,5 @@
import { type ChannelPlugin } from "openclaw/plugin-sdk/discord";
import { type ResolvedDiscordAccount } from "./accounts.js";
import { type ChannelPlugin } from "./runtime-api.js";
import { discordSetupAdapter } from "./setup-core.js";
import { createDiscordPluginBase } from "./shared.js";

View File

@@ -209,3 +209,42 @@ describe("discordPlugin outbound", () => {
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
});
});
describe("discordPlugin groups", () => {
it("uses plugin-owned group policy resolvers", () => {
const cfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
},
},
},
},
},
},
} as OpenClawConfig;
expect(
discordPlugin.groups?.resolveRequireMention?.({
cfg,
groupSpace: "guild1",
groupId: "123",
}),
).toBe(true);
expect(
discordPlugin.groups?.resolveToolPolicy?.({
cfg,
groupSpace: "guild1",
groupId: "123",
}),
).toEqual({ allow: ["message.channel"] });
});
});

View File

@@ -3,30 +3,14 @@ import {
buildAccountScopedAllowlistConfigEditor,
resolveLegacyDmAllowlistConfigPaths,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenGroupPolicyConfiguredRouteWarnings,
collectOpenProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime";
import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
import {
buildComputedAccountStatusSnapshot,
buildTokenChannelStatusSummary,
DEFAULT_ACCOUNT_ID,
getChatChannelMeta,
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type OpenClawConfig,
} from "openclaw/plugin-sdk/discord";
import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
import {
listDiscordAccountIds,
@@ -34,10 +18,18 @@ import {
type ResolvedDiscordAccount,
} from "./accounts.js";
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./directory-config.js";
import {
isDiscordExecApprovalClientEnabled,
shouldSuppressLocalDiscordExecApprovalPrompt,
} from "./exec-approvals.js";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
import { monitorDiscordProvider } from "./monitor.js";
import {
looksLikeDiscordTargetId,
@@ -46,10 +38,22 @@ import {
} from "./normalize.js";
import { probeDiscord, type DiscordProbe } from "./probe.js";
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
import {
buildComputedAccountStatusSnapshot,
buildTokenChannelStatusSummary,
type ChannelMessageActionAdapter,
type ChannelPlugin,
DEFAULT_ACCOUNT_ID,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
type OpenClawConfig,
} from "./runtime-api.js";
import { getDiscordRuntime } from "./runtime.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
import { discordSetupAdapter } from "./setup-core.js";
import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./targets.js";
import { DiscordUiContainer } from "./ui.js";
@@ -61,6 +65,14 @@ type DiscordSendFn = ReturnType<
const meta = getChatChannelMeta("discord");
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAccount>({
channelKey: "discord",
resolvePolicy: (account) => account.config.dm?.policy,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
});
function formatDiscordIntents(intents?: {
messageContent?: string;
guildMembers?: string;
@@ -79,12 +91,6 @@ function formatDiscordIntents(intents?: {
const discordMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null,
listActions: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
getCapabilities: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [],
getToolSchema: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.getToolSchema?.(ctx) ?? null,
extractToolSend: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null,
handleAction: async (ctx) => {
@@ -301,23 +307,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "discord",
normalize: ({ cfg, accountId, values }) =>
discordConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: resolveLegacyDmAllowlistConfigPaths,
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "discord",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dm?.policy,
allowFrom: account.config.dm?.allowFrom ?? [],
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
});
},
resolveDmPolicy: resolveDiscordDmPolicy,
collectWarnings: ({ account, cfg }) => {
const guildEntries = account.config.guilds ?? {};
const guildsConfigured = Object.keys(guildEntries).length > 0;
@@ -366,6 +361,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType,
buildCrossContextComponents: buildDiscordCrossContextComponents,

View File

@@ -0,0 +1,57 @@
import {
applyDirectoryQueryAndLimit,
collectNormalizedDirectoryIds,
toDirectoryEntries,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import type { InspectedDiscordAccount } from "../../../src/channels/read-only-account-inspect.discord.runtime.js";
import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js";
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
const account = (await inspectReadOnlyChannelAccount({
channelId: "discord",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedDiscordAccount | null;
if (!account || !("config" in account)) {
return [];
}
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
const ids = collectNormalizedDirectoryIds({
sources: [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers],
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
const account = (await inspectReadOnlyChannelAccount({
channelId: "discord",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedDiscordAccount | null;
if (!account || !("config" in account)) {
return [];
}
const ids = collectNormalizedDirectoryIds({
sources: Object.values(account.config.guilds ?? {}).map((guild) =>
Object.keys(guild.channels ?? {}),
),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DirectoryConfigParams } from "../../../src/plugin-sdk/directory-runtime.js";
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
describe("discord group policy", () => {
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
const discordCfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
toolsBySender: {
"id:user:guild-admin": { allow: ["sessions.list"] },
},
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
toolsBySender: {
"id:user:channel-admin": { deny: ["exec"] },
},
},
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
).toBe(true);
expect(
resolveDiscordGroupRequireMention({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
}),
).toBe(false);
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:channel-admin",
}),
).toEqual({ deny: ["exec"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.channel"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:guild-admin",
}),
).toEqual({ allow: ["sessions.list"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.guild"] });
});
});

View File

@@ -0,0 +1,111 @@
import {
resolveToolsBySender,
type GroupToolPolicyBySenderConfig,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core";
import type { DiscordConfig } from "openclaw/plugin-sdk/discord";
function normalizeDiscordSlug(value?: string | null) {
return normalizeAtHashSlug(value);
}
type SenderScopedToolsEntry = {
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
requireMention?: boolean;
};
function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) {
if (!guilds || Object.keys(guilds).length === 0) {
return null;
}
const space = groupSpace?.trim() ?? "";
if (space && guilds[space]) {
return guilds[space];
}
const normalized = normalizeDiscordSlug(space);
if (normalized && guilds[normalized]) {
return guilds[normalized];
}
if (normalized) {
const match = Object.values(guilds).find(
(entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized,
);
if (match) {
return match;
}
}
return guilds["*"] ?? null;
}
function resolveDiscordChannelEntry<TEntry extends SenderScopedToolsEntry>(
channelEntries: Record<string, TEntry> | undefined,
params: { groupId?: string | null; groupChannel?: string | null },
): TEntry | undefined {
if (!channelEntries || Object.keys(channelEntries).length === 0) {
return undefined;
}
const groupChannel = params.groupChannel;
const channelSlug = normalizeDiscordSlug(groupChannel);
return (
(params.groupId ? channelEntries[params.groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined)
);
}
function resolveSenderToolsEntry(
entry: SenderScopedToolsEntry | undefined | null,
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
if (!entry) {
return undefined;
}
const senderPolicy = resolveToolsBySender({
toolsBySender: entry.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
return senderPolicy ?? entry.tools;
}
function resolveDiscordPolicyContext(params: ChannelGroupContext) {
const guildEntry = resolveDiscordGuildEntry(
params.cfg.channels?.discord?.guilds,
params.groupSpace,
);
const channelEntries = guildEntry?.channels;
const channelEntry =
channelEntries && Object.keys(channelEntries).length > 0
? resolveDiscordChannelEntry(channelEntries, params)
: undefined;
return { guildEntry, channelEntry };
}
export function resolveDiscordGroupRequireMention(params: ChannelGroupContext): boolean {
const context = resolveDiscordPolicyContext(params);
if (typeof context.channelEntry?.requireMention === "boolean") {
return context.channelEntry.requireMention;
}
if (typeof context.guildEntry?.requireMention === "boolean") {
return context.guildEntry.requireMention;
}
return true;
}
export function resolveDiscordGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const context = resolveDiscordPolicyContext(params);
const channelPolicy = resolveSenderToolsEntry(context.channelEntry, params);
if (channelPolicy) {
return channelPolicy;
}
return resolveSenderToolsEntry(context.guildEntry, params);
}

View File

@@ -0,0 +1,754 @@
import {
type ButtonInteraction,
type ChannelSelectMenuInteraction,
type ComponentData,
type MentionableSelectMenuInteraction,
type ModalInteraction,
type RoleSelectMenuInteraction,
type StringSelectMenuInteraction,
type UserSelectMenuInteraction,
} from "@buape/carbon";
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ChannelType } from "discord-api-types/v10";
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import {
issuePairingChallenge,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist,
} from "openclaw/plugin-sdk/security-runtime";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import {
createDiscordFormModal,
parseDiscordComponentCustomId,
parseDiscordModalCustomId,
type DiscordComponentEntry,
type DiscordModalEntry,
} from "../components.js";
import {
type DiscordGuildEntryResolved,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
export const AGENT_BUTTON_KEY = "agent";
export const AGENT_SELECT_KEY = "agentsel";
export type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
export type AgentComponentMessageInteraction =
| ButtonInteraction
| StringSelectMenuInteraction
| RoleSelectMenuInteraction
| UserSelectMenuInteraction
| MentionableSelectMenuInteraction
| ChannelSelectMenuInteraction;
export type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
export type DiscordChannelContext = {
channelName: string | undefined;
channelSlug: string;
channelType: number | undefined;
isThread: boolean;
parentId: string | undefined;
parentName: string | undefined;
parentSlug: string;
};
export type AgentComponentContext = {
cfg: OpenClawConfig;
accountId: string;
discordConfig?: DiscordAccountConfig;
runtime?: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
token?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
allowFrom?: string[];
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
};
export type ComponentInteractionContext = NonNullable<
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
>;
function formatUsername(user: { username: string; discriminator?: string | null }): string {
if (user.discriminator && user.discriminator !== "0") {
return `${user.username}#${user.discriminator}`;
}
return user.username;
}
function isThreadChannelType(channelType: number | undefined): boolean {
return (
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread
);
}
function readParsedComponentId(data: ComponentData): unknown {
if (!data || typeof data !== "object") {
return undefined;
}
return "cid" in data
? (data as Record<string, unknown>).cid
: (data as Record<string, unknown>).componentId;
}
function normalizeComponentId(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return undefined;
}
function mapOptionLabels(
options: Array<{ value: string; label: string }> | undefined,
values: string[],
) {
if (!options || options.length === 0) {
return values;
}
const map = new Map(options.map((option) => [option.value, option.label]));
return values.map((value) => map.get(value) ?? value);
}
/**
* The component custom id only carries the logical button id. Channel binding
* comes from Discord's trusted interaction payload.
*/
export function buildAgentButtonCustomId(componentId: string): string {
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
export function buildAgentSelectCustomId(componentId: string): string {
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
export function resolveAgentComponentRoute(params: {
ctx: AgentComponentContext;
rawGuildId: string | undefined;
memberRoleIds: string[];
isDirectMessage: boolean;
userId: string;
channelId: string;
parentId: string | undefined;
}) {
return resolveAgentRoute({
cfg: params.ctx.cfg,
channel: "discord",
accountId: params.ctx.accountId,
guildId: params.rawGuildId,
memberRoleIds: params.memberRoleIds,
peer: {
kind: params.isDirectMessage ? "direct" : "channel",
id: params.isDirectMessage ? params.userId : params.channelId,
},
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
});
}
export async function ackComponentInteraction(params: {
interaction: AgentComponentInteraction;
replyOpts: { ephemeral?: boolean };
label: string;
}) {
try {
await params.interaction.reply({
content: "✓",
...params.replyOpts,
});
} catch (err) {
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
}
}
export function resolveDiscordChannelContext(
interaction: AgentComponentInteraction,
): DiscordChannelContext {
const channel = interaction.channel;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread && channel && "parentId" in channel) {
parentId = (channel.parentId as string) ?? undefined;
if ("parent" in channel) {
const parent = (channel as { parent?: { name?: string } }).parent;
if (parent?.name) {
parentName = parent.name;
parentSlug = normalizeDiscordSlug(parentName);
}
}
}
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
}
export async function resolveComponentInteractionContext(params: {
interaction: AgentComponentInteraction;
label: string;
defer?: boolean;
}) {
const { interaction, label } = params;
const channelId = interaction.rawData.channel_id;
if (!channelId) {
logError(`${label}: missing channel_id in interaction`);
return null;
}
const user = interaction.user;
if (!user) {
logError(`${label}: missing user in interaction`);
return null;
}
const shouldDefer = params.defer !== false && "defer" in interaction;
let didDefer = false;
if (shouldDefer) {
try {
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`${label}: failed to defer interaction: ${String(err)}`);
}
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
memberRoleIds,
};
}
export async function ensureGuildComponentMemberAllowed(params: {
interaction: AgentComponentInteraction;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
channelId: string;
rawGuildId: string | undefined;
channelCtx: DiscordChannelContext;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}) {
const {
interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel,
unauthorizedReply,
} = params;
if (!rawGuildId) {
return true;
}
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
parentId: channelCtx.parentId,
parentName: channelCtx.parentName,
parentSlug: channelCtx.parentSlug,
scope: channelCtx.isThread ? "thread" : "channel",
});
const { memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender: {
id: user.id,
name: user.username,
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
},
allowNameMatching: params.allowNameMatching,
});
if (memberAllowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
try {
await interaction.reply({
content: unauthorizedReply,
...replyOpts,
});
} catch {}
return false;
}
export async function ensureComponentUserAllowed(params: {
entry: DiscordComponentEntry;
interaction: AgentComponentInteraction;
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}) {
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
"discord:",
"user:",
"pk:",
]);
if (!allowList) {
return true;
}
const match = resolveDiscordAllowListMatch({
allowList,
candidate: {
id: params.user.id,
name: params.user.username,
tag: formatDiscordUserTag(params.user),
},
allowNameMatching: params.allowNameMatching,
});
if (match.allowed) {
return true;
}
logVerbose(
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
);
try {
await params.interaction.reply({
content: params.unauthorizedReply,
...params.replyOpts,
});
} catch {}
return false;
}
export async function ensureAgentComponentInteractionAllowed(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
rawGuildId: string | undefined;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
}) {
const guildInfo = resolveDiscordGuildEntry({
guild: params.interaction.guild ?? undefined,
guildId: params.rawGuildId,
guildEntries: params.ctx.guildEntries,
});
const channelCtx = resolveDiscordChannelContext(params.interaction);
const memberAllowed = await ensureGuildComponentMemberAllowed({
interaction: params.interaction,
guildInfo,
channelId: params.channelId,
rawGuildId: params.rawGuildId,
channelCtx,
memberRoleIds: params.memberRoleIds,
user: params.user,
replyOpts: params.replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!memberAllowed) {
return null;
}
return { parentId: channelCtx.parentId };
}
export function parseAgentComponentData(data: ComponentData): { componentId: string } | null {
const raw = readParsedComponentId(data);
const decodeSafe = (value: string): string => {
if (!value.includes("%")) {
return value;
}
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
return value;
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const componentId =
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
if (!componentId) {
return null;
}
return { componentId };
}
async function ensureDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
user: DiscordUser;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}) {
const { ctx, interaction, user, componentLabel, replyOpts } = params;
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
try {
await interaction.reply({
content: "DM interactions are disabled.",
...replyOpts,
});
} catch {}
return false;
}
if (dmPolicy === "open") {
return true;
}
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
if (allowMatch.allowed) {
return true;
}
if (dmPolicy === "pairing") {
const pairingResult = await issuePairingChallenge({
channel: "discord",
senderId: user.id,
senderIdLine: `Your Discord user id: ${user.id}`,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,
},
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "discord",
id,
accountId: ctx.accountId,
meta,
}),
sendPairingReply: async (text) => {
await interaction.reply({
content: text,
...replyOpts,
});
},
});
if (!pairingResult.created) {
try {
await interaction.reply({
content: "Pairing already requested. Ask the bot owner to approve your code.",
...replyOpts,
});
} catch {}
}
return false;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {}
return false;
}
export async function resolveInteractionContextWithDmAuth(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
label: string;
componentLabel: string;
defer?: boolean;
}) {
const interactionCtx = await resolveComponentInteractionContext({
interaction: params.interaction,
label: params.label,
defer: params.defer,
});
if (!interactionCtx) {
return null;
}
if (interactionCtx.isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({
ctx: params.ctx,
interaction: params.interaction,
user: interactionCtx.user,
componentLabel: params.componentLabel,
replyOpts: interactionCtx.replyOpts,
});
if (!authorized) {
return null;
}
}
return interactionCtx;
}
export function parseDiscordComponentData(
data: ComponentData,
customId?: string,
): { componentId: string; modalId?: string } | null {
if (!data || typeof data !== "object") {
return null;
}
const rawComponentId = readParsedComponentId(data);
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
let componentId = normalizeComponentId(rawComponentId);
let modalId = normalizeComponentId(rawModalId);
if (!componentId && customId) {
const parsed = parseDiscordComponentCustomId(customId);
if (parsed) {
componentId = parsed.componentId;
modalId = parsed.modalId;
}
}
if (!componentId) {
return null;
}
return { componentId, modalId };
}
export function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
if (data && typeof data === "object") {
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
const modalId = normalizeComponentId(rawModalId);
if (modalId) {
return modalId;
}
}
if (customId) {
return parseDiscordModalCustomId(customId);
}
return null;
}
export function resolveInteractionCustomId(
interaction: AgentComponentInteraction,
): string | undefined {
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
return undefined;
}
if (!("data" in interaction.rawData)) {
return undefined;
}
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
const customId = data?.custom_id;
if (typeof customId !== "string") {
return undefined;
}
const trimmed = customId.trim();
return trimmed ? trimmed : undefined;
}
export function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
if (entry.selectType === "string") {
return mapOptionLabels(entry.options, values);
}
if (entry.selectType === "user") {
return values.map((value) => `user:${value}`);
}
if (entry.selectType === "role") {
return values.map((value) => `role:${value}`);
}
if (entry.selectType === "mentionable") {
return values.map((value) => `mentionable:${value}`);
}
if (entry.selectType === "channel") {
return values.map((value) => `channel:${value}`);
}
return values;
}
export function resolveModalFieldValues(
field: DiscordModalEntry["fields"][number],
interaction: ModalInteraction,
): string[] {
const fields = interaction.fields;
const optionLabels = field.options?.map((option) => ({
value: option.value,
label: option.label,
}));
const required = field.required === true;
try {
switch (field.type) {
case "text": {
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
return value ? [value] : [];
}
case "select":
case "checkbox":
case "radio": {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return mapOptionLabels(optionLabels, values);
}
case "role-select": {
try {
const roles = required
? fields.getRoleSelect(field.id, true)
: (fields.getRoleSelect(field.id) ?? []);
return roles.map((role) => role.name ?? role.id);
} catch {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return values;
}
}
case "user-select": {
const users = required
? fields.getUserSelect(field.id, true)
: (fields.getUserSelect(field.id) ?? []);
return users.map((user) => formatDiscordUserTag(user));
}
default:
return [];
}
} catch (err) {
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
return [];
}
}
export function formatModalSubmissionText(
entry: DiscordModalEntry,
interaction: ModalInteraction,
): string {
const lines: string[] = [`Form "${entry.title}" submitted.`];
for (const field of entry.fields) {
const values = resolveModalFieldValues(field, interaction);
if (values.length === 0) {
continue;
}
lines.push(`- ${field.label}: ${values.join(", ")}`);
}
if (lines.length === 1) {
lines.push("- (no values)");
}
return lines.join("\n");
}
export function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
const rawId =
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
? (interaction.rawData as { id?: unknown }).id
: undefined;
if (typeof rawId === "string" && rawId.trim()) {
return rawId.trim();
}
if (typeof rawId === "number" && Number.isFinite(rawId)) {
return String(rawId);
}
return `discord-interaction:${Date.now()}`;
}
export function resolveComponentCommandAuthorized(params: {
ctx: AgentComponentContext;
interactionCtx: ComponentInteractionContext;
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
allowNameMatching: boolean;
}) {
const { ctx, interactionCtx, channelConfig, guildInfo } = params;
if (interactionCtx.isDirectMessage) {
return true;
}
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
allowFrom: ctx.allowFrom,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds: interactionCtx.memberRoleIds,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false;
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
}
export { resolveDiscordGuildEntry, resolvePinnedMainDmOwnerFromAllowlist };

View File

@@ -19,7 +19,6 @@ import {
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ButtonStyle, ChannelType } from "discord-api-types/v10";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
@@ -27,8 +26,6 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
@@ -48,32 +45,51 @@ import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist,
} from "openclaw/plugin-sdk/security-runtime";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
import {
createDiscordFormModal,
formatDiscordComponentEventText,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForCarbon,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForCarbon,
type DiscordComponentEntry,
type DiscordModalEntry,
} from "../components.js";
import {
AGENT_BUTTON_KEY,
AGENT_SELECT_KEY,
ackComponentInteraction,
buildAgentButtonCustomId,
buildAgentSelectCustomId,
type AgentComponentContext,
type AgentComponentInteraction,
type AgentComponentMessageInteraction,
ensureAgentComponentInteractionAllowed,
ensureComponentUserAllowed,
ensureGuildComponentMemberAllowed,
formatModalSubmissionText,
mapSelectValues,
parseAgentComponentData,
parseDiscordComponentData,
parseDiscordModalId,
resolveAgentComponentRoute,
resolveComponentCommandAuthorized,
type ComponentInteractionContext,
resolveDiscordChannelContext,
type DiscordChannelContext,
resolveDiscordInteractionId,
resolveInteractionContextWithDmAuth,
resolveInteractionCustomId,
resolveModalFieldValues,
resolvePinnedMainDmOwnerFromAllowlist,
type DiscordUser,
} from "./agent-components-helpers.js";
import {
type DiscordGuildEntryResolved,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
import {
@@ -84,714 +100,6 @@ import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import { sendTyping } from "./typing.js";
const AGENT_BUTTON_KEY = "agent";
const AGENT_SELECT_KEY = "agentsel";
type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
type AgentComponentMessageInteraction =
| ButtonInteraction
| StringSelectMenuInteraction
| RoleSelectMenuInteraction
| UserSelectMenuInteraction
| MentionableSelectMenuInteraction
| ChannelSelectMenuInteraction;
type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
type ComponentInteractionContext = NonNullable<
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
>;
type DiscordChannelContext = {
channelName: string | undefined;
channelSlug: string;
channelType: number | undefined;
isThread: boolean;
parentId: string | undefined;
parentName: string | undefined;
parentSlug: string;
};
function resolveAgentComponentRoute(params: {
ctx: AgentComponentContext;
rawGuildId: string | undefined;
memberRoleIds: string[];
isDirectMessage: boolean;
userId: string;
channelId: string;
parentId: string | undefined;
}) {
return resolveAgentRoute({
cfg: params.ctx.cfg,
channel: "discord",
accountId: params.ctx.accountId,
guildId: params.rawGuildId,
memberRoleIds: params.memberRoleIds,
peer: {
kind: params.isDirectMessage ? "direct" : "channel",
id: params.isDirectMessage ? params.userId : params.channelId,
},
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
});
}
async function ackComponentInteraction(params: {
interaction: AgentComponentInteraction;
replyOpts: { ephemeral?: boolean };
label: string;
}) {
try {
await params.interaction.reply({
content: "✓",
...params.replyOpts,
});
} catch (err) {
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
}
}
function resolveDiscordChannelContext(
interaction: AgentComponentInteraction,
): DiscordChannelContext {
const channel = interaction.channel;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread && channel && "parentId" in channel) {
parentId = (channel.parentId as string) ?? undefined;
if ("parent" in channel) {
const parent = (channel as { parent?: { name?: string } }).parent;
if (parent?.name) {
parentName = parent.name;
parentSlug = normalizeDiscordSlug(parentName);
}
}
}
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
}
async function resolveComponentInteractionContext(params: {
interaction: AgentComponentInteraction;
label: string;
defer?: boolean;
}): Promise<{
channelId: string;
user: DiscordUser;
username: string;
userId: string;
replyOpts: { ephemeral?: boolean };
rawGuildId: string | undefined;
isDirectMessage: boolean;
memberRoleIds: string[];
} | null> {
const { interaction, label } = params;
// Use interaction's actual channel_id (trusted source from Discord)
// This prevents channel spoofing attacks
const channelId = interaction.rawData.channel_id;
if (!channelId) {
logError(`${label}: missing channel_id in interaction`);
return null;
}
const user = interaction.user;
if (!user) {
logError(`${label}: missing user in interaction`);
return null;
}
const shouldDefer = params.defer !== false && "defer" in interaction;
let didDefer = false;
// Defer immediately to satisfy Discord's 3-second interaction ACK requirement.
// We use an ephemeral deferred reply so subsequent interaction.reply() calls
// can safely edit the original deferred response.
if (shouldDefer) {
try {
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`${label}: failed to defer interaction: ${String(err)}`);
}
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
// P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null
// when guild is not cached even though guild_id is present in rawData
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
memberRoleIds,
};
}
async function ensureGuildComponentMemberAllowed(params: {
interaction: AgentComponentInteraction;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
channelId: string;
rawGuildId: string | undefined;
channelCtx: DiscordChannelContext;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}): Promise<boolean> {
const {
interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel,
unauthorizedReply,
} = params;
if (!rawGuildId) {
return true;
}
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
parentId: channelCtx.parentId,
parentName: channelCtx.parentName,
parentSlug: channelCtx.parentSlug,
scope: channelCtx.isThread ? "thread" : "channel",
});
const { memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender: {
id: user.id,
name: user.username,
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
},
allowNameMatching: params.allowNameMatching,
});
if (memberAllowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
try {
await interaction.reply({
content: unauthorizedReply,
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function ensureComponentUserAllowed(params: {
entry: DiscordComponentEntry;
interaction: AgentComponentInteraction;
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}): Promise<boolean> {
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
"discord:",
"user:",
"pk:",
]);
if (!allowList) {
return true;
}
const match = resolveDiscordAllowListMatch({
allowList,
candidate: {
id: params.user.id,
name: params.user.username,
tag: formatDiscordUserTag(params.user),
},
allowNameMatching: params.allowNameMatching,
});
if (match.allowed) {
return true;
}
logVerbose(
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
);
try {
await params.interaction.reply({
content: params.unauthorizedReply,
...params.replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function ensureAgentComponentInteractionAllowed(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
rawGuildId: string | undefined;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
}): Promise<{ parentId: string | undefined } | null> {
const guildInfo = resolveDiscordGuildEntry({
guild: params.interaction.guild ?? undefined,
guildId: params.rawGuildId,
guildEntries: params.ctx.guildEntries,
});
const channelCtx = resolveDiscordChannelContext(params.interaction);
const memberAllowed = await ensureGuildComponentMemberAllowed({
interaction: params.interaction,
guildInfo,
channelId: params.channelId,
rawGuildId: params.rawGuildId,
channelCtx,
memberRoleIds: params.memberRoleIds,
user: params.user,
replyOpts: params.replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!memberAllowed) {
return null;
}
return { parentId: channelCtx.parentId };
}
export type AgentComponentContext = {
cfg: OpenClawConfig;
accountId: string;
discordConfig?: DiscordAccountConfig;
runtime?: RuntimeEnv;
token?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
/** DM allowlist (from allowFrom config; legacy: dm.allowFrom) */
allowFrom?: string[];
/** DM policy (default: "pairing") */
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
};
/**
* Build agent button custom ID: agent:componentId=<id>
* The channelId is NOT embedded in customId - we use interaction.rawData.channel_id instead
* to prevent channel spoofing attacks.
*
* Carbon's customIdParser parses "key:arg1=value1;arg2=value2" into { arg1: value1, arg2: value2 }
*/
export function buildAgentButtonCustomId(componentId: string): string {
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
/**
* Build agent select menu custom ID: agentsel:componentId=<id>
*/
export function buildAgentSelectCustomId(componentId: string): string {
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
/**
* Parse agent component data from Carbon's parsed ComponentData
* Supports both legacy { componentId } and Components v2 { cid } payloads.
*/
function readParsedComponentId(data: ComponentData): unknown {
if (!data || typeof data !== "object") {
return undefined;
}
return "cid" in data
? (data as Record<string, unknown>).cid
: (data as Record<string, unknown>).componentId;
}
function parseAgentComponentData(data: ComponentData): {
componentId: string;
} | null {
const raw = readParsedComponentId(data);
const decodeSafe = (value: string): string => {
// `cid` values may be raw (not URI-encoded). Guard against malformed % sequences.
// Only attempt decoding when it looks like it contains percent-encoding.
if (!value.includes("%")) {
return value;
}
// If it has a % but not a valid %XX sequence, skip decode.
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
return value;
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const componentId =
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
if (!componentId) {
return null;
}
return { componentId };
}
function formatUsername(user: { username: string; discriminator?: string | null }): string {
if (user.discriminator && user.discriminator !== "0") {
return `${user.username}#${user.discriminator}`;
}
return user.username;
}
/**
* Check if a channel type is a thread type
*/
function isThreadChannelType(channelType: number | undefined): boolean {
return (
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread
);
}
async function ensureDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
user: DiscordUser;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}): Promise<boolean> {
const { ctx, interaction, user, componentLabel, replyOpts } = params;
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
try {
await interaction.reply({
content: "DM interactions are disabled.",
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
if (dmPolicy === "open") {
return true;
}
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
if (allowMatch.allowed) {
return true;
}
if (dmPolicy === "pairing") {
const pairingResult = await issuePairingChallenge({
channel: "discord",
senderId: user.id,
senderIdLine: `Your Discord user id: ${user.id}`,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,
},
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "discord",
id,
accountId: ctx.accountId,
meta,
}),
sendPairingReply: async (text) => {
await interaction.reply({
content: text,
...replyOpts,
});
},
});
if (!pairingResult.created) {
try {
await interaction.reply({
content: "Pairing already requested. Ask the bot owner to approve your code.",
...replyOpts,
});
} catch {
// Interaction may have expired
}
}
return false;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function resolveInteractionContextWithDmAuth(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
label: string;
componentLabel: string;
defer?: boolean;
}): Promise<ComponentInteractionContext | null> {
const interactionCtx = await resolveComponentInteractionContext({
interaction: params.interaction,
label: params.label,
defer: params.defer,
});
if (!interactionCtx) {
return null;
}
if (interactionCtx.isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({
ctx: params.ctx,
interaction: params.interaction,
user: interactionCtx.user,
componentLabel: params.componentLabel,
replyOpts: interactionCtx.replyOpts,
});
if (!authorized) {
return null;
}
}
return interactionCtx;
}
function normalizeComponentId(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return undefined;
}
function parseDiscordComponentData(
data: ComponentData,
customId?: string,
): { componentId: string; modalId?: string } | null {
if (!data || typeof data !== "object") {
return null;
}
const rawComponentId = readParsedComponentId(data);
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
let componentId = normalizeComponentId(rawComponentId);
let modalId = normalizeComponentId(rawModalId);
if (!componentId && customId) {
const parsed = parseDiscordComponentCustomId(customId);
if (parsed) {
componentId = parsed.componentId;
modalId = parsed.modalId;
}
}
if (!componentId) {
return null;
}
return { componentId, modalId };
}
function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
if (data && typeof data === "object") {
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
const modalId = normalizeComponentId(rawModalId);
if (modalId) {
return modalId;
}
}
if (customId) {
return parseDiscordModalCustomId(customId);
}
return null;
}
function resolveInteractionCustomId(interaction: AgentComponentInteraction): string | undefined {
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
return undefined;
}
if (!("data" in interaction.rawData)) {
return undefined;
}
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
const customId = data?.custom_id;
if (typeof customId !== "string") {
return undefined;
}
const trimmed = customId.trim();
return trimmed ? trimmed : undefined;
}
function mapOptionLabels(
options: Array<{ value: string; label: string }> | undefined,
values: string[],
) {
if (!options || options.length === 0) {
return values;
}
const map = new Map(options.map((option) => [option.value, option.label]));
return values.map((value) => map.get(value) ?? value);
}
function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
if (entry.selectType === "string") {
return mapOptionLabels(entry.options, values);
}
if (entry.selectType === "user") {
return values.map((value) => `user:${value}`);
}
if (entry.selectType === "role") {
return values.map((value) => `role:${value}`);
}
if (entry.selectType === "mentionable") {
return values.map((value) => `mentionable:${value}`);
}
if (entry.selectType === "channel") {
return values.map((value) => `channel:${value}`);
}
return values;
}
function resolveModalFieldValues(
field: DiscordModalEntry["fields"][number],
interaction: ModalInteraction,
): string[] {
const fields = interaction.fields;
const optionLabels = field.options?.map((option) => ({
value: option.value,
label: option.label,
}));
const required = field.required === true;
try {
switch (field.type) {
case "text": {
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
return value ? [value] : [];
}
case "select":
case "checkbox":
case "radio": {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return mapOptionLabels(optionLabels, values);
}
case "role-select": {
try {
const roles = required
? fields.getRoleSelect(field.id, true)
: (fields.getRoleSelect(field.id) ?? []);
return roles.map((role) => role.name ?? role.id);
} catch {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return values;
}
}
case "user-select": {
const users = required
? fields.getUserSelect(field.id, true)
: (fields.getUserSelect(field.id) ?? []);
return users.map((user) => formatDiscordUserTag(user));
}
default:
return [];
}
} catch (err) {
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
return [];
}
}
function formatModalSubmissionText(
entry: DiscordModalEntry,
interaction: ModalInteraction,
): string {
const lines: string[] = [`Form "${entry.title}" submitted.`];
for (const field of entry.fields) {
const values = resolveModalFieldValues(field, interaction);
if (values.length === 0) {
continue;
}
lines.push(`- ${field.label}: ${values.join(", ")}`);
}
if (lines.length === 1) {
lines.push("- (no values)");
}
return lines.join("\n");
}
function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
const rawId =
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
? (interaction.rawData as { id?: unknown }).id
: undefined;
if (typeof rawId === "string" && rawId.trim()) {
return rawId.trim();
}
if (typeof rawId === "number" && Number.isFinite(rawId)) {
return String(rawId);
}
return `discord-interaction:${Date.now()}`;
}
async function dispatchPluginDiscordInteractiveEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@@ -931,54 +239,6 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
return "unmatched";
}
function resolveComponentCommandAuthorized(params: {
ctx: AgentComponentContext;
interactionCtx: ComponentInteractionContext;
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
allowNameMatching: boolean;
}): boolean {
const { ctx, interactionCtx, channelConfig, guildInfo } = params;
if (interactionCtx.isDirectMessage) {
return true;
}
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
allowFrom: ctx.allowFrom,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds: interactionCtx.memberRoleIds,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false;
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
}
async function dispatchDiscordComponentEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@@ -1045,7 +305,7 @@ async function dispatchDiscordComponentEvent(params: {
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: ctx.cfg.session?.dmScope,
allowFrom: channelConfig?.users ?? guildInfo?.users,
normalizeEntry: (entry) => {
normalizeEntry: (entry: string) => {
const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]);
const candidate = normalized?.ids.values().next().value;
return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined;

View File

@@ -7,11 +7,11 @@ import type {
import type { Client } from "@buape/carbon";
import { ChannelType } from "discord-api-types/v10";
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js";
import { buildPluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
@@ -50,7 +50,6 @@ const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const dispatchReplyMock = vi.hoisted(() => vi.fn());
const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
const resolveStorePathMock = vi.hoisted(() => vi.fn());
@@ -59,37 +58,20 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
let lastDispatchCtx: Record<string, unknown> | undefined;
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../src/security/dm-policy-shared.js")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
};
});
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
}));
vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args),
}));
vi.mock("../../../../src/channels/session.js", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
}));
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/pairing/pairing-store.js")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
@@ -105,6 +87,42 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal
};
});
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../../../src/auto-reply/reply/provider-dispatcher.js")
>();
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
};
});
vi.mock("../../../../src/channels/session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/channels/session.js")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
};
});
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
return {
@@ -287,12 +305,18 @@ describe("discord component interactions", () => {
const createComponentButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
const rest = {
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
post: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
};
const interaction = {
rawData: { channel_id: "dm-channel", id: "interaction-1" },
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
customId: "occomp:cid=btn_1",
message: { id: "msg-1" },
client: { rest: {} },
client: { rest },
defer,
reply,
...overrides,
@@ -303,6 +327,12 @@ describe("discord component interactions", () => {
const createModalInteraction = (overrides: Partial<ModalInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const acknowledge = vi.fn().mockResolvedValue(undefined);
const rest = {
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
post: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
};
const fields = {
getText: (key: string) => (key === "fld_1" ? "Casey" : undefined),
getStringSelect: (_key: string) => undefined,
@@ -316,7 +346,7 @@ describe("discord component interactions", () => {
fields,
acknowledge,
reply,
client: { rest: {} },
client: { rest },
...overrides,
} as unknown as ModalInteraction;
return { interaction, acknowledge, reply };
@@ -363,7 +393,6 @@ describe("discord component interactions", () => {
lastDispatchCtx = params.ctx;
await params.dispatcherOptions.deliver({ text: "ok" });
});
deliverDiscordReplyMock.mockClear();
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
@@ -415,8 +444,6 @@ describe("discord component interactions", () => {
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1");
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
@@ -482,8 +509,6 @@ describe("discord component interactions", () => {
expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.');
expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey");
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
import type { ChatType } from "../../../../src/channels/chat-type.js";
import { setDefaultChannelPluginRegistryForTests } from "../../../../src/commands/channel-test-helpers.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
@@ -12,32 +12,26 @@ import {
} from "./native-command.test-helpers.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type ResolveConfiguredBindingRouteFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
type EnsureConfiguredBindingRouteReadyFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredBindingRouteFn>((params) => ({
bindingResolution: null,
route: params.route,
})),
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
ok: true,
})),
}));
);
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
ensureConfiguredBindingRouteReadyMock(
...(args as Parameters<EnsureConfiguredBindingRouteReadyFn>),
),
};
});
import { createDiscordNativeCommand } from "./native-command.js";
function createInteraction(params?: {
channelType?: ChannelType;
channelId?: string;
@@ -66,7 +60,12 @@ function createConfig(): OpenClawConfig {
} as OpenClawConfig;
}
function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
async function loadCreateDiscordNativeCommand() {
return (await import("./native-command.js")).createDiscordNativeCommand;
}
async function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
const createDiscordNativeCommand = await loadCreateDiscordNativeCommand();
return createDiscordNativeCommand({
command: commandSpec,
cfg,
@@ -78,7 +77,8 @@ function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec
});
}
function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) {
async function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) {
const createDiscordNativeCommand = await loadCreateDiscordNativeCommand();
return createDiscordNativeCommand({
command: {
name: params.name,
@@ -119,7 +119,7 @@ async function expectPairCommandReply(params: {
commandName: string;
interaction: MockCommandInteraction;
}) {
const command = createPluginCommand({
const command = await createPluginCommand({
cfg: params.cfg,
name: params.commandName,
});
@@ -143,150 +143,14 @@ async function expectPairCommandReply(params: {
);
}
function createStatusCommand(cfg: OpenClawConfig) {
return createNativeCommand(cfg, {
async function createStatusCommand(cfg: OpenClawConfig) {
return await createNativeCommand(cfg, {
name: "status",
description: "Status",
acceptsArgs: false,
});
}
function resolveConversationFromParams(params: Parameters<ResolveConfiguredBindingRouteFn>[0]) {
if ("conversation" in params) {
return params.conversation;
}
return {
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
};
}
function createConfiguredBindingResolution(params: {
conversation: ReturnType<typeof resolveConversationFromParams>;
boundSessionKey: string;
}) {
const peerKind: ChatType = params.conversation.conversationId.startsWith("dm-")
? "direct"
: "channel";
const configuredBinding = {
spec: {
channel: "discord" as const,
accountId: params.conversation.accountId,
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
agentId: "codex",
mode: "persistent" as const,
},
record: {
bindingId: `config:acp:discord:${params.conversation.accountId}:${params.conversation.conversationId}`,
targetSessionKey: params.boundSessionKey,
targetKind: "session" as const,
conversation: params.conversation,
status: "active" as const,
boundAt: 0,
},
};
return {
conversation: params.conversation,
compiledBinding: {
channel: "discord" as const,
binding: {
type: "acp" as const,
agentId: "codex",
match: {
channel: "discord",
accountId: params.conversation.accountId,
peer: {
kind: peerKind,
id: params.conversation.conversationId,
},
},
acp: {
mode: "persistent" as const,
},
},
bindingConversationId: params.conversation.conversationId,
target: {
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
},
agentId: "codex",
provider: {
compileConfiguredBinding: () => ({
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
}),
matchInboundConversation: () => ({
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
}),
},
targetFactory: {
driverId: "acp" as const,
materialize: () => ({
record: configuredBinding.record,
statefulTarget: {
kind: "stateful" as const,
driverId: "acp",
sessionKey: params.boundSessionKey,
agentId: "codex",
},
}),
},
},
match: {
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
},
record: configuredBinding.record,
statefulTarget: {
kind: "stateful" as const,
driverId: "acp",
sessionKey: params.boundSessionKey,
agentId: "codex",
},
};
}
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => {
const conversation = resolveConversationFromParams(params);
const bindingResolution = createConfiguredBindingResolution({
conversation: {
...conversation,
conversationId: channelId,
},
boundSessionKey,
});
return {
bindingResolution,
boundSessionKey,
boundAgentId: "codex",
route: {
...params.route,
agentId: "codex",
sessionKey: boundSessionKey,
matchedBy: "binding.channel",
},
};
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
});
}
function createDispatchSpy() {
return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({
counts: {
@@ -299,26 +163,23 @@ function createDispatchSpy() {
function expectBoundSessionDispatch(
dispatchSpy: ReturnType<typeof createDispatchSpy>,
boundSessionKey: string,
expectedPattern: RegExp,
) {
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
expect(dispatchCall.ctx?.SessionKey).toMatch(expectedPattern);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(expectedPattern);
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
}
async function expectBoundStatusCommandDispatch(params: {
cfg: OpenClawConfig;
interaction: MockCommandInteraction;
channelId: string;
boundSessionKey: string;
expectedPattern: RegExp;
}) {
const command = createStatusCommand(params.cfg);
setConfiguredBinding(params.channelId, params.boundSessionKey);
const command = await createStatusCommand(params.cfg);
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
@@ -327,20 +188,16 @@ async function expectBoundStatusCommandDispatch(params: {
params.interaction as unknown,
);
expectBoundSessionDispatch(dispatchSpy, params.boundSessionKey);
expectBoundSessionDispatch(dispatchSpy, params.expectedPattern);
}
describe("Discord native plugin command dispatch", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
clearPluginCommands();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
bindingResolution: null,
route: params.route,
}));
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
setDefaultChannelPluginRegistryForTests();
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: true,
});
});
@@ -397,15 +254,7 @@ describe("Discord native plugin command dispatch", () => {
description: "Pair",
acceptsArgs: true,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const command = await createNativeCommand(cfg, commandSpec);
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId: "234567890123456789",
@@ -449,15 +298,7 @@ describe("Discord native plugin command dispatch", () => {
description: "List cron jobs",
acceptsArgs: false,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const command = await createNativeCommand(cfg, commandSpec);
const interaction = createInteraction();
const pluginMatch = {
command: {
@@ -492,11 +333,21 @@ describe("Discord native plugin command dispatch", () => {
it("routes native slash commands through configured ACP Discord channel bindings", async () => {
const guildId = "1459246755253325866";
const channelId = "1478836151241412759";
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
const cfg = {
commands: {
useAccessGroups: false,
},
channels: {
discord: {
guilds: {
[guildId]: {
channels: {
[channelId]: { allow: true, requireMention: false },
},
},
},
},
},
bindings: [
{
type: "acp",
@@ -522,8 +373,7 @@ describe("Discord native plugin command dispatch", () => {
await expectBoundStatusCommandDispatch({
cfg,
interaction,
channelId,
boundSessionKey,
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
});
});
@@ -557,7 +407,7 @@ describe("Discord native plugin command dispatch", () => {
},
},
} as OpenClawConfig;
const command = createStatusCommand(cfg);
const command = await createStatusCommand(cfg);
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId,
@@ -578,13 +428,11 @@ describe("Discord native plugin command dispatch", () => {
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
"agent:qwen:discord:channel:1478836151241412759",
);
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
});
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
const channelId = "dm-1";
const boundSessionKey = "agent:codex:acp:binding:discord:default:dmfeedface";
const cfg = {
commands: {
useAccessGroups: false,
@@ -617,15 +465,13 @@ describe("Discord native plugin command dispatch", () => {
await expectBoundStatusCommandDispatch({
cfg,
interaction,
channelId,
boundSessionKey,
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
});
});
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
const guildId = "1459246755253325866";
const channelId = "1479098716916023408";
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
const cfg = {
commands: {
useAccessGroups: false,
@@ -651,14 +497,13 @@ describe("Discord native plugin command dispatch", () => {
guildId,
guildName: "Ops",
});
const command = createNativeCommand(cfg, {
const command = await createNativeCommand(cfg, {
name: "new",
description: "Start a new session.",
acceptsArgs: true,
});
setConfiguredBinding(channelId, boundSessionKey);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,
error: "acpx exited with code 1",
});
@@ -671,10 +516,11 @@ describe("Discord native plugin command dispatch", () => {
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(dispatchCall.ctx?.SessionKey).toMatch(/^agent:codex:acp:binding:discord:default:/);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(
/^agent:codex:acp:binding:discord:default:/,
);
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
expect(interaction.reply).not.toHaveBeenCalledWith(
expect.objectContaining({
content: "Configured ACP binding is unavailable right now. Please try again.",

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ import {
baseRuntime,
getFirstDiscordMessageHandlerParams,
getProviderMonitorTestMocks,
mockResolvedDiscordAccountConfig,
resetDiscordProviderMonitorMocks,
} from "../../../../test/helpers/extensions/discord-provider.test-support.js";
@@ -37,6 +36,21 @@ const {
voiceRuntimeModuleLoadedMock,
} = getProviderMonitorTestMocks();
function createConfigWithDiscordAccount(overrides: Record<string, unknown> = {}): OpenClawConfig {
return {
channels: {
discord: {
accounts: {
default: {
token: "MTIz.abc.def",
...overrides,
},
},
},
},
} as OpenClawConfig;
}
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
"openclaw/plugin-sdk/plugin-runtime",
@@ -90,7 +104,18 @@ describe("monitorDiscordProvider", () => {
};
beforeEach(() => {
vi.resetModules();
resetDiscordProviderMonitorMocks();
vi.doMock("../accounts.js", () => ({
resolveDiscordAccount: (...args: Parameters<typeof resolveDiscordAccountMock>) =>
resolveDiscordAccountMock(...args),
}));
vi.doMock("../probe.js", () => ({
fetchDiscordApplicationId: async () => "app-1",
}));
vi.doMock("../token.js", () => ({
normalizeDiscordToken: (value?: string) => value,
}));
});
it("stops thread bindings when startup fails before lifecycle begins", async () => {
@@ -139,7 +164,7 @@ describe("monitorDiscordProvider", () => {
it("loads the Discord voice runtime only when voice is enabled", async () => {
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "cfg-token",
token: "MTIz.abc.def",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: true },
@@ -356,11 +381,18 @@ describe("monitorDiscordProvider", () => {
});
it("forwards custom eventQueue config from discord config to Carbon Client", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
mockResolvedDiscordAccountConfig({
eventQueue: { listenerTimeout: 300_000 },
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "MTIz.abc.def",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
eventQueue: { listenerTimeout: 300_000 },
},
});
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
@@ -374,12 +406,10 @@ describe("monitorDiscordProvider", () => {
it("does not reuse eventQueue.listenerTimeout as the queued inbound worker timeout", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
mockResolvedDiscordAccountConfig({
eventQueue: { listenerTimeout: 50_000 },
});
await monitorDiscordProvider({
config: baseConfig(),
config: createConfigWithDiscordAccount({
eventQueue: { listenerTimeout: 50_000 },
}),
runtime: baseRuntime(),
});
@@ -392,11 +422,18 @@ describe("monitorDiscordProvider", () => {
});
it("forwards inbound worker timeout config to the Discord message handler", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
mockResolvedDiscordAccountConfig({
inboundWorker: { runTimeoutMs: 300_000 },
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "MTIz.abc.def",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
inboundWorker: { runTimeoutMs: 300_000 },
},
});
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),

View File

@@ -0,0 +1,55 @@
export {
buildComputedAccountStatusSnapshot,
buildTokenChannelStatusSummary,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
} from "openclaw/plugin-sdk/discord";
export {
buildChannelConfigSchema,
getChatChannelMeta,
jsonResult,
readNumberParam,
readStringArrayParam,
readStringParam,
type ActionGate,
type ChannelPlugin,
type OpenClawConfig,
} from "openclaw/plugin-sdk/discord-core";
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./directory-config.js";
export {
createHybridChannelConfigAdapter,
createScopedChannelConfigAdapter,
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createTopLevelChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
export {
createAccountActionGate,
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
export type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-runtime";
export {
assertMediaNotDataUrl,
parseAvailableTags,
readReactionParams,
resolvePollMaxSelections,
withNormalizedTimestamp,
} from "openclaw/plugin-sdk/discord-core";
export type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/config-runtime";

View File

@@ -1,12 +1,12 @@
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
import {
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createLegacyCompatChannelDmPolicy,
DEFAULT_ACCOUNT_ID,
createEnvPatchedAccountSetupAdapter,
noteChannelLookupFailure,
noteChannelLookupSummary,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
setLegacyChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
@@ -88,21 +88,11 @@ export function createDiscordSetupWizardBase(handlers: {
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>;
}) {
const discordDmPolicy: ChannelSetupDmPolicy = {
const discordDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({
label: "Discord",
channel,
policyKey: "channels.discord.dmPolicy",
allowFromKey: "channels.discord.allowFrom",
getCurrent: (cfg: OpenClawConfig) =>
cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing",
setPolicy: (cfg: OpenClawConfig, policy) =>
setLegacyChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: handlers.promptAllowFrom,
};
});
return {
channel,
@@ -145,7 +135,8 @@ export function createDiscordSetupWizardBase(handlers: {
},
},
],
groupAccess: {
groupAccess: createAccountScopedGroupAccessSection({
channel,
label: "Discord channels",
placeholder: "My Server/#general, guildId/channelId, #support",
currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
@@ -164,57 +155,8 @@ export function createDiscordSetupWizardBase(handlers: {
),
updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds),
setPolicy: ({
cfg,
accountId,
policy,
}: {
cfg: OpenClawConfig;
accountId: string;
policy: "open" | "allowlist" | "disabled";
}) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { groupPolicy: policy },
}),
resolveAllowlist: async ({
cfg,
accountId,
credentialValues,
entries,
prompter,
}: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { token?: string };
entries: string[];
prompter: { note: (message: string, title?: string) => Promise<void> };
}) => {
try {
return await handlers.resolveGroupAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
});
} catch (error) {
await noteChannelLookupFailure({
prompter,
label: "Discord channels",
error,
});
await noteChannelLookupSummary({
prompter,
label: "Discord channels",
resolvedSections: [],
unresolved: entries,
});
return entries.map((input) => ({ input, resolved: false }));
}
},
resolveAllowlist: handlers.resolveGroupAllowlist,
fallbackResolved: (entries) => entries.map((input) => ({ input, resolved: false })),
applyAllowlist: ({
cfg,
accountId,
@@ -224,8 +166,9 @@ export function createDiscordSetupWizardBase(handlers: {
accountId: string;
resolved: unknown;
}) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never),
},
allowFrom: {
}),
allowFrom: createAccountScopedAllowFromSection({
channel,
credentialInputKey: "token",
helpTitle: "Discord allowlist",
helpLines: [
@@ -242,42 +185,15 @@ export function createDiscordSetupWizardBase(handlers: {
invalidWithoutCredentialNote:
"Bot token missing; use numeric user ids (or mention form) only.",
parseId: parseDiscordAllowFromId,
resolveEntries: async ({
cfg,
accountId,
credentialValues,
entries,
}: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { token?: string };
entries: string[];
}) => await handlers.resolveAllowFromEntries({ cfg, accountId, credentialValues, entries }),
apply: async ({
cfg,
accountId,
allowFrom,
}: {
cfg: OpenClawConfig;
accountId: string;
allowFrom: string[];
}) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { dmPolicy: "allowlist", allowFrom },
}),
},
resolveEntries: handlers.resolveAllowFromEntries,
}),
dmPolicy: discordDmPolicy,
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}
export function createDiscordSetupWizardProxy(
loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>,
) {
export function createDiscordSetupWizardProxy(loadWizard: () => Promise<ChannelSetupWizard>) {
return createAllowlistSetupWizardProxy({
loadWizard: async () => (await loadWizard()).discordSetupWizard,
loadWizard,
createBase: createDiscordSetupWizardBase,
fallbackResolvedGroupAllowlist: (entries) =>
entries.map((input) => ({ input, resolved: false })),

View File

@@ -1,17 +1,13 @@
import {
resolveEntriesWithOptionalToken,
type OpenClawConfig,
promptLegacyChannelAllowFrom,
resolveSetupAccountId,
promptLegacyChannelAllowFromForAccount,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import {
resolveDiscordChannelAllowlist,
type DiscordChannelResolution,
} from "./resolve-channels.js";
import { resolveDiscordChannelAllowlist } from "./resolve-channels.js";
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
import {
createDiscordSetupWizardBase,
@@ -23,22 +19,26 @@ import {
const channel = "discord" as const;
async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) {
if (!params.token?.trim()) {
return params.entries.map((input) => ({
return await resolveEntriesWithOptionalToken({
token: params.token,
entries: params.entries,
buildWithoutToken: (input) => ({
input,
resolved: false,
id: null,
}));
}
const resolved = await resolveDiscordUserAllowlist({
token: params.token,
entries: params.entries,
}),
resolveEntries: async ({ token, entries }) =>
(
await resolveDiscordUserAllowlist({
token,
entries,
})
).map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
})),
});
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
}));
}
async function promptDiscordAllowFrom(params: {
@@ -46,17 +46,15 @@ async function promptDiscordAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
});
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
return promptLegacyChannelAllowFrom({
return await promptLegacyChannelAllowFromForAccount({
cfg: params.cfg,
channel,
prompter: params.prompter,
existing: resolved.config.allowFrom ?? resolved.config.dm?.allowFrom ?? [],
token: resolved.token,
accountId: params.accountId,
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
resolveExisting: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom ?? [],
resolveToken: (account) => account.token,
noteTitle: "Discord allowlist",
noteLines: [
"Allowlist Discord DMs by username (we resolve to user ids).",
@@ -71,11 +69,17 @@ async function promptDiscordAllowFrom(params: {
placeholder: "@alice, 123456789012345678",
parseId: parseDiscordAllowFromId,
invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.",
resolveEntries: ({ token, entries }) =>
resolveDiscordUserAllowlist({
token,
entries,
}),
resolveEntries: async ({ token, entries }) =>
(
await resolveDiscordUserAllowlist({
token,
entries,
})
).map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
})),
});
}
@@ -85,18 +89,20 @@ async function resolveDiscordGroupAllowlist(params: {
credentialValues: { token?: string };
entries: string[];
}) {
const token =
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token ||
(typeof params.credentialValues.token === "string" ? params.credentialValues.token : "");
if (!token || params.entries.length === 0) {
return params.entries.map((input) => ({
return await resolveEntriesWithOptionalToken({
token:
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token ||
(typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""),
entries: params.entries,
buildWithoutToken: (input) => ({
input,
resolved: false,
}));
}
return await resolveDiscordChannelAllowlist({
token,
entries: params.entries,
}),
resolveEntries: async ({ token, entries }) =>
await resolveDiscordChannelAllowlist({
token,
entries,
}),
});
}

View File

@@ -1,14 +1,5 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildChannelConfigSchema,
DiscordConfigSchema,
getChatChannelMeta,
type ChannelPlugin,
} from "openclaw/plugin-sdk/discord-core";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
listDiscordAccountIds,
@@ -16,6 +7,13 @@ import {
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import {
createScopedChannelConfigAdapter,
buildChannelConfigSchema,
DiscordConfigSchema,
getChatChannelMeta,
type ChannelPlugin,
} from "./runtime-api.js";
import { createDiscordSetupWizardProxy } from "./setup-core.js";
export const DISCORD_CHANNEL = "discord" as const;
@@ -24,24 +22,20 @@ async function loadDiscordChannelRuntime() {
return await import("./channel.runtime.js");
}
export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({
discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard,
}));
export const discordSetupWizard = createDiscordSetupWizardProxy(
async () => (await loadDiscordChannelRuntime()).discordSetupWizard,
);
export const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
export const discordConfigBase = createScopedChannelConfigBase<ResolvedDiscordAccount>({
export const discordConfigAdapter = createScopedChannelConfigAdapter<ResolvedDiscordAccount>({
sectionKey: DISCORD_CHANNEL,
listAccountIds: listDiscordAccountIds,
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultDiscordAccountId,
clearBaseFields: ["token", "name"],
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
export function createDiscordPluginBase(params: {
@@ -58,12 +52,10 @@ export function createDiscordPluginBase(params: {
| "config"
| "setup"
> {
return {
return createChannelPluginBase({
id: DISCORD_CHANNEL,
meta: {
...getChatChannelMeta(DISCORD_CHANNEL),
},
setupWizard: discordSetupWizard,
meta: { ...getChatChannelMeta(DISCORD_CHANNEL) },
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
@@ -78,7 +70,7 @@ export function createDiscordPluginBase(params: {
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
...discordConfigBase,
...discordConfigAdapter,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
@@ -87,8 +79,18 @@ export function createDiscordPluginBase(params: {
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
...discordConfigAccessors,
},
setup: params.setup,
};
}) as Pick<
ChannelPlugin<ResolvedDiscordAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "streaming"
| "reload"
| "configSchema"
| "config"
| "setup"
>;
}

44
extensions/fal/index.ts Normal file
View File

@@ -0,0 +1,44 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { buildFalImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
const PROVIDER_ID = "fal";
export default definePluginEntry({
id: PROVIDER_ID,
name: "fal Provider",
description: "Bundled fal image generation provider",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "fal",
docsPath: "/providers/models",
envVars: ["FAL_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "fal API key",
hint: "Image generation API key",
optionKey: "falApiKey",
flagName: "--fal-api-key",
envVar: "FAL_KEY",
promptMessage: "Enter fal API key",
defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF,
expectedProviders: ["fal"],
applyConfig: (cfg) => applyFalConfig(cfg),
wizard: {
choiceId: "fal-api-key",
choiceLabel: "fal API key",
choiceHint: "Image generation API key",
groupId: "fal",
groupLabel: "fal",
groupHint: "Image generation",
},
}),
],
});
api.registerImageGenerationProvider(buildFalImageGenerationProvider());
},
});

21
extensions/fal/onboard.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
export const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev";
export function applyFalConfig(cfg: OpenClawConfig): OpenClawConfig {
if (cfg.agents?.defaults?.imageGenerationModel) {
return cfg;
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
imageGenerationModel: {
primary: FAL_DEFAULT_IMAGE_MODEL_REF,
},
},
},
};
}

View File

@@ -0,0 +1,27 @@
{
"id": "fal",
"providers": ["fal"],
"providerAuthEnvVars": {
"fal": ["FAL_KEY"]
},
"providerAuthChoices": [
{
"provider": "fal",
"method": "api-key",
"choiceId": "fal-api-key",
"choiceLabel": "fal API key",
"groupId": "fal",
"groupLabel": "fal",
"groupHint": "Image generation",
"optionKey": "falApiKey",
"cliFlag": "--fal-api-key",
"cliOption": "--fal-api-key <key>",
"cliDescription": "fal API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

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