Compare commits

..

133 Commits

Author SHA1 Message Date
George Zhang
1358cba962 fix(cache): compact newest tool results first to preserve prompt cache prefix (#58036) Thanks @bcherny 2026-04-03 17:18:44 -07:00
Boris Cherny
6aa591ba56 fix(cache): compact newest tool results first to preserve prompt cache prefix
compactExistingToolResultsInPlace iterated front-to-back, replacing the
oldest tool results with placeholders when context exceeded 75%. This
rewrote messages[k] for small k, invalidating the provider prompt cache
from that point onward on every subsequent turn.

Reverse the loop to compact newest-first. The cached prefix stays intact;
the tradeoff is the model loses recent tool output instead of old, which
is acceptable since this guard only fires as an emergency measure past
the 75% threshold.
2026-04-03 17:15:52 -07:00
Peter Steinberger
d01cb5ecc6 docs: expand model fallback guide 2026-04-04 09:13:43 +09:00
Peter Steinberger
5bea93fd63 fix: restore gateway watch boot path 2026-04-04 01:10:49 +01:00
Peter Steinberger
fe72474153 fix: persist fallback overrides safely 2026-04-04 09:00:16 +09:00
Peter Steinberger
411282c36d docs(changelog): note Telegram picker fix (#60384) (thanks @sfuminya) 2026-04-04 08:58:50 +09:00
fumin
43272d27f8 fix(telegram): compare full provider/model in models picker 2026-04-04 08:58:50 +09:00
Vincent Koc
bb4e54ccf7 fix(ci): restore plugin sdk boundary seams 2026-04-04 08:51:57 +09:00
Vincent Koc
df83374a54 docs(changelog): summarize provider transport rollout 2026-04-04 08:34:23 +09:00
Peter Steinberger
236a9003b6 test(ci): fix logs cli gateway mock typing 2026-04-04 00:28:25 +01:00
Peter Steinberger
3a3fdf1920 fix(ci): restore plugin contract surfaces 2026-04-04 00:24:57 +01:00
Ted Li
ff62705206 fix(whatsapp): reset watchdog timeout after reconnect (#60007)
Merged via squash.

Prepared head SHA: 64223e5dd4
Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-03 20:23:36 -03:00
Peter Steinberger
306fe841f5 fix(cli): add local logs fallback 2026-04-04 08:17:11 +09:00
Peter Steinberger
de2eaccfce fix: restore discord startup logging and boundary bootstrap 2026-04-04 00:16:10 +01:00
Peter Steinberger
0f18e44538 test: trim onboarding helper partial mock 2026-04-04 00:13:45 +01:00
Peter Steinberger
d02fc365b4 test(plugins): drop stale core test files 2026-04-04 00:11:54 +01:00
Peter Steinberger
ab318de8b7 test(plugins): finish moving contract coverage 2026-04-04 00:11:39 +01:00
Peter Steinberger
e4b5027c5e refactor(plugins): move extension seams into extensions 2026-04-04 00:10:16 +01:00
Tak Hoffman
c19321ed9e docs: trim PR template root-cause boilerplate 2026-04-03 18:08:55 -05:00
Agustin Rivera
ff607adc69 fix(sandbox): block home credential binds (#59157)
* fix(sandbox): block home credential binds
* fix(sandbox): harden blocked credential bind checks
2026-04-03 16:06:22 -07:00
Peter Steinberger
4540effd6c test(ci): make mattermost client retry deterministic 2026-04-03 23:55:39 +01:00
Efe Büken
5fc9918a20 fix(matrix): surface user-visible 'too large' marker when Matrix media exceeds size limit (#60289)
Merged via squash.

Prepared head SHA: f33dd49946
Co-authored-by: efe-arv <259833796+efe-arv@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 18:53:51 -04:00
Peter Steinberger
94fee8486f test: make mattermost reconnect tests deterministic 2026-04-03 23:46:19 +01:00
pgondhi987
e19dce0aed fix(hooks): harden before_tool_call hook runner to fail-closed on error [AI] (#59822)
* fix: address issue

* fix: address PR review feedback

* docs: add changelog entry for PR merge

* docs: normalize changelog entry placement

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-03 16:44:35 -06:00
Peter Steinberger
1322aa2ba2 test(ci): make mattermost reconnect deterministic 2026-04-03 23:30:33 +01:00
Peter Steinberger
2af05ac558 test: restore leaked timer spies in mattermost reconnect tests 2026-04-03 23:27:29 +01:00
Peter Steinberger
3d2734185b test: stabilize rebased auto-reply command checks 2026-04-03 23:11:34 +01:00
Gustavo Madeira Santana
9004ef65df Plugins: add install --force overwrite flag (#60544)
Merged via squash.

Prepared head SHA: 28ae50b615
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 18:09:14 -04:00
Tak Hoffman
3fd29e549d fix: honor tools default account 2026-04-03 17:06:50 -05:00
Peter Steinberger
ccf16a25d2 test: tolerate plain baileys exports in whatsapp hooks 2026-04-03 23:06:43 +01:00
Tak Hoffman
cc0987f7b1 fix: honor allowlist default account 2026-04-03 17:04:11 -05:00
Agustin Rivera
42ffdf882f fix(fetch): normalize guarded redirect handling (#59121)
* fix(fetch): align guarded redirect rewrites

* fix(fetch): tighten redirect coverage

* fix(fetch): add changelog entry
2026-04-03 15:03:18 -07:00
Tak Hoffman
393d8c7606 fix: honor approve default account 2026-04-03 17:01:58 -05:00
Tak Hoffman
5f1f43af4d fix: honor config write default account 2026-04-03 16:59:58 -05:00
Tak Hoffman
4d60d61dec fix: honor reset notice default account 2026-04-03 16:57:15 -05:00
Tak Hoffman
da68fa4079 fix: honor followup default account 2026-04-03 16:53:38 -05:00
Tak Hoffman
6ddc86a3d1 fix: honor session default delivery account 2026-04-03 16:51:34 -05:00
Peter Steinberger
30fd4c6cdb test: add default channel account stubs in subagent focus tests 2026-04-03 22:51:17 +01:00
Tak Hoffman
01534d9bd5 fix: honor acp projector default account 2026-04-03 16:49:16 -05:00
Peter Steinberger
381a865822 test: force real timers in mattermost reconnect tests 2026-04-03 22:47:19 +01:00
Tak Hoffman
2b54ce30ae fix: honor acp delivery default account 2026-04-03 16:46:25 -05:00
Tak Hoffman
bb649de1ad fix: honor subagent default account 2026-04-03 16:43:26 -05:00
Tak Hoffman
4518b9ea7a fix: honor acp reset default account 2026-04-03 16:39:50 -05:00
Peter Steinberger
32f9a7c7bc test(ci): stabilize discord model picker timers 2026-04-03 22:35:42 +01:00
Tak Hoffman
10062e8111 fix: honor plugin binding default account 2026-04-03 16:31:24 -05:00
Peter Steinberger
4fb0837220 test: relax qr dashboard cli exit assertion 2026-04-03 22:30:46 +01:00
Agustin Rivera
e8e7d1fab3 Keep non-interactive auth choices on trusted plugins (#59120)
* fix(onboard): ignore untrusted workspace auth choices

* fix(onboard): scope auth-choice inference to trusted plugins (#59120) (thanks @eleqtrizit)
2026-04-03 14:28:01 -07:00
Tak Hoffman
037da3ce34 fix: honor acp dispatch default account 2026-04-03 16:26:41 -05:00
Peter Steinberger
ee45a59b4e test: normalize owning npm path assertions 2026-04-03 22:25:34 +01:00
Tak Hoffman
932379b19f fix: honor acp spawn default account 2026-04-03 16:23:29 -05:00
Peter Steinberger
be1d31fa8a test(ci): fix windows update and task cleanup cases 2026-04-03 22:22:51 +01:00
Peter Steinberger
9f132fc1b0 test: stabilize qr dashboard ci assertion 2026-04-03 22:17:01 +01:00
Nyx
dc21e3bb1e fix(plugins): reuse active registry during tool resolution (#52262)
Merged via squash.

Prepared head SHA: 55982a6be6
Co-authored-by: PerfectPan <24316656+PerfectPan@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 17:16:33 -04:00
Peter Steinberger
5b29483ab1 test(ci): type-safe exec timeout stub 2026-04-03 22:14:59 +01:00
Tak Hoffman
001e0c1f65 fix: honor default account in plugin commands 2026-04-03 16:13:31 -05:00
Peter Steinberger
5a94909654 test(ci): stabilize exec timeout tests 2026-04-03 22:12:08 +01:00
solodmd
8ae8a5c174 config: skip empty string in raw redaction to avoid corrupting snapshot (#28214)
Merged via squash.

Prepared head SHA: 07ec5b77b1
Co-authored-by: solodmd <51304754+solodmd@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 17:11:06 -04:00
Tak Hoffman
759598f737 fix: honor default account in conversation bindings 2026-04-03 16:09:48 -05:00
Peter Steinberger
267b6f595c test: harden windows ci coverage 2026-04-03 22:09:34 +01:00
Peter Steinberger
361efd28c9 test(ci): preserve runtime module shape in qr tests 2026-04-03 22:05:25 +01:00
Peter Steinberger
66dfe18c36 fix(ci): avoid duplicate accountId spread 2026-04-03 21:59:44 +01:00
Nimrod Gutman
9b667fc534 fix(agents): cover default workspace fallback (#59858) (thanks @joelnishanth) 2026-04-03 23:57:58 +03:00
joelnishanth
94e170763e fix: respect agents.defaults.workspace for non-default agents (#59789) 2026-04-03 23:57:58 +03:00
Peter Steinberger
8343a11a6b fix(ci): type qr dashboard runtime mocks 2026-04-03 21:57:39 +01:00
Tak Hoffman
1c5a4d01c9 fix: preserve channel status account ids 2026-04-03 15:56:54 -05:00
Peter Steinberger
eb6698002c fix(ci): repair qr test typing and mattermost setup status 2026-04-03 21:55:33 +01:00
Peter Steinberger
51eb877a15 test(ci): stabilize qr cli runtime mocks 2026-04-03 21:53:02 +01:00
Tak Hoffman
db4d0c0abc fix: honor mattermost default setup status 2026-04-03 15:51:49 -05:00
Tak Hoffman
8e023ffd06 fix: honor imessage default setup status 2026-04-03 15:49:56 -05:00
Tak Hoffman
2e9cad224d fix: honor irc default setup status 2026-04-03 15:47:49 -05:00
Tak Hoffman
cfef9bf856 fix: honor feishu default setup status 2026-04-03 15:44:49 -05:00
Peter Steinberger
0204b8dd28 fix: stabilize live and docker test lanes 2026-04-03 21:43:36 +01:00
Tak Hoffman
5d3edb1d40 fix: honor nextcloud default setup status 2026-04-03 15:41:44 -05:00
Bruce MacDonald
5ec53fff0c feat(ollama): add bundled web search provider (#59318)
Merged via squash.

Prepared head SHA: 1ec105f356
Co-authored-by: BruceMacD <5853428+BruceMacD@users.noreply.github.com>
Co-authored-by: BruceMacD <5853428+BruceMacD@users.noreply.github.com>
Reviewed-by: @BruceMacD
2026-04-03 13:41:24 -07:00
Tak Hoffman
dbb0164934 fix: honor signal default setup status 2026-04-03 15:39:50 -05:00
Peter Steinberger
40ae49effa fix(ci): normalize line default-account setup checks 2026-04-03 21:39:01 +01:00
Peter Steinberger
f336f0b83c test: trim more helper partial mocks 2026-04-03 21:38:01 +01:00
mappel-nv
21e53aea9e Gateway: refresh websocket auth after secrets reload (#60323)
* Gateway: refresh websocket auth after secrets reload

* Gateway: always restore auth reload test globals

* chore: add changelog for websocket auth reload

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-03 14:35:31 -06:00
Peter Steinberger
f3a6d13965 test: trim helper partial mocks 2026-04-03 21:34:42 +01:00
Tak Hoffman
183601c347 fix: honor line default setup status 2026-04-03 15:32:44 -05:00
Peter Steinberger
fa6e6603fa test(ci): harden cli and exec tests for shared workers 2026-04-03 21:30:47 +01:00
Tak Hoffman
5361b5cf04 fix: honor zalo default setup status 2026-04-03 15:27:59 -05:00
Tak Hoffman
8cb3316afb fix: honor bluebubbles default setup status 2026-04-03 15:24:29 -05:00
Peter Steinberger
63603876bf test: trim fs bridge partial mocks 2026-04-03 21:24:00 +01:00
Tak Hoffman
6317fce9cb fix: honor discord default setup status 2026-04-03 15:22:44 -05:00
Peter Steinberger
8158597f84 test(ci): make mattermost retry test deterministic 2026-04-03 21:22:16 +01:00
@zimeg
8d557c19d5 docs(slack): set always online to true in example app manifest 2026-04-03 13:21:15 -07:00
Tak Hoffman
f59c52bc16 fix: honor slack default setup status 2026-04-03 15:21:02 -05:00
Peter Steinberger
faff198777 test: trim control ui assets partial mocks 2026-04-03 21:19:14 +01:00
Peter Steinberger
fde573bab2 test(ci): reset mattermost websocket timers per test 2026-04-03 21:17:55 +01:00
Tak Hoffman
de6997a203 fix: honor googlechat default setup status 2026-04-03 15:15:01 -05:00
Peter Steinberger
ee63fdb056 test(ci): harden context engine runtime bridge test 2026-04-03 21:12:56 +01:00
Peter Steinberger
93e716e775 test: trim model startup retry partial mocks 2026-04-03 21:10:17 +01:00
Tak Hoffman
756597e6ad fix: honor discord default directory cache account 2026-04-03 15:09:18 -05:00
Peter Steinberger
328b7bee75 test(ci): fix slack pairing notify typing 2026-04-03 21:07:06 +01:00
Tak Hoffman
78022740fc fix: honor twitch default send account 2026-04-03 15:06:56 -05:00
Peter Steinberger
4c0f51df81 test: trim gateway and infra partial mocks 2026-04-03 21:03:54 +01:00
Peter Steinberger
b57922552e fix(ci): restore command auth sdk export 2026-04-03 21:02:59 +01:00
Vincent Koc
58ee283658 test(providers): fix anthropic payload test typing 2026-04-04 05:02:22 +09:00
Tak Hoffman
299ed8cb39 fix: honor slack default pairing account 2026-04-03 15:01:55 -05:00
@zimeg
2a13508379 docs(slack): expand app manifest example and scope checklist 2026-04-03 12:58:47 -07:00
Vincent Koc
067496b129 refactor(providers): share anthropic payload policy 2026-04-04 04:57:47 +09:00
Peter Steinberger
3e0ddaf5bc test(ci): stabilize mattermost websocket retry test 2026-04-03 20:56:09 +01:00
Tak Hoffman
d44af743db fix: honor whatsapp default setup finalize account 2026-04-03 14:55:27 -05:00
Tak Hoffman
f8a0f9ffd3 fix: honor twitch default setup account 2026-04-03 14:52:31 -05:00
Peter Steinberger
d007559c38 test: trim more agent e2e partial mocks 2026-04-03 20:50:57 +01:00
Tak Hoffman
84db697cd6 fix: honor twitch default outbound account 2026-04-03 14:49:55 -05:00
Peter Steinberger
2a5fbf0fd6 fix(ci): align discord dm auth account expectation 2026-04-03 20:48:05 +01:00
Peter Steinberger
7db148706a test: trim more runtime partial mocks 2026-04-03 20:46:57 +01:00
Tak Hoffman
f56a9f3b3b fix: honor twitch default runtime account 2026-04-03 14:46:23 -05:00
Peter Steinberger
1ff586cda1 fix(ci): repair discord preflight test types 2026-04-03 20:44:03 +01:00
Tak Hoffman
314512ae14 fix: honor whatsapp default heartbeat account 2026-04-03 14:42:38 -05:00
Peter Steinberger
2247089381 test: trim command install partial mocks 2026-04-03 20:42:29 +01:00
Peter Steinberger
92409aa4d6 test: trim agent e2e partial mocks 2026-04-03 20:42:29 +01:00
Peter Steinberger
52fb51db77 fix(ci): update whatsapp setup status mock signature 2026-04-03 20:40:54 +01:00
Peter Steinberger
3f86972e46 test: trim sandbox and gateway partial mocks 2026-04-03 20:40:27 +01:00
Tak Hoffman
5942726d25 fix: honor discord default dm preflight account 2026-04-03 14:37:56 -05:00
Peter Steinberger
ae976a90a5 test: trim more command partial mocks 2026-04-03 20:37:14 +01:00
Peter Steinberger
4481c41368 fix(ci): repair slack feishu and telegram regressions 2026-04-03 20:36:40 +01:00
Tak Hoffman
aa983566c4 fix: honor whatsapp default setup status account 2026-04-03 14:34:40 -05:00
Peter Steinberger
6f8f2a012b test: trim commands and cli partial mocks 2026-04-03 20:34:23 +01:00
Tak Hoffman
f7f467b042 fix: honor telegram default debounce account 2026-04-03 14:30:34 -05:00
Peter Steinberger
a715b83e67 test: trim auto-reply and cli partial mocks 2026-04-03 20:30:10 +01:00
Tak Hoffman
6c5064b437 fix: honor slack default hook account 2026-04-03 14:26:57 -05:00
Vincent Koc
30e43550bb test(cli): make plugin install recovery requests explicit 2026-04-04 04:26:51 +09:00
Vincent Koc
4265a59892 fix(config): hide legacy internal hook handlers 2026-04-04 04:26:51 +09:00
Peter Steinberger
d9af49a7af test: trim reply label generator mock 2026-04-03 20:25:49 +01:00
Tak Hoffman
58d6c16d12 fix: honor discord default mention account 2026-04-03 14:24:07 -05:00
Peter Steinberger
6068497409 test: trim auto-reply tools mock 2026-04-03 20:23:34 +01:00
Peter Steinberger
c8c0aeda76 test: trim more auto-reply partial mocks 2026-04-03 20:22:24 +01:00
Tak Hoffman
489a62e788 fix: honor whatsapp default outbound account 2026-04-03 14:22:08 -05:00
Peter Steinberger
63443acc2b fix(ci): repair telegram test harness config 2026-04-03 20:21:50 +01:00
Peter Steinberger
0805add3a4 test: trim more reply and agent mocks 2026-04-03 20:20:51 +01:00
619 changed files with 15138 additions and 9809 deletions

View File

@@ -35,19 +35,17 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
- Related #
- [ ] This PR fixes a bug or regression
## Root Cause / Regression History (if applicable)
## Root Cause (if applicable)
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
- Root cause:
- Missing detection / guardrail:
- Prior context (`git blame`, prior PR, issue, or refactor if known):
- Why this regressed now:
- If unknown, what was ruled out:
- Contributing context (if known):
## Regression Test Plan (if applicable)
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`.
For bug fixes or regressions, name the smallest reliable test coverage that should catch this. Otherwise write `N/A`.
- Coverage level that should have caught this:
- [ ] Unit test

View File

@@ -15,6 +15,9 @@ Docs: https://docs.openclaw.ai
- Plugins/browser seams: split browser and WhatsApp plugin-sdk seams into narrower browser, approval-auth, and target-helper facades so hot paths and owner tests avoid broader runtime fan-out. (#60376) Thanks @shakkernerd.
- Tests/runtime: trim local unit-test import/runtime fan-out across browser, WhatsApp, cron, task, and reply flows so owner suites start faster with lower shared-worker overhead while preserving the same focused behavior coverage. (#60249) Thanks @shakkernerd.
- Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd.
- Providers/Ollama: add bundled Ollama Web Search provider for key-free web_search via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD.
- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras.
- Providers/transport: add shared proxy/TLS/auth-aware request transport support across model-provider paths, including Anthropic and Google native transport runtimes, so provider request overrides work beyond OpenAI-family traffic.
### Fixes
@@ -28,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Telegram/local Bot API: thread `channels.telegram.apiRoot` through buffered reply-media and album downloads so self-hosted Bot API file paths stop falling back to `api.telegram.org` and 404ing. (#59544) Thanks @SARAMALI15792.
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
- Telegram/native commands: clean up metadata-driven progress placeholders when replies fall back, edits fail, or local exec approval prompts are suppressed. (#59300) Thanks @jalehman.
- Telegram/models: compare full provider/model refs in the Telegram picker so same-id models from other providers no longer show the wrong current-model checkmark. (#60384) Thanks @sfuminya.
- Media/request overrides: resolve shared and capability-filtered media request SecretRefs correctly and expose media transport override fields to schema-driven config consumers. (#59848) Thanks @vincentkoc.
- Providers/request overrides: stop advertising unsupported proxy and TLS transport settings on `models.providers.*.request`, and fail closed if unvalidated config tries to route LLM model-provider traffic through dead transport fields. (#59682) Thanks @vincentkoc.
- Discord/mentions: treat `@everyone` and `@here` as valid mention-gate triggers in guild preflight so mention-required bots still respond to those broadcasts. (#60343) Thanks @geekhuashan.
@@ -36,6 +40,7 @@ Docs: https://docs.openclaw.ai
- Ollama/auth: prefer real cloud auth over local marker during model auth resolution so cloud-backed Ollama auth does not get shadowed by stale local-only markers.
- Plugins/Kimi Coding: parse tagged Kimi tool-call text into structured tool calls on the provider stream path so tools execute instead of echoing raw markup. (#60051) Thanks @obviyus.
- Channels/passive hooks: emit passive message hooks for mention-skipped Telegram and Signal group messages when `ingest` is enabled, including wildcard/default fallback and per-group override handling. (#60018) Thanks @obviyus.
- Providers/compat: stop forcing OpenAI-only payload defaults on proxy and custom OpenAI-compatible routes, and preserve native vendor-specific reasoning, tool, and streaming behavior for Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, Z.ai, and other routed provider paths.
- Plugins/manifest registry: stop warning when an explicit manifest `id` intentionally differs from the discovery hint. (#59185) Thanks @samzong.
- WhatsApp/streaming: honor `channels.whatsapp.blockStreaming` again for inbound auto-replies so progressive block replies can be enabled explicitly instead of being forced to final-only delivery. Thanks @mcaxtr.
- Auth/failover: shorten `auth_permanent` lockouts, add dedicated config knobs for permanent-auth backoff, and downgrade ambiguous auth-ish upstream incidents to retryable auth failures so providers recover automatically after transient outages. (#60404) Thanks @extrasmall0.
@@ -43,6 +48,7 @@ Docs: https://docs.openclaw.ai
- Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380.
- Infra/tailscale: ignore `OPENCLAW_TEST_TAILSCALE_BINARY` outside explicit test environments and block it from workspace `.env`, so test-only binary overrides cannot be injected through trusted repository state. (#58468) Thanks @eleqtrizit.
- Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly. Thanks @steipete.
- Cache/context guard: compact newest tool results first so the cached prompt prefix stays byte-identical and avoids full re-tokenization every turn past the 75% context threshold. (#58036) Thanks @bcherny.
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
- Plugins/startup: migrate legacy `tools.web.search.<provider>` config before strict startup validation, and record plugin failure phase/timestamp so degraded plugin startup is easier to diagnose from logs and `plugins list`.
@@ -77,6 +83,7 @@ Docs: https://docs.openclaw.ai
- Plugins/allowlists: let explicit bundled chat channel enablement bypass `plugins.allow`, while keeping auto-enabled channel activation and startup sidecars behind restrictive allowlists. (#60233) Thanks @dorukardahan.
- Allowlist/commands: require owner access for `/allowlist add` and `/allowlist remove` so command-authorized non-owners cannot mutate persisted allowlists. (#59836) Thanks @eleqtrizit.
- Control UI/skills: clear stale ClawHub results immediately when the search query changes, so debounced searches cannot keep outdated install targets visible. Related #60134.
- Fetch/redirects: normalize guarded redirect method rewriting and loop detection so SSRF-guarded requests match platform redirect behavior without missing loops back to the original URL. (#59121) Thanks @eleqtrizit.
- Discord/ack reactions: keep automatic ACK reaction auth on the active hydrated Discord account so SecretRef-backed and non-default-account reactions stop falling back to stale default config resolution. (#60081) Thanks @FunJim.
- Telegram/model switching: render non-default `/model` callback confirmations with HTML formatting so Telegram shows the selected model in bold instead of raw `**...**` markers. (#60042) Thanks @GitZhangChi.
- Plugins/update: allow `openclaw plugins update` to use `--dangerously-force-unsafe-install` for built-in dangerous-code false positives during plugin updates. (#60066) Thanks @huntharo.
@@ -85,7 +92,16 @@ Docs: https://docs.openclaw.ai
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
- Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. (#60463) Thanks @jalehman.
- Slack/app manifest: set `bot_user.always_online` to `true` in the onboarding and example Slack app manifest so the Slack app appears ready to respond.
- Gateway/websocket auth: refresh auth on new websocket connects after secrets reload so rotated gateway tokens take effect immediately without requiring a restart. (#60323) Thanks @mappel-nv.
- Onboarding/plugins: keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins so untrusted workspace manifests cannot hijack built-in provider API-key flows. (#59120) Thanks @eleqtrizit.
- Agents/workspace: respect `agents.defaults.workspace` for non-default agents by resolving them under the configured base path instead of falling back to `workspace-<id>`. (#59858) Thanks @joelnishanth.
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the snapshot during redaction. (#28214) thanks @solodmd.
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
- Hooks/tool policy: block tool calls when a `before_tool_call` hook crashes so hook failures fail closed instead of silently allowing execution. (#59822) Thanks @pgondhi987.
- Matrix/media: surface a dedicated `[matrix <kind> attachment too large]` marker for oversized inbound media instead of the generic unavailable marker, and classify size-limit failures with a typed Matrix error. (#60289) Thanks @efe-arv.
- WhatsApp/watchdog: reset watchdog timeout after reconnect so quiet channels no longer enter a tight reconnect loop from stale message timestamps carried across connection runs. (#60007) Thanks @MonkeyLeeT.
- Agents/fallback: persist selected fallback overrides before retry attempts start, prefer persisted overrides during live-session reconciliation, and keep provider-scoped auth-profile failover from snapping retries back to stale primary selections.
## 2026.4.2
@@ -115,6 +131,10 @@ Docs: https://docs.openclaw.ai
### Fixes
- Sandbox/security: block credential-path binds even when sandbox home paths resolve through canonical aliases, so agent containers cannot mount user secret stores through alternate home-directory paths. (#59157) Thanks @eleqtrizit.
## 2026.4.1-beta.1
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
- Providers/OpenRouter: gate documented OpenRouter attribution to native OpenRouter endpoints or the default route so custom proxy base URLs do not inherit OpenRouter request headers.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.

View File

@@ -1,5 +1,5 @@
{
"originHash" : "1c9c9d251b760ed3234ecff741a88eb4bf42315ad6f50ac7392b187cf226c16c",
"originHash" : "fb90e7b1977f43661ac91681d16da11f9ddd85630407ef170eaada0a6ee39972",
"pins" : [
{
"identity" : "axorcist",
@@ -24,7 +24,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},

View File

@@ -12279,7 +12279,7 @@
"advanced"
],
"label": "Internal Hooks Enabled",
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
"hasChildren": false
},
{
@@ -12345,72 +12345,6 @@
"tags": [],
"hasChildren": false
},
{
"path": "hooks.internal.handlers",
"kind": "core",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Internal Hook Handlers",
"help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.",
"hasChildren": true
},
{
"path": "hooks.internal.handlers.*",
"kind": "core",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "hooks.internal.handlers.*.event",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Internal Hook Event",
"help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.",
"hasChildren": false
},
{
"path": "hooks.internal.handlers.*.export",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Internal Hook Export",
"help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.",
"hasChildren": false
},
{
"path": "hooks.internal.handlers.*.module",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Internal Hook Module",
"help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.",
"hasChildren": false
},
{
"path": "hooks.internal.installs",
"kind": "core",

View File

@@ -12278,7 +12278,7 @@
"advanced"
],
"label": "Internal Hooks Enabled",
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
"hasChildren": false
},
{
@@ -12344,72 +12344,6 @@
"tags": [],
"hasChildren": false
},
{
"path": "hooks.internal.handlers",
"kind": "core",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Internal Hook Handlers",
"help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.",
"hasChildren": true
},
{
"path": "hooks.internal.handlers.*",
"kind": "core",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "hooks.internal.handlers.*.event",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Internal Hook Event",
"help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.",
"hasChildren": false
},
{
"path": "hooks.internal.handlers.*.export",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Internal Hook Export",
"help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.",
"hasChildren": false
},
{
"path": "hooks.internal.handlers.*.module",
"kind": "core",
"type": "string",
"required": true,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Internal Hook Module",
"help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.",
"hasChildren": false
},
{
"path": "hooks.internal.installs",
"kind": "core",

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -215,6 +215,10 @@
"source": "FAQ",
"target": "常见问题"
},
{
"source": "Ollama Web Search",
"target": "Ollama Web 搜索"
},
{
"source": "onboarding",
"target": "新手引导"

View File

@@ -392,7 +392,7 @@ Notes:
## Manifest and scope checklist
<AccordionGroup>
<Accordion title="Slack app manifest example">
<Accordion title="Slack app manifest example" defaultOpen>
```json
{
@@ -403,7 +403,7 @@ Notes:
"features": {
"bot_user": {
"display_name": "OpenClaw",
"always_online": false
"always_online": true
},
"app_home": {
"messages_tab_enabled": true,

View File

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

View File

@@ -48,6 +48,7 @@ capabilities.
```bash
openclaw plugins install <package> # ClawHub first, then npm
openclaw plugins install clawhub:<package> # ClawHub only
openclaw plugins install <package> --force # overwrite existing install
openclaw plugins install <package> --pin # pin version
openclaw plugins install <package> --dangerously-force-unsafe-install
openclaw plugins install <path> # local path
@@ -58,6 +59,10 @@ openclaw plugins install <plugin> --marketplace <name> # marketplace (explicit)
Bare package names are checked against ClawHub first, then npm. Security note:
treat plugin installs like running code. Prefer pinned versions.
`--force` reuses the existing install target and overwrites an already-installed
plugin or hook pack in place. Use it when you are intentionally reinstalling
the same id from a new local path, archive, ClawHub package, or npm artifact.
`--dangerously-force-unsafe-install` is a break-glass option for false positives
in the built-in dangerous-code scanner. It allows the install to continue even
when the built-in scanner reports `critical` findings, but it does **not**
@@ -157,6 +162,9 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
openclaw plugins install -l ./my-plugin
```
`--force` is not supported with `--link` because linked installs reuse the
source path instead of copying over a managed install target.
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
`plugins.installs` while keeping the default behavior unpinned.

View File

@@ -3,6 +3,7 @@ summary: "How OpenClaw rotates auth profiles and falls back across models"
read_when:
- Diagnosing auth profile rotation, cooldowns, or model fallback behavior
- Updating failover rules for auth profiles or models
- Understanding how session model overrides interact with fallback retries
title: "Model Failover"
---
@@ -15,6 +16,44 @@ OpenClaw handles failures in two stages:
This doc explains the runtime rules and the data that backs them.
## Runtime flow
For a normal text run, OpenClaw evaluates candidates in this order:
1. The currently selected session model.
2. Configured `agents.defaults.model.fallbacks` in order.
3. The configured primary model at the end when the run started from an override.
Inside each candidate, OpenClaw tries auth-profile failover before advancing to
the next model candidate.
High-level sequence:
1. Resolve the active session model and auth-profile preference.
2. Build the model candidate chain.
3. Try the current provider with auth-profile rotation/cooldown rules.
4. If that provider is exhausted with a failover-worthy error, move to the next
model candidate.
5. Persist the selected fallback override before the retry starts so other
session readers see the same provider/model the runner is about to use.
6. If the fallback candidate fails, roll back only the fallback-owned session
override fields when they still match that failed candidate.
7. If every candidate fails, throw a `FallbackSummaryError` with per-attempt
detail and the soonest cooldown expiry when one is known.
This is intentionally narrower than "save and restore the whole session". The
reply runner only persists the model-selection fields it owns for fallback:
- `providerOverride`
- `modelOverride`
- `authProfileOverride`
- `authProfileOverrideSource`
- `authProfileOverrideCompactionCount`
That prevents a failed fallback retry from overwriting newer unrelated session
mutations such as manual `/model` changes or session rotation updates that
happened while the attempt was running.
## Auth storage (keys + OAuth)
OpenClaw uses **auth profiles** for both API keys and OAuth tokens.
@@ -148,6 +187,102 @@ with `auth.cooldowns.overloadedProfileRotations`,
When a run starts with a model override (hooks or CLI), fallbacks still end at
`agents.defaults.model.primary` after trying any configured fallbacks.
### Candidate chain rules
OpenClaw builds the candidate list from the currently requested `provider/model`
plus configured fallbacks.
Rules:
- The requested model is always first.
- Explicit configured fallbacks are deduplicated but not filtered by the model
allowlist. They are treated as explicit operator intent.
- If the current run is already on a configured fallback in the same provider
family, OpenClaw keeps using the full configured chain.
- If the current run is on a different provider than config and that current
model is not already part of the configured fallback chain, OpenClaw does not
append unrelated configured fallbacks from another provider.
- When the run started from an override, the configured primary is appended at
the end so the chain can settle back onto the normal default once earlier
candidates are exhausted.
### Which errors advance fallback
Model fallback continues on:
- auth failures
- rate limits and cooldown exhaustion
- overloaded/provider-busy errors
- timeout-shaped failover errors
- billing disables
- `LiveSessionModelSwitchError`, which is normalized into a failover path so a
stale persisted model does not create an outer retry loop
- other unrecognized errors when there are still remaining candidates
Model fallback does not continue on:
- explicit aborts that are not timeout/failover-shaped
- context overflow errors that should stay inside compaction/retry logic
- a final unknown error when there are no candidates left
### Cooldown skip vs probe behavior
When every auth profile for a provider is already in cooldown, OpenClaw does
not automatically skip that provider forever. It makes a per-candidate decision:
- Persistent auth failures skip the whole provider immediately.
- Billing disables usually skip, but the primary candidate can still be probed
on a throttle so recovery is possible without restarting.
- The primary candidate may be probed near cooldown expiry, with a per-provider
throttle.
- Same-provider fallback siblings can be attempted despite cooldown when the
failure looks transient (`rate_limit`, `overloaded`, or unknown).
- Transient cooldown probes are limited to one per provider per fallback run so
a single provider does not stall cross-provider fallback.
## Session overrides and live model switching
Session model changes are shared state. The active runner, `/model` command,
compaction/session updates, and live-session reconciliation all read or write
parts of the same session entry.
That means fallback retries have to coordinate with live model switching:
- Before a fallback retry starts, the reply runner persists the selected
fallback override fields to the session entry.
- Live-session reconciliation prefers persisted session overrides over stale
runtime model fields.
- If the fallback attempt fails, the runner rolls back only the override fields
it wrote, and only if they still match that failed candidate.
This prevents the classic race:
1. Primary fails.
2. Fallback candidate is chosen in memory.
3. Session store still says the old primary.
4. Live-session reconciliation reads the stale session state.
5. The retry gets snapped back to the old model before the fallback attempt
starts.
The persisted fallback override closes that window, and the narrow rollback
keeps newer manual or runtime session changes intact.
## Observability and failure summaries
`runWithModelFallback(...)` records per-attempt details that feed logs and
user-facing cooldown messaging:
- provider/model attempted
- reason (`rate_limit`, `overloaded`, `billing`, `auth`, `model_not_found`, and
similar failover reasons)
- optional status/code
- human-readable error summary
When every candidate fails, OpenClaw throws `FallbackSummaryError`. The outer
reply runner can use that to build a more specific message such as "all models
are temporarily rate-limited" and include the soonest cooldown expiry when one
is known.
## Related config
See [Gateway configuration](/gateway/configuration) for:

View File

@@ -16,6 +16,8 @@ For model selection rules, see [/concepts/models](/concepts/models).
- Model refs use `provider/model` (example: `opencode/claude-opus-4-6`).
- If you set `agents.defaults.models`, it becomes the allowlist.
- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`.
- Fallback runtime rules, cooldown probes, and session-override persistence are
documented in [/concepts/model-failover](/concepts/model-failover).
- Provider plugins can inject model catalogs via `registerProvider({ catalog })`;
OpenClaw merges that output into `models.providers` before writing
`models.json`.

View File

@@ -1177,6 +1177,7 @@
"tools/gemini-search",
"tools/grok-search",
"tools/kimi-search",
"tools/ollama-search",
"tools/perplexity-search",
"tools/searxng-search",
"tools/tavily"

View File

@@ -1410,16 +1410,24 @@ for usage/billing and raise limits as needed.
</Accordion>
<Accordion title="How do I enable web search (and web fetch)?">
`web_fetch` works without an API key. `web_search` requires a key for your
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
`web_fetch` works without an API key. `web_search` depends on your selected
provider:
- API-backed providers such as Brave, Exa, Firecrawl, Gemini, Grok, Kimi, Perplexity, and Tavily require their normal API key setup.
- Ollama Web Search is key-free, but it uses your configured Ollama host and requires `ollama signin`.
- DuckDuckGo is key-free, but it is an unofficial HTML-based integration.
**Recommended:** run `openclaw configure --section web` and choose a provider.
Environment alternatives:
- Brave: `BRAVE_API_KEY`
- Exa: `EXA_API_KEY`
- Firecrawl: `FIRECRAWL_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
- Tavily: `TAVILY_API_KEY`
```json5
{
@@ -2080,13 +2088,13 @@ for usage/billing and raise limits as needed.
1. Install Ollama from `https://ollama.com/download`
2. Pull a local model such as `ollama pull glm-4.7-flash`
3. If you want Ollama Cloud too, run `ollama signin`
3. If you want cloud models too, run `ollama signin`
4. Run `openclaw onboard` and choose `Ollama`
5. Pick `Local` or `Cloud + Local`
Notes:
- `Cloud + Local` gives you Ollama Cloud models plus your local Ollama models
- `Cloud + Local` gives you cloud models plus your local Ollama models
- cloud models such as `kimi-k2.5:cloud` do not need a local pull
- for manual switching, use `openclaw models list` and `openclaw models set ollama/<model>`

View File

@@ -235,6 +235,33 @@ To use cloud models, select **Cloud + Local** mode during setup. The wizard chec
You can also sign in directly at [ollama.com/signin](https://ollama.com/signin).
## Ollama Web Search
OpenClaw also supports **Ollama Web Search** as a bundled `web_search`
provider.
- It uses your configured Ollama host (`models.providers.ollama.baseUrl` when
set, otherwise `http://127.0.0.1:11434`).
- It is key-free.
- It requires Ollama to be running and signed in with `ollama signin`.
Choose **Ollama Web Search** during `openclaw onboard` or
`openclaw configure --section web`, or set:
```json5
{
tools: {
web: {
search: {
provider: "ollama",
},
},
},
}
```
For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-search).
## Advanced
### Reasoning models

View File

@@ -77,13 +77,18 @@ See [Memory](/concepts/memory).
### 4) Web search tool
`web_search` uses API keys and may incur usage charges depending on your provider:
`web_search` may incur usage charges depending on your provider:
- **Brave Search API**: `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey`
- **Exa**: `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`
- **Firecrawl**: `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey`
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey`
- **Grok (xAI)**: `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey`
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `plugins.entries.moonshot.config.webSearch.apiKey`
- **Ollama Web Search**: key-free, but requires a reachable Ollama host plus `ollama signin`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey`
- **Tavily**: `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
- **DuckDuckGo**: key-free fallback (no API billing, but unofficial and HTML-based)
Legacy `tools.web.search.*` provider paths still load through the temporary compatibility shim, but they are no longer the recommended config surface.

View File

@@ -100,8 +100,8 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve <channel> <code>` or use allowlists.
</Step>
<Step title="Web search">
- Pick a provider: Perplexity, Brave, Gemini, Grok, or Kimi (or skip).
- Paste your API key (QuickStart auto-detects keys from env vars or existing config).
- Pick a supported provider such as Brave, Firecrawl, Gemini, Grok, Kimi, Ollama Web Search, Perplexity, or Tavily (or skip).
- API-backed providers can use env vars or existing config for quick setup; key-free providers use their provider-specific prerequisites instead.
- Skip with `--skip-search`.
- Configure later: `openclaw configure --section web`.
</Step>

View File

@@ -36,8 +36,9 @@ openclaw agents add <name>
<Tip>
CLI onboarding includes a web search step where you can pick a provider
(Perplexity, Brave, Gemini, Grok, or Kimi) and paste your API key so the agent
can use `web_search`. You can also configure this later with
such as Brave, Firecrawl, Gemini, Grok, Kimi, Ollama Web Search, Perplexity,
or Tavily. Some providers require an API key, while others are key-free. You
can also configure this later with
`openclaw configure --section web`. Docs: [Web tools](/tools/web).
</Tip>

View File

@@ -0,0 +1,94 @@
---
summary: "Ollama Web Search via your configured Ollama host"
read_when:
- You want to use Ollama for web_search
- You want a key-free web_search provider
- You need Ollama Web Search setup guidance
title: "Ollama Web Search"
---
# Ollama Web Search
OpenClaw supports **Ollama Web Search** as a bundled `web_search` provider.
It uses Ollama's experimental web-search API and returns structured results
with titles, URLs, and snippets.
Unlike the Ollama model provider, this setup does not need an API key. It
does require:
- an Ollama host that is reachable from OpenClaw
- `ollama signin`
## Setup
<Steps>
<Step title="Start Ollama">
Make sure Ollama is installed and running.
</Step>
<Step title="Sign in">
Run:
```bash
ollama signin
```
</Step>
<Step title="Choose Ollama Web Search">
Run:
```bash
openclaw configure --section web
```
Then select **Ollama Web Search** as the provider.
</Step>
</Steps>
If you already use Ollama for models, Ollama Web Search reuses the same
configured host.
## Config
```json5
{
tools: {
web: {
search: {
provider: "ollama",
},
},
},
}
```
Optional Ollama host override:
```json5
{
models: {
providers: {
ollama: {
baseUrl: "http://ollama-host:11434",
},
},
},
}
```
If no explicit Ollama base URL is set, OpenClaw uses `http://127.0.0.1:11434`.
## Notes
- No API key field is required for this provider.
- OpenClaw warns during setup if Ollama is unreachable or not signed in, but
it does not block selection.
- Runtime auto-detect can fall back to Ollama Web Search when no higher-priority
credentialed provider is configured.
- The provider uses Ollama's experimental `/api/experimental/web_search`
endpoint.
## Related
- [Web Search overview](/tools/web) -- all providers and auto-detection
- [Ollama](/providers/ollama) -- Ollama model setup and cloud/local modes

View File

@@ -209,6 +209,7 @@ openclaw plugins doctor # diagnostics
openclaw plugins install <package> # install (ClawHub first, then npm)
openclaw plugins install clawhub:<pkg> # install from ClawHub only
openclaw plugins install <spec> --force # overwrite existing install
openclaw plugins install <path> # install from local path
openclaw plugins install -l <path> # link (no copy) for dev
openclaw plugins install <spec> --dangerously-force-unsafe-install
@@ -220,6 +221,10 @@ openclaw plugins enable <id>
openclaw plugins disable <id>
```
`--force` overwrites an existing installed plugin or hook pack in place.
It is not supported with `--link`, which reuses the source path instead of
copying over a managed install target.
`--dangerously-force-unsafe-install` is a break-glass override for false
positives from the built-in dangerous-code scanner. It allows plugin installs
and plugin updates to continue past built-in `critical` findings, but it still

View File

@@ -27,16 +27,18 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
## Quick start
<Steps>
<Step title="Get an API key">
Pick a provider and get an API key. See the provider pages below for
sign-up links.
<Step title="Choose a provider">
Pick a provider and complete any required setup. Some providers are
key-free, while others use API keys. See the provider pages below for
details.
</Step>
<Step title="Configure">
```bash
openclaw configure --section web
```
This stores the key and sets the provider. You can also set an env var
(e.g. `BRAVE_API_KEY`) and skip this step.
This stores the provider and any needed credential. You can also set an env
var (for example `BRAVE_API_KEY`) and skip this step for API-backed
providers.
</Step>
<Step title="Use it">
The agent can now call `web_search`:
@@ -78,6 +80,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
<Card title="Kimi" icon="moon" href="/tools/kimi-search">
AI-synthesized answers with citations via Moonshot web search.
</Card>
<Card title="Ollama Web Search" icon="globe" href="/tools/ollama-search">
Key-free search via your configured Ollama host. Requires `ollama signin`.
</Card>
<Card title="Perplexity" icon="search" href="/tools/perplexity-search">
Structured results with content extraction controls and domain filtering.
</Card>
@@ -91,18 +96,19 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
### Provider comparison
| Provider | Result style | Filters | API key |
| -------------------------------------- | -------------------------- | ------------------------------------------------ | ------------------------------------------- |
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` |
| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` |
| [Kimi](/tools/kimi-search) | AI-synthesized + citations | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) |
| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` |
| Provider | Result style | Filters | API key |
| ----------------------------------------- | -------------------------- | ------------------------------------------------ | ------------------------------------------- |
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` |
| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` |
| [Kimi](/tools/kimi-search) | AI-synthesized + citations | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None (`ollama signin` required) |
| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) |
| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` |
## Auto-detection
@@ -146,8 +152,8 @@ If native Codex search is enabled but the current model is not Codex-capable, Op
Provider lists in docs and setup flows are alphabetical. Auto-detection keeps a
separate precedence order:
If no `provider` is set, OpenClaw checks for API keys in this order and uses
the first one found:
If no `provider` is set, OpenClaw checks providers in this order and uses the
first one that is ready:
1. **Brave** -- `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey`
2. **Gemini** -- `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey`
@@ -155,12 +161,14 @@ the first one found:
4. **Kimi** -- `KIMI_API_KEY` / `MOONSHOT_API_KEY` or `plugins.entries.moonshot.config.webSearch.apiKey`
5. **Perplexity** -- `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` or `plugins.entries.perplexity.config.webSearch.apiKey`
6. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey`
7. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
7. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`
8. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
9. **DuckDuckGo** -- key-free HTML fallback with no account or API key
10. **Ollama Web Search** -- key-free fallback via your configured Ollama host; requires Ollama to be reachable and signed in with `ollama signin`
Key-free providers are checked after API-backed providers:
8. **DuckDuckGo** -- no key needed (auto-detect order 100)
9. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (auto-detect order 200)
11. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (auto-detect order 200)
If no provider is detected, it falls back to Brave (you will get a missing-key
error prompting you to configure one).
@@ -376,3 +384,4 @@ If you use tool profiles or allowlists, add `web_search`, `x_search`, or `group:
- [Web Fetch](/tools/web-fetch) -- fetch a URL and extract readable content
- [Web Browser](/tools/browser) -- full browser automation for JS-heavy sites
- [Grok Search](/tools/grok-search) -- Grok as the `web_search` provider
- [Ollama Web Search](/tools/ollama-search) -- key-free web search through your Ollama host

View File

@@ -0,0 +1,8 @@
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicServiceTierWrapper,
resolveAnthropicBetas,
resolveAnthropicFastMode,
resolveAnthropicServiceTier,
} from "./stream-wrappers.js";

View File

@@ -1,7 +1,10 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http";
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
const log = createSubsystemLogger("anthropic-stream");
@@ -52,20 +55,6 @@ function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
}
function allowsAnthropicServiceTier(model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
}): boolean {
return resolveProviderRequestCapabilities({
provider: typeof model.provider === "string" ? model.provider : undefined,
api: typeof model.api === "string" ? model.api : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
capability: "llm",
transport: "stream",
}).allowsAnthropicServiceTier;
}
function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier {
return enabled ? "auto" : "standard_only";
}
@@ -161,15 +150,19 @@ export function createAnthropicFastModeWrapper(
const underlying = baseStreamFn ?? streamSimple;
const serviceTier = resolveAnthropicFastServiceTier(enabled);
return (model, context, options) => {
if (!allowsAnthropicServiceTier(model)) {
const payloadPolicy = resolveAnthropicPayloadPolicy({
provider: typeof model.provider === "string" ? model.provider : undefined,
api: typeof model.api === "string" ? model.api : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
serviceTier,
});
if (!payloadPolicy.allowsServiceTier) {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
if (payloadObj.service_tier === undefined) {
payloadObj.service_tier = serviceTier;
}
});
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
);
};
}
@@ -179,15 +172,19 @@ export function createAnthropicServiceTierWrapper(
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (!allowsAnthropicServiceTier(model)) {
const payloadPolicy = resolveAnthropicPayloadPolicy({
provider: typeof model.provider === "string" ? model.provider : undefined,
api: typeof model.api === "string" ? model.api : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
serviceTier,
});
if (!payloadPolicy.allowsServiceTier) {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
if (payloadObj.service_tier === undefined) {
payloadObj.service_tier = serviceTier;
}
});
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
);
};
}

View File

@@ -245,6 +245,34 @@ describe("bluebubbles setup surface", () => {
expect(resolved.configured).toBe(true);
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const { blueBubblesSetupWizard } = await import("./setup-surface.js");
const configured = await blueBubblesSetupWizard.status.resolveConfigured({
cfg: {
channels: {
bluebubbles: {
defaultAccount: "work",
serverUrl: "http://localhost:3000",
password: "top-secret",
accounts: {
alerts: {
serverUrl: "http://localhost:4000",
password: "alerts-secret",
},
work: {
serverUrl: "",
password: "",
},
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(false);
});
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => {
const { blueBubblesSetupWizard } = await import("./setup-surface.js");

View File

@@ -174,10 +174,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
(accountId ? [accountId] : listBlueBubblesAccountIds(cfg)).some((resolvedAccountId) => {
const account = resolveBlueBubblesAccount({ cfg, accountId: resolvedAccountId });
return account.configured;
}),
resolveBlueBubblesAccount({ cfg, accountId }).configured,
}),
resolveSelectionHint: ({ configured }) =>
configured ? "configured" : "iMessage via BlueBubbles app",

View File

@@ -0,0 +1,16 @@
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-config-contract.js";
export {
unsupportedSecretRefSurfacePatterns,
collectUnsupportedSecretRefConfigCandidates,
} from "./src/security-contract.js";
export { deriveLegacySessionChatType } from "./src/session-contract.js";
export type {
DiscordInteractiveHandlerContext,
DiscordInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";

View File

@@ -12,7 +12,7 @@ type DiscordGuild = { id: string; name: string };
type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean };
type DiscordMember = { user: DiscordUser; nick?: string | null };
type DiscordChannel = { id: string; name?: string | null };
type DiscordDirectoryAccess = { token: string; query: string };
type DiscordDirectoryAccess = { token: string; query: string; accountId: string };
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
@@ -30,7 +30,7 @@ function resolveDiscordDirectoryAccess(
if (!token) {
return null;
}
return { token, query: normalizeQuery(params.query) };
return { token, query: normalizeQuery(params.query), accountId: account.accountId };
}
async function listDiscordGuilds(token: string): Promise<DiscordGuild[]> {
@@ -45,7 +45,7 @@ export async function listDiscordDirectoryGroupsLive(
if (!access) {
return [];
}
const { token, query } = access;
const { token, query, accountId } = access;
const guilds = await listDiscordGuilds(token);
const rows: ChannelDirectoryEntry[] = [];
@@ -82,7 +82,7 @@ export async function listDiscordDirectoryPeersLive(
if (!access) {
return [];
}
const { token, query } = access;
const { token, query, accountId } = access;
if (!query) {
return [];
}
@@ -106,7 +106,7 @@ export async function listDiscordDirectoryPeersLive(
continue;
}
rememberDiscordDirectoryUser({
accountId: params.accountId,
accountId,
userId: user.id,
handles: [
user.username,

View File

@@ -0,0 +1,292 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeDiscordStreamingAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveDiscordPreviewStreamMode(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
);
}
if (
params.pathPrefix.startsWith("channels.discord") &&
resolved === "off" &&
hadLegacyStreamMode
) {
params.changes.push(
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
);
}
return { entry: updated, changed };
}
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
);
}
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
}
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
function hasLegacyTtsProviderKeys(value: unknown): boolean {
const tts = asObjectRecord(value);
if (!tts) {
return false;
}
return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key));
}
function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((accountValue) => {
const account = asObjectRecord(accountValue);
const voice = asObjectRecord(account?.voice);
return hasLegacyTtsProviderKeys(voice?.tts);
});
}
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>) {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) {
continue;
}
const existing = target[key];
if (existing === undefined) {
target[key] = value;
continue;
}
if (
existing &&
typeof existing === "object" &&
!Array.isArray(existing) &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
mergeMissing(existing as Record<string, unknown>, value as Record<string, unknown>);
}
}
}
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
const providers = asObjectRecord(tts.providers) ?? {};
tts.providers = providers;
return providers;
}
function mergeLegacyTtsProviderConfig(
tts: Record<string, unknown>,
legacyKey: string,
providerId: string,
): boolean {
const legacyValue = asObjectRecord(tts[legacyKey]);
if (!legacyValue) {
return false;
}
const providers = getOrCreateTtsProviders(tts);
const existing = asObjectRecord(providers[providerId]) ?? {};
const merged = structuredClone(existing);
mergeMissing(merged, legacyValue);
providers[providerId] = merged;
delete tts[legacyKey];
return true;
}
function migrateLegacyTtsConfig(
tts: Record<string, unknown> | null,
pathLabel: string,
changes: string[],
): boolean {
if (!tts) {
return false;
}
let changed = false;
if (mergeLegacyTtsProviderConfig(tts, "openai", "openai")) {
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs")) {
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft")) {
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "edge", "microsoft")) {
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
changed = true;
}
return changed;
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
match: hasLegacyDiscordAccountStreamingAliases,
},
{
path: ["channels", "discord", "voice", "tts"],
message:
"channels.discord.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers.<provider> (auto-migrated on load).",
match: hasLegacyTtsProviderKeys,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider> (auto-migrated on load).",
match: hasLegacyDiscordAccountTtsProviderKeys,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.discord);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const streaming = normalizeDiscordStreamingAliases({
entry: updated,
pathPrefix: "channels.discord",
changes,
});
updated = streaming.entry;
changed = changed || streaming.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
const account = asObjectRecord(rawAccount);
if (!account) {
continue;
}
const accountStreaming = normalizeDiscordStreamingAliases({
entry: account,
pathPrefix: `channels.discord.accounts.${accountId}`,
changes,
});
if (accountStreaming.changed) {
accounts[accountId] = accountStreaming.entry;
accountsChanged = true;
}
const accountVoice = asObjectRecord(accountStreaming.entry.voice);
if (
accountVoice &&
migrateLegacyTtsConfig(
asObjectRecord(accountVoice.tts),
`channels.discord.accounts.${accountId}.voice.tts`,
changes,
)
) {
accounts[accountId] = {
...accountStreaming.entry,
voice: accountVoice,
};
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
const voice = asObjectRecord(updated.voice);
if (
voice &&
migrateLegacyTtsConfig(asObjectRecord(voice.tts), "channels.discord.voice.tts", changes)
) {
updated = { ...updated, voice };
changed = true;
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
discord: updated,
} as OpenClawConfig["channels"],
},
changes,
};
}

View File

@@ -1,15 +1,12 @@
import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
type ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import {
resolveDiscordPreviewStreamMode,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
collectProviderDangerousNameMatchingScopes,
isDiscordMutableAllowEntry,
} from "openclaw/plugin-sdk/runtime";
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
import { isDiscordMutableAllowEntry } from "./security-audit.js";
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
@@ -517,11 +514,48 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
];
}
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
);
}
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
}
const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
match: hasLegacyDiscordAccountStreamingAliases,
},
];
export const discordDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: DISCORD_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg),
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
collectDiscordNumericIdWarnings({

View File

@@ -2,9 +2,17 @@ import { ChannelType } from "@buape/carbon";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn());
const handleDiscordDmCommandDecisionMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./preflight-audio.runtime.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
transcribeFirstAudio: transcribeFirstAudioMock,
}));
vi.mock("./dm-command-auth.js", () => ({
resolveDiscordDmCommandAccess: resolveDiscordDmCommandAccessMock,
}));
vi.mock("./dm-command-decision.js", () => ({
handleDiscordDmCommandDecision: handleDiscordDmCommandDecisionMock,
}));
import {
__testing as sessionBindingTesting,
@@ -261,6 +269,14 @@ describe("preflightDiscordMessage", () => {
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
transcribeFirstAudioMock.mockReset();
resolveDiscordDmCommandAccessMock.mockReset();
resolveDiscordDmCommandAccessMock.mockResolvedValue({
commandAuthorized: true,
decision: "allow",
allowMatch: { allowed: true, matchedBy: "allowFrom", value: "123" },
});
handleDiscordDmCommandDecisionMock.mockReset();
handleDiscordDmCommandDecisionMock.mockResolvedValue(undefined);
});
it("drops bound-thread bot system messages to prevent ACP self-loop", async () => {
@@ -349,6 +365,56 @@ describe("preflightDiscordMessage", () => {
});
});
it("falls back to the default discord account for omitted-account dm authorization", async () => {
const message = createDiscordMessage({
id: "m-dm-default-account",
channelId: "dm-channel-default-account",
content: "who are you",
author: {
id: "user-1",
bot: false,
username: "alice",
},
});
await preflightDiscordMessage({
...createPreflightArgs({
cfg: {
...DEFAULT_PREFLIGHT_CFG,
channels: {
discord: {
defaultAccount: "work",
accounts: {
default: {
token: "token-default",
},
work: {
token: "token-work",
},
},
},
},
},
discordConfig: {
defaultAccount: "work",
dmPolicy: "allowlist",
} as DiscordConfig,
data: {
channel_id: "dm-channel-default-account",
author: message.author,
message,
} as DiscordMessageEvent,
client: createDmClient("dm-channel-default-account"),
}),
});
expect(resolveDiscordDmCommandAccessMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
}),
);
});
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
const threadBinding = createThreadBinding({
targetKind: "session",

View File

@@ -34,6 +34,7 @@ import {
} from "./allow-list.js";
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
import { resolveDefaultDiscordAccountId } from "../accounts.js";
import {
formatDiscordUserTag,
resolveDiscordSystemLocation,
@@ -386,7 +387,7 @@ export async function preflightDiscordMessage(
const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
const resolvedAccountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg);
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
let commandAuthorized = true;
if (isDirectMessage) {

View File

@@ -16,7 +16,6 @@ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pi
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
@@ -40,6 +39,7 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
import { createDiscordDraftStream } from "../draft-stream.js";
import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js";
import { removeReactionDiscord } from "../send.js";
import { editMessageDiscord } from "../send.messages.js";
import {

View File

@@ -6,7 +6,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import * as dispatcherModule from "openclaw/plugin-sdk/reply-dispatch-runtime";
import * as globalsModule from "openclaw/plugin-sdk/runtime-env";
import * as commandTextModule from "openclaw/plugin-sdk/text-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
@@ -248,12 +248,17 @@ function createDispatchSpy() {
describe("Discord model picker interactions", () => {
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
nativeCommandTesting.setDispatchReplyWithDispatcher(
dispatcherModule.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
);
});
afterEach(() => {
vi.useRealTimers();
});
it("registers distinct fallback ids for button and select handlers", () => {
const context = createModelPickerContext();
const button = createDiscordModelPickerFallbackButton(context);

View File

@@ -11,22 +11,34 @@ import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtim
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type ConversationRuntimeModule = typeof import("openclaw/plugin-sdk/conversation-runtime");
type ResolveConfiguredBindingRoute = ConversationRuntimeModule["resolveConfiguredBindingRoute"];
type ConfiguredBindingRouteResult = ReturnType<ResolveConfiguredBindingRoute>;
type EnsureConfiguredBindingRouteReady =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
type ResolveConfiguredBindingRoute =
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
ConversationRuntimeModule["ensureConfiguredBindingRouteReady"];
function createUnboundConfiguredRouteResult(): ConfiguredBindingRouteResult {
return {
bindingResolution: null,
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: SESSION_KEY,
mainSessionKey: SESSION_KEY,
lastRoutePolicy: "main",
matchedBy: "default",
},
};
}
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
vi.fn<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
);
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
vi.fn<ResolveConfiguredBindingRoute>(({ route }) => ({
bindingResolution: null,
route,
})),
vi.fn<ResolveConfiguredBindingRoute>(() => createUnboundConfiguredRouteResult()),
);
type ConfiguredBindingRoute = ReturnType<ResolveConfiguredBindingRoute>;
type ConfiguredBindingRoute = ConfiguredBindingRouteResult;
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
function createConfiguredRouteResult(
@@ -35,6 +47,11 @@ function createConfiguredRouteResult(
return {
bindingResolution: {
record: {
bindingId: "binding-1",
targetSessionKey: SESSION_KEY,
targetKind: "session",
status: "active",
boundAt: Date.now(),
conversation: {
channel: "discord",
accountId: "default",
@@ -87,10 +104,7 @@ describe("discord native /think autocomplete", () => {
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
resolveConfiguredBindingRouteMock.mockReset();
resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({
bindingResolution: null,
route,
}));
resolveConfiguredBindingRouteMock.mockReturnValue(createUnboundConfiguredRouteResult());
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
fs.writeFileSync(
STORE_PATH,

View File

@@ -1063,6 +1063,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
eventQueueListenerTimeoutMs: eventQueueOpts.listenerTimeout,
});
logDiscordStartupPhase({
runtime,
accountId: account.accountId,
phase: "client-start",
startAt: startupStartedAt,
gateway: lifecycleGateway,
});
const botIdentity =
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
runtime.log?.(

View File

@@ -0,0 +1,51 @@
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
function normalizeStreamingMode(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized || null;
}
function parseStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null {
const normalized = normalizeStreamingMode(value);
if (
normalized === "off" ||
normalized === "partial" ||
normalized === "block" ||
normalized === "progress"
) {
return normalized;
}
return null;
}
function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
return null;
}
return parsed === "progress" ? "partial" : parsed;
}
export function resolveDiscordPreviewStreamMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): DiscordPreviewStreamMode {
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming;
}
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "off";
}

View File

@@ -0,0 +1,140 @@
import {
collectNestedChannelFieldAssignments,
collectNestedChannelTtsAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
isBaseFieldActiveForChannelSurface,
isEnabledFlag,
isRecord,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.discord.accounts.*.pluralkit.token",
targetType: "channels.discord.accounts.*.pluralkit.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.pluralkit.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.token",
targetType: "channels.discord.accounts.*.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
targetType: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 6,
},
{
id: "channels.discord.pluralkit.token",
targetType: "channels.discord.pluralkit.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.pluralkit.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.token",
targetType: "channels.discord.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.voice.tts.providers.*.apiKey",
targetType: "channels.discord.voice.tts.providers.*.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.voice.tts.providers.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 4,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "discord");
if (!resolved) {
return;
}
const { channel: discord, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "discord",
field: "token",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Discord token.",
accountInactiveReason: "Discord account is disabled.",
});
collectNestedChannelFieldAssignments({
channelKey: "discord",
nestedKey: "pluralkit",
field: "token",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topLevelActive:
isBaseFieldActiveForChannelSurface(surface, "pluralkit") &&
isRecord(discord.pluralkit) &&
isEnabledFlag(discord.pluralkit),
topInactiveReason:
"no enabled Discord surface inherits this top-level PluralKit config or PluralKit is disabled.",
accountActive: ({ account, enabled }) =>
enabled && isRecord(account.pluralkit) && isEnabledFlag(account.pluralkit),
accountInactiveReason: "Discord account is disabled or PluralKit is disabled for this account.",
});
collectNestedChannelTtsAssignments({
channelKey: "discord",
nestedKey: "voice",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topLevelActive:
isBaseFieldActiveForChannelSurface(surface, "voice") &&
isRecord(discord.voice) &&
isEnabledFlag(discord.voice),
topInactiveReason:
"no enabled Discord surface inherits this top-level voice config or voice is disabled.",
accountActive: ({ account, enabled }) =>
enabled && isRecord(account.voice) && isEnabledFlag(account.voice),
accountInactiveReason: "Discord account is disabled or voice is disabled for this account.",
});
}

View File

@@ -21,7 +21,7 @@ function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
return undefined;
}
function isDiscordMutableAllowEntry(raw: string): boolean {
export function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;

View File

@@ -0,0 +1,49 @@
type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export const unsupportedSecretRefSurfacePatterns = [
"channels.discord.threadBindings.webhookToken",
"channels.discord.accounts.*.threadBindings.webhookToken",
] as const;
export function collectUnsupportedSecretRefConfigCandidates(
raw: unknown,
): UnsupportedSecretRefConfigCandidate[] {
if (!isRecord(raw)) {
return [];
}
if (!isRecord(raw.channels) || !isRecord(raw.channels.discord)) {
return [];
}
const candidates: UnsupportedSecretRefConfigCandidate[] = [];
const discord = raw.channels.discord;
const threadBindings = isRecord(discord.threadBindings) ? discord.threadBindings : null;
if (threadBindings) {
candidates.push({
path: "channels.discord.threadBindings.webhookToken",
value: threadBindings.webhookToken,
});
}
const accounts = isRecord(discord.accounts) ? discord.accounts : null;
if (!accounts) {
return candidates;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!isRecord(account) || !isRecord(account.threadBindings)) {
continue;
}
candidates.push({
path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`,
value: account.threadBindings.webhookToken,
});
}
return candidates;
}

View File

@@ -365,15 +365,15 @@ export async function sendWebhookMessageDiscord(
throw new Error("Discord webhook id/token are required");
}
const rewrittenText = rewriteDiscordKnownMentions(text, {
accountId: opts.accountId,
});
const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : "";
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const { account, proxyFetch } = resolveDiscordClientAccountContext({
cfg: opts.cfg,
accountId: opts.accountId,
});
const rewrittenText = rewriteDiscordKnownMentions(text, {
accountId: account.accountId,
});
const response = await (proxyFetch ?? fetch)(
resolveWebhookExecutionUrl({
@@ -430,11 +430,16 @@ export async function sendStickerDiscord(
stickerIds: string[],
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({
cfg,
accountId: opts.accountId,
});
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
const content = opts.content?.trim();
const rewrittenContent = content
? rewriteDiscordKnownMentions(content, {
accountId: opts.accountId,
accountId: accountInfo.accountId,
})
: undefined;
const stickers = normalizeStickerIds(stickerIds);
@@ -456,11 +461,16 @@ export async function sendPollDiscord(
poll: PollInput,
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({
cfg,
accountId: opts.accountId,
});
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
const content = opts.content?.trim();
const rewrittenContent = content
? rewriteDiscordKnownMentions(content, {
accountId: opts.accountId,
accountId: accountInfo.accountId,
})
: undefined;
if (poll.durationSeconds !== undefined) {

View File

@@ -126,6 +126,40 @@ describe("sendMessageDiscord", () => {
);
});
it("uses configured defaultAccount for cached mention rewriting when accountId is omitted", async () => {
rememberDiscordDirectoryUser({
accountId: "work",
userId: "222333444555666777",
handles: ["Alice"],
});
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
postMock.mockResolvedValue({
id: "msg1",
channel_id: "789",
});
await sendMessageDiscord("channel:789", "ping @Alice", {
rest,
token: "t",
cfg: {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: {
token: "Bot work-token", // pragma: allowlist secret
},
},
},
},
} as never,
});
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({ body: { content: "ping <@222333444555666777>" } }),
);
});
it("auto-creates a forum thread when target is a Forum channel", async () => {
const { rest, postMock, getMock } = makeDiscordRest();
// Channel type lookup returns a Forum channel.

View File

@@ -0,0 +1,3 @@
export function deriveLegacySessionChatType(sessionKey: string): "channel" | undefined {
return /^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(sessionKey) ? "channel" : undefined;
}

View File

@@ -109,10 +109,7 @@ export function createDiscordSetupWizardBase(handlers: {
configuredScore: 2,
unconfiguredScore: 1,
resolveConfigured: ({ cfg, accountId }) =>
(accountId ? [accountId] : listDiscordSetupAccountIds(cfg)).some((resolvedAccountId) => {
const account = inspectDiscordSetupAccount({ cfg, accountId: resolvedAccountId });
return account.configured;
}),
inspectDiscordSetupAccount({ cfg, accountId }).configured,
}),
credentials: [
{

View File

@@ -54,3 +54,28 @@ describe("discordSetupWizard.dmPolicy", () => {
expect(next?.channels?.discord?.accounts?.alerts?.allowFrom).toEqual(["123", "*"]);
});
});
describe("discordSetupWizard.status", () => {
it("uses configured defaultAccount for omitted setup configured state", async () => {
const configured = await discordSetupWizard.status.resolveConfigured({
cfg: {
channels: {
discord: {
defaultAccount: "work",
token: "discord-root-token",
accounts: {
alerts: {
token: "discord-alerts-token",
},
work: {
token: "",
},
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(false);
});
});

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetDiscordDirectoryCacheForTest,
resolveDiscordDirectoryUserId,
} from "./directory-cache.js";
import * as directoryLive from "./directory-live.js";
import {
resolveDiscordGroupRequireMention,
@@ -76,6 +80,7 @@ describe("resolveDiscordTarget", () => {
beforeEach(() => {
vi.restoreAllMocks();
__resetDiscordDirectoryCacheForTest();
});
it("returns a resolved user for usernames", async () => {
@@ -102,6 +107,33 @@ describe("resolveDiscordTarget", () => {
).resolves.toMatchObject({ kind: "user", id: "123" });
expect(listPeers).not.toHaveBeenCalled();
});
it("caches username lookups under the configured default account when accountId is omitted", async () => {
const cfg = {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: {
token: "discord-work",
},
},
},
},
} as OpenClawConfig;
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([
{ kind: "user", id: "user:999", name: "Jane" } as const,
]);
await expect(resolveDiscordTarget("jane", { cfg })).resolves.toMatchObject({
kind: "user",
id: "999",
normalized: "user:999",
});
expect(resolveDiscordDirectoryUserId({ accountId: "work", handle: "jane" })).toBe("999");
expect(resolveDiscordDirectoryUserId({ accountId: "default", handle: "jane" })).toBeUndefined();
});
});
describe("normalizeDiscordMessagingTarget", () => {

View File

@@ -7,6 +7,7 @@ import {
type MessagingTargetParseOptions,
} from "openclaw/plugin-sdk/channel-targets";
import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime";
import { resolveDiscordAccount } from "./accounts.js";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
@@ -100,8 +101,12 @@ export async function resolveDiscordTarget(
if (match && match.kind === "user") {
// Extract user ID from the directory entry (format: "user:<id>")
const userId = match.id.replace(/^user:/, "");
rememberDiscordDirectoryUser({
const resolvedAccountId = resolveDiscordAccount({
cfg: options.cfg,
accountId: options.accountId,
}).accountId;
rememberDiscordDirectoryUser({
accountId: resolvedAccountId,
userId,
handles: [trimmed, match.name, match.handle],
});

View File

@@ -0,0 +1,6 @@
export { createFeishuThreadBindingManager } from "./src/thread-bindings.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { messageActionTargetAliases } from "./src/message-action-contract.js";

View File

@@ -1,25 +1,48 @@
// Private runtime barrel for the bundled Feishu extension.
// Keep this barrel thin and aligned with the local extension surface.
// Keep this barrel thin and generic-only.
export type {
AllowlistMatch,
AnyAgentTool,
BaseProbeResult,
ChannelGroupContext,
ChannelMessageActionName,
ChannelMeta,
ChannelOutboundAdapter,
OpenClawConfig as ClawdbotConfig,
ChannelPlugin,
HistoryEntry,
OpenClawConfig,
OpenClawPluginApi,
OutboundIdentity,
PluginRuntime,
RuntimeEnv,
} from "openclaw/plugin-sdk/feishu";
ReplyPayload,
} from "openclaw/plugin-sdk/core";
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime";
export {
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
createActionGate,
createDedupeCache,
} from "openclaw/plugin-sdk/core";
export {
PAIRING_APPROVED_MESSAGE,
buildProbeChannelStatusSummary,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/feishu";
export * from "openclaw/plugin-sdk/feishu";
} from "openclaw/plugin-sdk/channel-status";
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline";
export {
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,
resolveChannelContextVisibilityMode,
} from "openclaw/plugin-sdk/config-runtime";
export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
export { normalizeAgentId } from "openclaw/plugin-sdk/routing";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
isRequestBodyLimitError,
readRequestBodyWithLimit,

View File

@@ -51,6 +51,7 @@ import type {
import { createFeishuClient } from "./client.js";
import { FeishuConfigSchema } from "./config-schema.js";
import {
buildFeishuModelOverrideParentCandidates,
buildFeishuConversationId,
parseFeishuConversationId,
parseFeishuDirectConversationId,
@@ -59,6 +60,7 @@ import {
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import { collectFeishuSecurityAuditFindings } from "./security-audit.js";
import {
resolveFeishuParentConversationCandidates,
resolveFeishuSessionConversation,
@@ -146,7 +148,9 @@ function describeFeishuMessageTool({
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const enabledAccounts = accountId
? [resolveFeishuAccount({ cfg, accountId })].filter((account) => account.enabled && account.configured)
? [resolveFeishuAccount({ cfg, accountId })].filter(
(account) => account.enabled && account.configured,
)
: listEnabledFeishuAccounts(cfg);
const enabled =
enabledAccounts.length > 0 ||
@@ -179,9 +183,9 @@ function describeFeishuMessageTool({
"channel-list",
]);
if (
(accountId
accountId
? enabledAccounts.some((account) => isFeishuReactionsActionEnabled({ cfg, account }))
: areAnyFeishuReactionActionsEnabled(cfg))
: areAnyFeishuReactionActionsEnabled(cfg)
) {
actions.add("react");
actions.add("reactions");
@@ -567,6 +571,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
},
conversationBindings: {
defaultTopLevelPlacement: "current",
buildModelOverrideParentCandidates: ({ parentConversationId }) =>
buildFeishuModelOverrideParentCandidates(parentConversationId),
},
mentions: {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
@@ -1180,6 +1186,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
cfg: ClawdbotConfig;
accountId?: string | null;
}>(collectFeishuSecurityWarnings),
collectAuditFindings: ({ cfg }) => collectFeishuSecurityAuditFindings({ cfg }),
},
pairing: {
text: {

View File

@@ -166,3 +166,32 @@ export function parseFeishuConversationId(params: {
scope: "group",
};
}
export function buildFeishuModelOverrideParentCandidates(
parentConversationId?: string | null,
): string[] {
const rawId = normalizeText(parentConversationId);
if (!rawId) {
return [];
}
const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
if (topicSenderMatch) {
const chatId = topicSenderMatch[1]?.trim().toLowerCase();
const topicId = topicSenderMatch[2]?.trim().toLowerCase();
if (chatId && topicId) {
return [`${chatId}:topic:${topicId}`, chatId];
}
return [];
}
const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i);
if (topicMatch) {
const chatId = topicMatch[1]?.trim().toLowerCase();
return chatId ? [chatId] : [];
}
const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i);
if (senderMatch) {
const chatId = senderMatch[1]?.trim().toLowerCase();
return chatId ? [chatId] : [];
}
return [];
}

View File

@@ -0,0 +1,13 @@
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
type MessageActionTargetAliasSpec = {
aliases: string[];
};
export const messageActionTargetAliases = {
read: { aliases: ["messageId"] },
pin: { aliases: ["messageId"] },
unpin: { aliases: ["messageId"] },
"list-pins": { aliases: ["chatId"] },
"channel-info": { aliases: ["chatId"] },
} satisfies Partial<Record<ChannelMessageActionName, MessageActionTargetAliasSpec>>;

View File

@@ -0,0 +1,140 @@
import {
collectConditionalChannelFieldAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
normalizeSecretStringValue,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.feishu.accounts.*.appSecret",
targetType: "channels.feishu.accounts.*.appSecret",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.appSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.accounts.*.encryptKey",
targetType: "channels.feishu.accounts.*.encryptKey",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.encryptKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.accounts.*.verificationToken",
targetType: "channels.feishu.accounts.*.verificationToken",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.verificationToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.appSecret",
targetType: "channels.feishu.appSecret",
configFile: "openclaw.json",
pathPattern: "channels.feishu.appSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.encryptKey",
targetType: "channels.feishu.encryptKey",
configFile: "openclaw.json",
pathPattern: "channels.feishu.encryptKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.verificationToken",
targetType: "channels.feishu.verificationToken",
configFile: "openclaw.json",
pathPattern: "channels.feishu.verificationToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "feishu");
if (!resolved) {
return;
}
const { channel: feishu, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "feishu",
field: "appSecret",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Feishu appSecret.",
accountInactiveReason: "Feishu account is disabled.",
});
const baseConnectionMode =
normalizeSecretStringValue(feishu.connectionMode) === "webhook" ? "webhook" : "websocket";
const resolveAccountMode = (account: Record<string, unknown>) =>
hasOwnProperty(account, "connectionMode")
? normalizeSecretStringValue(account.connectionMode)
: baseConnectionMode;
collectConditionalChannelFieldAssignments({
channelKey: "feishu",
field: "encryptKey",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "encryptKey") &&
resolveAccountMode(account) === "webhook",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
topInactiveReason: "no enabled Feishu webhook-mode surface inherits this top-level encryptKey.",
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
});
collectConditionalChannelFieldAssignments({
channelKey: "feishu",
field: "verificationToken",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "verificationToken") &&
resolveAccountMode(account) === "webhook",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
topInactiveReason:
"no enabled Feishu webhook-mode surface inherits this top-level verificationToken.",
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
});
}

View File

@@ -0,0 +1,70 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean {
const channels = asRecord(cfg.channels);
const feishu = asRecord(channels?.feishu);
if (!feishu || feishu.enabled === false) {
return false;
}
const baseTools = asRecord(feishu.tools);
const baseDocEnabled = baseTools?.doc !== false;
const baseAppId = hasNonEmptyString(feishu.appId);
const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret, cfg.secrets?.defaults);
const baseConfigured = baseAppId && baseAppSecret;
const accounts = asRecord(feishu.accounts);
if (!accounts || Object.keys(accounts).length === 0) {
return baseDocEnabled && baseConfigured;
}
for (const accountValue of Object.values(accounts)) {
const account = asRecord(accountValue) ?? {};
if (account.enabled === false) {
continue;
}
const accountTools = asRecord(account.tools);
const effectiveTools = accountTools ?? baseTools;
const docEnabled = effectiveTools?.doc !== false;
if (!docEnabled) {
continue;
}
const accountConfigured =
(hasNonEmptyString(account.appId) || baseAppId) &&
(hasConfiguredSecretInput(account.appSecret, cfg.secrets?.defaults) || baseAppSecret);
if (accountConfigured) {
return true;
}
}
return false;
}
export function collectFeishuSecurityAuditFindings(params: { cfg: OpenClawConfig }) {
if (!isFeishuDocToolEnabled(params.cfg)) {
return [];
}
return [
{
checkId: "channels.feishu.doc_owner_open_id",
severity: "warn" as const,
title: "Feishu doc create can grant requester permissions",
detail:
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.',
remediation:
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.",
},
];
}

View File

@@ -318,6 +318,34 @@ describe("feishu setup wizard status", () => {
expect(status.statusLines).toEqual(["Feishu: needs app credentials"]);
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const status = await feishuGetStatus({
cfg: {
channels: {
feishu: {
defaultAccount: "work",
appId: "top_level_app",
appSecret: "top-level-secret", // pragma: allowlist secret
accounts: {
alerts: {
appId: "alerts-app",
appSecret: "alerts-secret", // pragma: allowlist secret
},
work: {
appId: "",
appSecret: "work-secret", // pragma: allowlist secret
},
},
},
},
} as never,
accountOverrides: {},
});
expect(status.configured).toBe(false);
expect(status.statusLines).toEqual(["Feishu: needs app credentials"]);
});
it("uses configured defaultAccount for omitted DM policy account context", async () => {
const { feishuSetupWizard } = await import("./setup-surface.js");
const cfg = {

View File

@@ -98,8 +98,9 @@ function setFeishuGroupAllowFrom(
return patchFeishuConfig(cfg, accountId, { groupAllowFrom });
}
function isFeishuConfigured(cfg: OpenClawConfig): boolean {
function isFeishuConfigured(cfg: OpenClawConfig, accountId?: string | null): boolean {
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
const resolvedAccountId = normalizeString(accountId) ?? resolveDefaultFeishuAccountId(cfg);
const isAppIdConfigured = (value: unknown): boolean => {
const asString = normalizeString(value);
@@ -122,22 +123,25 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean {
isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret),
);
const accountConfigured = Object.values(feishuCfg.accounts ?? {}).some((account) => {
if (!account || typeof account !== "object") {
return false;
}
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
const accountAppIdConfigured = hasOwnAppId
? isAppIdConfigured((account as Record<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
: hasConfiguredSecretInput(feishuCfg?.appSecret);
return Boolean(accountAppIdConfigured && accountSecretConfigured);
});
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
return topLevelConfigured;
}
return topLevelConfigured || accountConfigured;
const account = feishuCfg.accounts?.[resolvedAccountId];
if (!account || typeof account !== "object") {
return topLevelConfigured;
}
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
const accountAppIdConfigured = hasOwnAppId
? isAppIdConfigured((account as Record<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
: hasConfiguredSecretInput(feishuCfg?.appSecret);
return Boolean(accountAppIdConfigured && accountSecretConfigured);
}
async function promptFeishuAllowFrom(params: {
@@ -258,8 +262,7 @@ export const feishuSetupWizard: ChannelSetupWizard = {
unconfiguredHint: "needs app creds",
configuredScore: 2,
unconfiguredScore: 0,
resolveConfigured: ({ cfg, accountId }) =>
accountId ? resolveFeishuAccount({ cfg, accountId }).configured : isFeishuConfigured(cfg),
resolveConfigured: ({ cfg, accountId }) => isFeishuConfigured(cfg, accountId),
resolveStatusLines: async ({ cfg, accountId, configured }) => {
const resolvedCredentials = accountId
? (() => {

View File

@@ -81,7 +81,7 @@ describe("feishu tool account routing", () => {
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("wiki tool prefers configured defaultAccount over inherited default account context", async () => {
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
const { api, resolveTool } = createToolFactoryHarness(
createConfig({
defaultAccount: "b",
@@ -94,7 +94,7 @@ describe("feishu tool account routing", () => {
const tool = resolveTool("feishu_wiki", { agentAccountId: "a" });
await tool.execute("call", { action: "search" });
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {

View File

@@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -50,6 +50,7 @@ import {
type OpenClawConfig,
type ResolvedGoogleChatAccount,
} from "./channel.deps.runtime.js";
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { googlechatSetupAdapter } from "./setup-core.js";
@@ -218,6 +219,7 @@ export const googlechatPlugin = createChatChannelPlugin({
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
},
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),

View File

@@ -0,0 +1,57 @@
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isGoogleChatMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
if (!withoutPrefix) {
return false;
}
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
return withoutUsers.includes("@");
}
export const collectGoogleChatMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "googlechat",
detector: isGoogleChatMutableAllowEntry,
collectLists: (scope) => {
const lists = [
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
];
const dm = asObjectRecord(scope.account.dm);
if (dm) {
lists.push({
pathLabel: `${scope.prefix}.dm.allowFrom`,
list: dm.allowFrom,
});
}
const groups = asObjectRecord(scope.account.groups);
if (groups) {
for (const [groupKey, groupRaw] of Object.entries(groups)) {
const group = asObjectRecord(groupRaw);
if (!group) {
continue;
}
lists.push({
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
list: group.users,
});
}
}
return lists;
},
});

View File

@@ -0,0 +1,156 @@
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
import {
getChannelSurface,
hasOwnProperty,
pushAssignment,
pushInactiveSurfaceWarning,
pushWarning,
resolveChannelAccountSurface,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
type GoogleChatAccountLike = {
serviceAccount?: unknown;
serviceAccountRef?: unknown;
accounts?: Record<string, unknown>;
};
export const secretTargetRegistryEntries = [
{
id: "channels.googlechat.accounts.*.serviceAccount",
targetType: "channels.googlechat.serviceAccount",
targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"],
configFile: "openclaw.json",
pathPattern: "channels.googlechat.accounts.*.serviceAccount",
refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string-or-object",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
accountIdPathSegmentIndex: 3,
},
{
id: "channels.googlechat.serviceAccount",
targetType: "channels.googlechat.serviceAccount",
configFile: "openclaw.json",
pathPattern: "channels.googlechat.serviceAccount",
refPathPattern: "channels.googlechat.serviceAccountRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string-or-object",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
function resolveSecretInputRef(params: {
value: unknown;
refValue?: unknown;
defaults?: SecretDefaults;
}) {
const explicitRef = coerceSecretRef(params.refValue, params.defaults);
const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults);
return {
explicitRef,
inlineRef,
ref: explicitRef ?? inlineRef,
};
}
function collectGoogleChatAccountAssignment(params: {
target: GoogleChatAccountLike;
path: string;
defaults: SecretDefaults | undefined;
context: ResolverContext;
active?: boolean;
inactiveReason?: string;
}): void {
const { explicitRef, ref } = resolveSecretInputRef({
value: params.target.serviceAccount,
refValue: params.target.serviceAccountRef,
defaults: params.defaults,
});
if (!ref) {
return;
}
if (params.active === false) {
pushInactiveSurfaceWarning({
context: params.context,
path: `${params.path}.serviceAccount`,
details: params.inactiveReason,
});
return;
}
if (
explicitRef &&
params.target.serviceAccount !== undefined &&
!coerceSecretRef(params.target.serviceAccount, params.defaults)
) {
pushWarning(params.context, {
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path: params.path,
message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
});
}
pushAssignment(params.context, {
ref,
path: `${params.path}.serviceAccount`,
expected: "string-or-object",
apply: (value) => {
params.target.serviceAccount = value;
},
});
}
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "googlechat");
if (!resolved) {
return;
}
const googleChat = resolved.channel as GoogleChatAccountLike;
const surface = resolveChannelAccountSurface(googleChat as Record<string, unknown>);
const topLevelServiceAccountActive = !surface.channelEnabled
? false
: !surface.hasExplicitAccounts
? true
: surface.accounts.some(
({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "serviceAccount") &&
!hasOwnProperty(account, "serviceAccountRef"),
);
collectGoogleChatAccountAssignment({
target: googleChat,
path: "channels.googlechat",
defaults: params.defaults,
context: params.context,
active: topLevelServiceAccountActive,
inactiveReason: "no enabled account inherits this top-level Google Chat serviceAccount.",
});
if (!surface.hasExplicitAccounts) {
return;
}
for (const { accountId, account, enabled } of surface.accounts) {
if (
!hasOwnProperty(account, "serviceAccount") &&
!hasOwnProperty(account, "serviceAccountRef")
) {
continue;
}
collectGoogleChatAccountAssignment({
target: account as GoogleChatAccountLike,
path: `channels.googlechat.accounts.${accountId}`,
defaults: params.defaults,
context: params.context,
active: enabled,
inactiveReason: "Google Chat account is disabled.",
});
}
}

View File

@@ -104,13 +104,7 @@ export const googlechatSetupWizard: ChannelSetupWizard = {
unconfiguredHint: "needs auth",
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
accountId
? resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none"
: listGoogleChatAccountIds(cfg).some(
(resolvedAccountId) =>
resolveGoogleChatAccount({ cfg, accountId: resolvedAccountId }).credentialSource !==
"none",
),
resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
}),
introNote: {
title: "Google Chat setup",

View File

@@ -211,6 +211,28 @@ describe("googlechat setup", () => {
expect(status.configured).toBe(false);
});
it("reports configured state for the configured defaultAccount instead of any account", async () => {
const status = await googlechatStatus({
cfg: {
channels: {
googlechat: {
defaultAccount: "alerts",
accounts: {
default: {
serviceAccount: { client_email: "default@example.com" },
},
alerts: {},
},
},
},
} as OpenClawConfig,
accountOverrides: {},
options: {},
});
expect(status.configured).toBe(false);
});
it("reports account-scoped config keys for named accounts", () => {
expect(googlechatPlugin.setupWizard?.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
policyKey: "channels.googlechat.accounts.alerts.dm.policy",

View File

@@ -3,6 +3,7 @@ export * from "./src/accounts.js";
export * from "./src/conversation-bindings.js";
export * from "./src/conversation-id.js";
export * from "./src/group-policy.js";
export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./src/normalize.js";
export { IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js";
export * from "./src/probe.js";
export * from "./src/target-parsing-helpers.js";

View File

@@ -0,0 +1,10 @@
export { createIMessageTestPlugin } from "./src/test-plugin.js";
export {
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./src/media-contract.js";

View File

@@ -135,6 +135,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
},
doctor: {
groupAllowFromFallbackToAllowFrom: false,
},
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ cfg, accountId }) =>
@@ -195,7 +198,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
dbPath: snapshot.dbPath ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
await (await loadIMessageChannelRuntime()).probeIMessageAccount({
await (
await loadIMessageChannelRuntime()
).probeIMessageAccount({
timeoutMs,
cliPath: account.config.cliPath,
dbPath: account.config.dbPath,

View File

@@ -0,0 +1,31 @@
import { mergeInboundPathRoots } from "openclaw/plugin-sdk/media-runtime";
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveIMessageAccount } from "./accounts.js";
export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const;
export function resolveIMessageAttachmentRoots(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount(params);
return mergeInboundPathRoots(
account.config.attachmentRoots,
params.cfg.channels?.imessage?.attachmentRoots,
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
);
}
export function resolveIMessageRemoteAttachmentRoots(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveIMessageAccount(params);
return mergeInboundPathRoots(
account.config.remoteAttachmentRoots,
params.cfg.channels?.imessage?.remoteAttachmentRoots,
account.config.attachmentRoots,
params.cfg.channels?.imessage?.attachmentRoots,
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
);
}

View File

@@ -20,12 +20,7 @@ import {
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import {
isInboundPathAllowed,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "openclaw/plugin-sdk/media-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
@@ -40,6 +35,10 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
import { resolveIMessageAccount } from "../accounts.js";
import { createIMessageRpcClient } from "../client.js";
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
import {
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "../media-contract.js";
import { probeIMessage } from "../probe.js";
import { sendMessageIMessage } from "../send.js";
import { normalizeIMessageHandle } from "../targets.js";

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./normalize.js";
describe("normalizeIMessageMessagingTarget", () => {
it("normalizes blank inputs to undefined", () => {
expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined();
});
it("preserves service prefixes for handles", () => {
expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333");
});
it("drops service prefixes for chat targets", () => {
expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123");
expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc");
expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chatidentifier:foo");
});
});
describe("looksLikeIMessageTargetId", () => {
it("detects common iMessage target forms", () => {
expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true);
expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true);
expect(looksLikeIMessageTargetId("user@example.com")).toBe(true);
expect(looksLikeIMessageTargetId("+15555550123")).toBe(true);
expect(looksLikeIMessageTargetId("")).toBe(false);
});
});

View File

@@ -193,12 +193,7 @@ export const imessageSetupStatusBase = {
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) =>
accountId
? resolveIMessageAccount({ cfg, accountId }).configured
: listIMessageAccountIds(cfg).some(
(listedAccountId) =>
resolveIMessageAccount({ cfg, accountId: listedAccountId }).configured,
),
resolveIMessageAccount({ cfg, accountId }).configured,
};
export function createIMessageSetupWizardProxy(loadWizard: () => Promise<ChannelSetupWizard>) {

View File

@@ -313,6 +313,28 @@ describe("imessage setup status", () => {
expect(status.statusLines).toContain("imsg: missing (/tmp/work-imsg)");
});
it("does not inherit configured state from a sibling when defaultAccount is named", async () => {
const status = await getIMessageSetupStatus({
cfg: {
channels: {
imessage: {
defaultAccount: "work",
accounts: {
default: {
cliPath: "/usr/local/bin/imsg",
},
work: {},
},
},
},
} as never,
accountOverrides: {},
});
expect(status.configured).toBe(false);
expect(status.statusLines).toContain("iMessage: needs setup");
});
});
describe("probeIMessage", () => {

View File

@@ -2,8 +2,8 @@ import { afterEach, describe, expect, it } from "vitest";
import {
listImportedBundledPluginFacadeIds,
resetFacadeRuntimeStateForTest,
} from "../plugin-sdk/facade-runtime.js";
import { createIMessageTestPlugin } from "./imessage-test-plugin.js";
} from "../../../src/plugin-sdk/facade-runtime.js";
import { createIMessageTestPlugin } from "./test-plugin.js";
afterEach(() => {
resetFacadeRuntimeStateForTest();

View File

@@ -1,7 +1,37 @@
import { normalizeIMessageHandle } from "../channels/plugins/normalize/imessage.js";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js";
import { resolveOutboundSendDep } from "../infra/outbound/send-deps.js";
import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers";
function normalizeIMessageTestHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("imessage:")) {
return normalizeIMessageTestHandle(trimmed.slice("imessage:".length));
}
if (lowered.startsWith("sms:")) {
return normalizeIMessageTestHandle(trimmed.slice("sms:".length));
}
if (lowered.startsWith("auto:")) {
return normalizeIMessageTestHandle(trimmed.slice("auto:".length));
}
if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) =>
match.toLowerCase(),
);
}
if (trimmed.includes("@")) {
return trimmed.toLowerCase();
}
const digits = trimmed.replace(/[^\d+]/g, "");
if (digits) {
return digits.startsWith("+") ? `+${digits.slice(1)}` : `+${digits}`;
}
return trimmed.replace(/\s+/g, "");
}
const defaultIMessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
@@ -77,6 +107,6 @@ export const createIMessageTestPlugin = (params?: {
},
hint: "<handle|chat_id:ID>",
},
normalizeTarget: (raw) => normalizeIMessageHandle(raw),
normalizeTarget: (raw) => normalizeIMessageTestHandle(raw),
},
});

View File

@@ -28,6 +28,7 @@ import {
type ResolvedIrcAccount,
} from "./accounts.js";
import { IrcChannelConfigSchema } from "./config-schema.js";
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
import { monitorIrcProvider } from "./monitor.js";
import {
normalizeIrcMessagingTarget,
@@ -187,6 +188,10 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
},
}),
},
doctor: {
groupAllowFromFallbackToAllowFrom: false,
collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings,
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });

View File

@@ -0,0 +1,53 @@
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isIrcMutableAllowEntry(raw: string): boolean {
const text = raw.trim().toLowerCase();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^irc:/, "")
.replace(/^user:/, "")
.trim();
return !normalized.includes("!") && !normalized.includes("@");
}
export const collectIrcMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "irc",
detector: isIrcMutableAllowEntry,
collectLists: (scope) => {
const lists = [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
];
const groups = asObjectRecord(scope.account.groups);
if (groups) {
for (const [groupKey, groupRaw] of Object.entries(groups)) {
const group = asObjectRecord(groupRaw);
if (!group) {
continue;
}
lists.push({
pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`,
list: group.allowFrom,
});
}
}
return lists;
},
});

View File

@@ -1,38 +1,51 @@
// Private runtime barrel for the bundled IRC extension.
// Keep this barrel thin and aligned with the local extension surface.
// Keep this barrel thin and generic-only.
export {
buildBaseChannelStatusSummary,
createAccountStatusSink,
chunkTextForOutbound,
createChannelPairingController,
DEFAULT_ACCOUNT_ID,
deliverFormattedTextWithAttachments,
dispatchInboundReplyWithBase,
getChatChannelMeta,
GROUP_POLICY_BLOCKED_LABEL,
isDangerousNameMatchingEnabled,
logInboundDrop,
PAIRING_APPROVED_MESSAGE,
readStoreAllowFromForDmPolicy,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveControlCommandGate,
resolveDefaultGroupPolicy,
resolveEffectiveAllowFromLists,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/irc";
export type {
BaseProbeResult,
BlockStreamingCoalesceConfig,
ChannelPlugin,
OpenClawConfig,
PluginRuntime,
} from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
GroupToolPolicyBySenderConfig,
GroupToolPolicyConfig,
MarkdownConfig,
OpenClawConfig,
OutboundReplyPayload,
PluginRuntime,
RuntimeEnv,
} from "openclaw/plugin-sdk/irc";
} from "openclaw/plugin-sdk/config-runtime";
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
getChatChannelMeta,
} from "openclaw/plugin-sdk/core";
export {
PAIRING_APPROVED_MESSAGE,
buildBaseChannelStatusSummary,
} from "openclaw/plugin-sdk/channel-status";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
export {
readStoreAllowFromForDmPolicy,
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/channel-policy";
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
deliverFormattedTextWithAttachments,
formatTextWithAttachmentLinks,
resolveOutboundMediaUrls,
} from "openclaw/plugin-sdk/reply-payload";
export {
GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
isDangerousNameMatchingEnabled,
} from "openclaw/plugin-sdk/config-runtime";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";

View File

@@ -179,12 +179,7 @@ export const ircSetupWizard: ChannelSetupWizard = {
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
accountId
? resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured
: listIrcAccountIds(cfg as CoreConfig).some(
(resolvedAccountId) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId: resolvedAccountId }).configured,
),
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured,
}),
introNote: {
title: "IRC setup",

View File

@@ -138,6 +138,32 @@ describe("irc setup", () => {
expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
});
it("setup status honors the configured default account", async () => {
const status = await ircStatus({
cfg: {
channels: {
irc: {
defaultAccount: "work",
accounts: {
ops: {
host: "irc.example.com",
nick: "ops-bot",
},
work: {
host: "irc.example.com",
nick: "",
},
},
},
},
} as CoreConfig,
accountOverrides: {},
});
expect(status.configured).toBe(false);
expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
});
it("stores nickserv and account config patches on the scoped account", () => {
const cfg: CoreConfig = { channels: { irc: {} } };

View File

@@ -1,12 +1,13 @@
export type {
ChannelAccountSnapshot,
ChannelPlugin,
OpenClawConfig,
OpenClawPluginApi,
PluginRuntime,
} from "openclaw/plugin-sdk/core";
export type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
export {

View File

@@ -0,0 +1,5 @@
export {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "./src/accounts.js";

View File

@@ -2,16 +2,19 @@
// Keep this barrel thin and aligned with the local extension surface.
export type {
ChannelAccountSnapshot,
ChannelPlugin,
OpenClawConfig,
OpenClawPluginApi,
PluginRuntime,
} from "openclaw/plugin-sdk/core";
export type {
ChannelGatewayContext,
ChannelStatusIssue,
} from "openclaw/plugin-sdk/channel-contract";
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
export {
buildComputedAccountStatusSnapshot,
@@ -46,6 +49,7 @@ export {
sendMessageLine,
} from "./src/send.js";
export { monitorLineProvider } from "./src/monitor.js";
export { hasLineDirectives, parseLineDirectives } from "./src/reply-payload-transform.js";
export * from "./src/accounts.js";
export * from "./src/bot-access.js";
@@ -55,6 +59,7 @@ export * from "./src/download.js";
export * from "./src/group-keys.js";
export * from "./src/markdown-to-line.js";
export * from "./src/probe.js";
export * from "./src/reply-payload-transform.js";
export * from "./src/send.js";
export * from "./src/signature.js";
export * from "./src/template-messages.js";

View File

@@ -8,6 +8,7 @@ import { lineChannelPluginCommon } from "./channel-shared.js";
import { lineGatewayAdapter } from "./gateway.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
import { lineOutboundAdapter } from "./outbound.js";
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
import { getLineRuntime } from "./runtime.js";
import { pushMessageLine } from "./send.js";
import { lineSetupAdapter } from "./setup-core.js";
@@ -74,6 +75,12 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
},
resolveInboundConversation: ({ to, conversationId }) =>
resolveLineInboundConversation({ to, conversationId }),
transformReplyPayload: ({ payload }) => {
if (!payload.text || !hasLineDirectives(payload.text)) {
return payload;
}
return parseLineDirectives(payload);
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};

View File

@@ -1,12 +1,12 @@
import type { ReplyPayload } from "../runtime-api.js";
import {
createAgendaCard,
createAppleTvRemoteCard,
createDeviceControlCard,
createMediaPlayerCard,
createEventCard,
} from "../../plugin-sdk/line.js";
import type { LineChannelData } from "../../plugin-sdk/line.js";
import type { ReplyPayload } from "../types.js";
createMediaPlayerCard,
} from "./flex-templates.js";
import type { LineChannelData } from "./types.js";
/**
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
@@ -21,8 +21,6 @@ import type { ReplyPayload } from "../types.js";
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
* - [[appletv_remote: name | status]]
*
* Returns the modified payload with directives removed from text and fields populated.
*/
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
let text = payload.text;
@@ -49,7 +47,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
return base.join("&");
};
// Parse [[quick_replies: option1, option2, option3]]
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
if (quickRepliesMatch) {
const options = quickRepliesMatch[1]
@@ -62,7 +59,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(quickRepliesMatch[0], "").trim();
}
// Parse [[location: title | address | latitude | longitude]]
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
if (locationMatch && !lineData.location) {
const parts = locationMatch[1].split("|").map((s) => s.trim());
@@ -82,18 +78,14 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(locationMatch[0], "").trim();
}
// Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]]
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
if (confirmMatch && !lineData.templateMessage) {
const parts = confirmMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [question, yesPart, noPart] = parts;
// Parse yes_label:yes_data format
const [yesLabel, yesData] = yesPart.includes(":")
? yesPart.split(":").map((s) => s.trim())
: [yesPart, yesPart.toLowerCase()];
const [noLabel, noData] = noPart.includes(":")
? noPart.split(":").map((s) => s.trim())
: [noPart, noPart.toLowerCase()];
@@ -111,7 +103,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(confirmMatch[0], "").trim();
}
// Parse [[buttons: title | text | btn1:data1, btn2:data2]]
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
if (buttonsMatch && !lineData.templateMessage) {
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
@@ -120,7 +111,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
const actions = actionsStr.split(",").map((actionStr) => {
const trimmed = actionStr.trim();
// Find first colon delimiter, ignoring URLs without a label.
const colonIndex = (() => {
const index = trimmed.indexOf(":");
if (index === -1) {
@@ -144,7 +134,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
data = trimmed.slice(colonIndex + 1).trim();
}
// Detect action type
if (data.startsWith("http://") || data.startsWith("https://")) {
return { type: "uri" as const, label, uri: data };
}
@@ -159,7 +148,7 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
type: "buttons",
title,
text: bodyText,
actions: actions.slice(0, 4), // LINE limit
actions: actions.slice(0, 4),
altText: `${title}: ${bodyText}`,
};
}
@@ -167,17 +156,13 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(buttonsMatch[0], "").trim();
}
// Parse [[media_player: title | artist | source | imageUrl | playing/paused]]
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
if (mediaPlayerMatch && !lineData.flexMessage) {
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [title, artist, source, imageUrl, statusStr] = parts;
const isPlaying = statusStr?.toLowerCase() === "playing";
// LINE requires HTTPS URLs for images - skip local/HTTP URLs
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
const deviceKey = toSlug(source || title || "media");
const card = createMediaPlayerCard({
title: title || "Unknown Track",
@@ -201,7 +186,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(mediaPlayerMatch[0], "").trim();
}
// Parse [[event: title | date | time | location | description]]
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
if (eventMatch && !lineData.flexMessage) {
const parts = eventMatch[1].split("|").map((s) => s.trim());
@@ -224,7 +208,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(eventMatch[0], "").trim();
}
// Parse [[appletv_remote: name | status]]
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
if (appleTvMatch && !lineData.flexMessage) {
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
@@ -259,13 +242,11 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(appleTvMatch[0], "").trim();
}
// Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
if (agendaMatch && !lineData.flexMessage) {
const parts = agendaMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, eventsStr] = parts;
const events = eventsStr.split(",").map((eventStr) => {
const trimmed = eventStr.trim();
const colonIdx = trimmed.lastIndexOf(":");
@@ -291,13 +272,11 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(agendaMatch[0], "").trim();
}
// Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
if (deviceMatch && !lineData.flexMessage) {
const parts = deviceMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, deviceType, status, controlsStr] = parts;
const deviceKey = toSlug(deviceName || "device");
const controls = controlsStr
? controlsStr.split(",").map((ctrlStr) => {
@@ -322,7 +301,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
text = text.replace(deviceMatch[0], "").trim();
}
// Clean up multiple whitespace/newlines
text = text.replace(/\n{3,}/g, "\n\n").trim();
result.text = text || undefined;
@@ -332,9 +310,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
return result;
}
/**
* Check if text contains any LINE directives
*/
export function hasLineDirectives(text: string): boolean {
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
text,

View File

@@ -265,6 +265,34 @@ describe("line setup wizard", () => {
expect(next?.channels?.line?.accounts?.work?.dmPolicy).toBe("open");
expect(next?.channels?.line?.accounts?.work?.allowFrom).toEqual(["Uroot", "*"]);
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const { lineSetupWizard } = await import("./setup-surface.js");
const configured = await lineSetupWizard.status.resolveConfigured({
cfg: {
channels: {
line: {
defaultAccount: "work",
channelAccessToken: "root-token",
channelSecret: "root-secret",
accounts: {
alerts: {
channelAccessToken: "alerts-token",
channelSecret: "alerts-secret",
},
work: {
channelAccessToken: "",
channelSecret: "",
},
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(false);
});
});
describe("probeLineBot", () => {

View File

@@ -57,8 +57,7 @@ const lineDmPolicy: ChannelSetupDmPolicy = {
},
getCurrent: (cfg, accountId) =>
resolveLineAccount({ cfg, accountId: accountId ?? resolveDefaultLineAccountId(cfg) }).config
.dmPolicy ??
"pairing",
.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) =>
patchLineAccountConfig({
cfg,
@@ -95,15 +94,14 @@ export const lineSetupWizard: ChannelSetupWizard = {
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
accountId
? isLineConfigured(cfg, accountId)
: listLineAccountIds(cfg).some((resolvedAccountId) => isLineConfigured(cfg, resolvedAccountId)),
isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listLineAccountIds(cfg).length || 0}`],
}),
introNote: {
title: "LINE Messaging API",
lines: LINE_SETUP_HELP_LINES,
shouldShow: ({ cfg, accountId }) => !isLineConfigured(cfg, accountId),
shouldShow: ({ cfg, accountId }) =>
!isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
},
credentials: [
{

View File

@@ -0,0 +1,16 @@
export {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "./src/matrix/thread-bindings.js";
export { setMatrixRuntime } from "./src/runtime.js";
export {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./src/setup-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { matrixSetupAdapter } from "./src/setup-core.js";
export { matrixSetupWizard } from "./src/setup-surface.js";

View File

@@ -30,10 +30,10 @@ export type {
OpenClawConfig,
PluginRuntime,
RuntimeLogger,
RuntimeEnv,
WizardPrompter,
} from "openclaw/plugin-sdk/matrix-runtime-shared";
export { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
} from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export { formatZonedTimestamp } from "openclaw/plugin-sdk/core";
export function chunkTextForOutbound(text: string, limit: number): string[] {
const chunks: string[] = [];

View File

@@ -0,0 +1 @@
export * from "./src/runtime-heavy-api.js";

View File

@@ -1,142 +1,124 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "./account-selection.js";
import type { CoreConfig } from "./types.js";
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
describe("Matrix account selection topology", () => {
it("includes a top-level default account when its auth is actually complete", () => {
const cfg = {
describe("matrix account selection", () => {
it("resolves configured account ids from non-canonical account keys", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accessToken: "default-token",
accounts: {
ops: {
"Team Ops": { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
});
it("matches the default account against normalized Matrix account keys", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
defaultAccount: "Team Ops",
accounts: {
"Ops Bot": { homeserver: "https://matrix.example.org" },
"Team Ops": { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
});
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: { homeserver: "https://matrix.example.org" },
alerts: { homeserver: "https://matrix.example.org" },
},
},
},
};
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
});
it("finds the raw Matrix account entry by normalized account id", () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"Team Ops": {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
userId: "@ops:example.org",
},
},
},
},
} as CoreConfig;
};
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
});
});
it("discovers env-backed named Matrix accounts during enumeration", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
const cfg: OpenClawConfig = {
channels: {
matrix: {},
},
};
const env = {
[keys.homeserver]: "https://matrix.example.org",
[keys.accessToken]: "secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("treats mixed default and named env-backed Matrix accounts as multi-account", () => {
const keys = getMatrixScopedEnvVarNames("team-ops");
const cfg: OpenClawConfig = {
channels: {
matrix: {},
},
};
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
} as NodeJS.ProcessEnv;
MATRIX_ACCESS_TOKEN: "default-secret",
[keys.homeserver]: "https://matrix.example.org",
[keys.accessToken]: "team-secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true);
});
it("does not materialize a top-level default account from partial shared auth fields", () => {
const cfg = {
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
});
it("does not materialize a default env account from partial global auth fields", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "shared-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("does not materialize a top-level default account from homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
});
it("does not materialize a default env account from global homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
it("discovers default Matrix accounts backed only by global env vars", () => {
const cfg: OpenClawConfig = {};
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@default:example.org",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
MATRIX_ACCESS_TOKEN: "default-secret",
} satisfies NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("counts env-backed named accounts when shared homeserver comes from channel config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("keeps env-backed named accounts that rely on cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
});
});

View File

@@ -0,0 +1,194 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
import {
MATRIX_DEFAULT_ACCESS_TOKEN,
MATRIX_DEFAULT_DEVICE_ID,
MATRIX_DEFAULT_USER_ID,
MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_ACCOUNT_ID,
MATRIX_OPS_DEVICE_ID,
MATRIX_OPS_USER_ID,
MATRIX_TEST_HOMESERVER,
writeFile,
writeMatrixCredentials,
} from "./test-helpers.js";
function createDefaultMatrixConfig(): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
},
},
};
}
function writeDefaultLegacyCryptoFixture(home: string) {
const stateDir = path.join(home, ".openclaw");
const cfg = createDefaultMatrixConfig();
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
});
writeFile(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
);
return { cfg, rootDir };
}
function createOpsLegacyCryptoFixture(params: {
home: string;
accessToken?: string;
includeStoredCredentials?: boolean;
}) {
const stateDir = path.join(params.home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
);
if (params.includeStoredCredentials) {
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
deviceId: MATRIX_OPS_DEVICE_ID,
});
}
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
accountId: MATRIX_OPS_ACCOUNT_ID,
});
return { rootDir };
}
describe("matrix legacy encrypted-state migration", () => {
afterEach(() => {});
it("extracts a saved backup key into the new recovery-key path", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
});
});
it("skips migration when no legacy Matrix plans exist", async () => {
await withTempHome(async () => {
const result = await autoPrepareLegacyMatrixCrypto({
cfg: createDefaultMatrixConfig(),
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [],
});
});
});
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 15, backedUp: 10 },
backupVersion: null,
decryptionKeyBase64: null,
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
);
const state = JSON.parse(
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
) as { restoreStatus: string };
expect(state.restoreStatus).toBe("manual-action-required");
});
});
it("prefers stored credentials for named accounts when config is token-only", async () => {
await withTempHome(async (home) => {
const { rootDir } = createOpsLegacyCryptoFixture({
home,
includeStoredCredentials: true,
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
},
},
},
};
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_OPS_DEVICE_ID,
roomKeyCounts: { total: 1, backedUp: 1 },
backupVersion: "1",
decryptionKeyBase64: "b3Bz",
}),
},
});
expect(result.migrated).toBe(true);
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(true);
});
});
});

View File

@@ -1,23 +1,18 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugin-sdk/json-store.js";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixLegacyFlatStoragePaths,
} from "./matrix-config-helpers.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
import {
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,
} from "./matrix-migration-config.js";
import {
MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE,
isMatrixLegacyCryptoInspectorAvailable,
loadMatrixLegacyCryptoInspector,
type MatrixLegacyCryptoInspector,
} from "./matrix-plugin-helper.js";
} from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.";
type MatrixLegacyCryptoCounts = {
total: number;
@@ -75,6 +70,27 @@ type MatrixLegacyCryptoPrepareDeps = {
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
};
type MatrixLegacyCryptoInspectorParams = {
cryptoRootDir: string;
userId: string;
deviceId: string;
log?: (message: string) => void;
};
type MatrixLegacyCryptoInspectorResult = {
deviceId: string | null;
roomKeyCounts: {
total: number;
backedUp: number;
} | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoInspector = (
params: MatrixLegacyCryptoInspectorParams,
) => Promise<MatrixLegacyCryptoInspectorResult>;
type MatrixLegacyBotSdkMetadata = {
deviceId: string | null;
};
@@ -91,6 +107,15 @@ type MatrixStoredRecoveryKey = {
};
};
function isMatrixLegacyCryptoInspectorAvailable(): boolean {
return true;
}
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
const module = await import("./matrix/legacy-crypto-inspector.js");
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
}
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
detected: boolean;
warning?: string;
@@ -301,13 +326,7 @@ export function detectLegacyMatrixCrypto(params: {
cfg: params.cfg,
env: params.env ?? process.env,
});
if (
detection.plans.length > 0 &&
!isMatrixLegacyCryptoInspectorAvailable({
cfg: params.cfg,
env: params.env,
})
) {
if (detection.plans.length > 0 && !isMatrixLegacyCryptoInspectorAvailable()) {
return {
plans: detection.plans,
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
@@ -346,10 +365,7 @@ export async function autoPrepareLegacyMatrixCrypto(params: {
let inspectLegacyStore = params.deps?.inspectLegacyStore;
if (!inspectLegacyStore) {
try {
inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
cfg: params.cfg,
env,
});
inspectLegacyStore = await loadMatrixLegacyCryptoInspector();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!warnings.includes(message)) {

View File

@@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf-8");
}
describe("matrix legacy state migration", () => {
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected a migratable Matrix legacy state plan");
}
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
});
});
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-from-cache",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected cached credentials to make Matrix migration resolvable");
}
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
});
});
});

View File

@@ -1,10 +1,10 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-config-helpers.js";
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
export type MatrixLegacyStateMigrationResult = {
migrated: boolean;

View File

@@ -6,4 +6,4 @@ export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
} from "./runtime-heavy-api.js";

View File

@@ -0,0 +1,20 @@
export const MATRIX_MEDIA_SIZE_LIMIT_ERROR_MESSAGE = "Matrix media exceeds configured size limit";
export class MatrixMediaSizeLimitError extends Error {
readonly code = "MATRIX_MEDIA_SIZE_LIMIT" as const;
constructor(message = MATRIX_MEDIA_SIZE_LIMIT_ERROR_MESSAGE, options?: ErrorOptions) {
super(message, options);
this.name = "MatrixMediaSizeLimitError";
}
}
export function isMatrixMediaSizeLimitError(err: unknown): err is MatrixMediaSizeLimitError {
if (err instanceof MatrixMediaSizeLimitError) {
return true;
}
if (!(err instanceof Error) || err.cause === undefined) {
return false;
}
return isMatrixMediaSizeLimitError(err.cause);
}

View File

@@ -26,9 +26,13 @@ function resolveMatrixMediaLabel(
function formatMatrixAttachmentMarker(params: {
kind?: MatrixMessageAttachmentKind;
tooLarge?: boolean;
unavailable?: boolean;
}): string {
const label = resolveMatrixMediaLabel(params.kind);
if (params.tooLarge) {
return `[matrix ${label} too large]`;
}
return params.unavailable ? `[matrix ${label} unavailable]` : `[matrix ${label}]`;
}
@@ -96,6 +100,7 @@ export function resolveMatrixMessageBody(params: {
export function formatMatrixAttachmentText(params: {
attachment?: MatrixMessageAttachmentSummary;
tooLarge?: boolean;
unavailable?: boolean;
}): string | undefined {
if (!params.attachment) {
@@ -103,6 +108,7 @@ export function formatMatrixAttachmentText(params: {
}
return formatMatrixAttachmentMarker({
kind: params.attachment.kind,
tooLarge: params.tooLarge,
unavailable: params.unavailable,
});
}
@@ -110,11 +116,13 @@ export function formatMatrixAttachmentText(params: {
export function formatMatrixMessageText(params: {
body?: string;
attachment?: MatrixMessageAttachmentSummary;
tooLarge?: boolean;
unavailable?: boolean;
}): string | undefined {
const body = params.body?.trim() ?? "";
const marker = formatMatrixAttachmentText({
attachment: params.attachment,
tooLarge: params.tooLarge,
unavailable: params.unavailable,
});
if (!marker) {
@@ -145,3 +153,17 @@ export function formatMatrixMediaUnavailableText(params: {
}) ?? ""
);
}
export function formatMatrixMediaTooLargeText(params: {
body?: string;
filename?: string;
msgtype?: string;
}): string {
return (
formatMatrixMessageText({
body: resolveMatrixMessageBody(params),
attachment: resolveMatrixMessageAttachment(params),
tooLarge: true,
}) ?? ""
);
}

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
import { MatrixMediaSizeLimitError } from "../media-errors.js";
import {
createMatrixHandlerTestHarness,
createMatrixRoomMessageEvent,
@@ -9,9 +10,13 @@ const { downloadMatrixMediaMock } = vi.hoisted(() => ({
downloadMatrixMediaMock: vi.fn(),
}));
vi.mock("./media.js", () => ({
downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args),
}));
vi.mock("./media.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./media.js")>();
return {
...actual,
downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args),
};
});
function createMediaFailureHarness() {
const logger = {
@@ -211,4 +216,52 @@ describe("createMatrixRoomMessageHandler media failures", () => {
}),
);
});
it("shows a too-large marker when the download is rejected due to size limit", async () => {
downloadMatrixMediaMock.mockRejectedValue(new MatrixMediaSizeLimitError());
const { handler, recordInboundSession } = createMediaFailureHarness();
await handler(
"!room:example.org",
createImageEvent({
msgtype: "m.image",
body: "big-photo.jpg",
url: "mxc://example/big-image",
}),
);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
RawBody: "[matrix image attachment too large]",
CommandBody: "[matrix image attachment too large]",
MediaPath: undefined,
}),
}),
);
});
it("preserves a real caption while marking the attachment too large on size limit error", async () => {
downloadMatrixMediaMock.mockRejectedValue(new MatrixMediaSizeLimitError());
const { handler, recordInboundSession } = createMediaFailureHarness();
await handler(
"!room:example.org",
createImageEvent({
msgtype: "m.image",
body: "check this out",
filename: "large-photo.jpg",
url: "mxc://example/big-image",
}),
);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
RawBody: "check this out\n\n[matrix image attachment too large]",
CommandBody: "check this out\n\n[matrix image attachment too large]",
}),
}),
);
});
});

View File

@@ -4,7 +4,9 @@ import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runti
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
import { createMatrixDraftStream } from "../draft-stream.js";
import { isMatrixMediaSizeLimitError } from "../media-errors.js";
import {
formatMatrixMediaTooLargeText,
formatMatrixMediaUnavailableText,
formatMatrixMessageText,
resolveMatrixMessageAttachment,
@@ -139,6 +141,7 @@ function resolveMatrixInboundBodyText(params: {
msgtype?: string;
hadMediaUrl: boolean;
mediaDownloadFailed: boolean;
mediaSizeLimitExceeded?: boolean;
}): string {
if (params.mediaPlaceholder) {
return params.rawBody || params.mediaPlaceholder;
@@ -146,6 +149,13 @@ function resolveMatrixInboundBodyText(params: {
if (!params.mediaDownloadFailed || !params.hadMediaUrl) {
return params.rawBody;
}
if (params.mediaSizeLimitExceeded) {
return formatMatrixMediaTooLargeText({
body: params.rawBody,
filename: params.filename,
msgtype: params.msgtype,
});
}
return formatMatrixMediaUnavailableText({
body: params.rawBody,
filename: params.filename,
@@ -766,6 +776,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
placeholder: string;
} | null = null;
let mediaDownloadFailed = false;
let mediaSizeLimitExceeded = false;
const finalContentUrl =
"url" in content && typeof content.url === "string" ? content.url : undefined;
const finalContentFile =
@@ -795,6 +806,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
});
} catch (err) {
mediaDownloadFailed = true;
if (isMatrixMediaSizeLimitError(err)) {
mediaSizeLimitExceeded = true;
}
const errorText = err instanceof Error ? err.message : String(err);
logVerboseMessage(
`matrix: media download failed room=${roomId} id=${event.event_id ?? "unknown"} type=${content.msgtype} error=${errorText}`,
@@ -817,6 +831,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
msgtype: content.msgtype,
hadMediaUrl: Boolean(finalMediaUrl),
mediaDownloadFailed,
mediaSizeLimitExceeded,
});
if (!bodyText) {
await commitInboundEventIfClaimed();

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../../../runtime-api.js";
import { setMatrixRuntime } from "../../runtime.js";
import { MatrixMediaSizeLimitError } from "../media-errors.js";
import { downloadMatrixMedia } from "./media.js";
function createEncryptedClient() {
@@ -111,12 +112,31 @@ describe("downloadMatrixMedia", () => {
maxBytes: 1024,
file,
}),
).rejects.toThrow("Matrix media exceeds configured size limit");
).rejects.toBeInstanceOf(MatrixMediaSizeLimitError);
expect(decryptMedia).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
});
it("preserves typed size-limit errors from plain media downloads", async () => {
const tooLargeError = new MatrixMediaSizeLimitError(
"Matrix media exceeds configured size limit (8192 bytes > 4096 bytes)",
);
const downloadContent = vi.fn().mockRejectedValue(tooLargeError);
const client = {
downloadContent,
} as unknown as import("../sdk.js").MatrixClient;
await expect(
downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
maxBytes: 4096,
}),
).rejects.toBe(tooLargeError);
});
it("passes byte limits through plain media downloads", async () => {
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("plain"));

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